add: gui and tsdiag
This commit is contained in:
+677
@@ -0,0 +1,677 @@
|
||||
package gui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"image"
|
||||
"io"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"gioui.org/app"
|
||||
"gioui.org/font"
|
||||
"gioui.org/io/clipboard"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/text"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
|
||||
"tslink/core"
|
||||
)
|
||||
|
||||
// Options configures the GUI.
|
||||
type Options struct {
|
||||
Version string
|
||||
ConfigPath string
|
||||
ConfigURL string
|
||||
Supervisor *core.Supervisor
|
||||
Logs *core.LogBuffer
|
||||
Logger *slog.Logger
|
||||
// IPInfoToken is passed through to the diagnostics runner.
|
||||
IPInfoToken string
|
||||
// StartDark selects the initial theme.
|
||||
StartDark bool
|
||||
}
|
||||
|
||||
// pageID identifies a top-level view.
|
||||
type pageID int
|
||||
|
||||
const (
|
||||
pageOverview pageID = iota
|
||||
pagePeers
|
||||
pageLan
|
||||
pageDiag
|
||||
pageLogs
|
||||
pageSettings
|
||||
)
|
||||
|
||||
type navEntry struct {
|
||||
id pageID
|
||||
label Key
|
||||
icon IconFunc
|
||||
click widget.Clickable
|
||||
}
|
||||
|
||||
// App is the whole GUI. It owns the window event loop and holds every page's
|
||||
// state.
|
||||
type App struct {
|
||||
opt Options
|
||||
logger *slog.Logger
|
||||
th *Theme
|
||||
fonts *FontSet
|
||||
|
||||
win *app.Window
|
||||
|
||||
nav []navEntry
|
||||
current pageID
|
||||
|
||||
overview *overviewPage
|
||||
peers *peersPage
|
||||
lan *lanPage
|
||||
diag *diagPage
|
||||
logs *logsPage
|
||||
settings *settingsPage
|
||||
|
||||
splash *splashView
|
||||
overlay *logOverlay
|
||||
|
||||
overlayBtn widget.Clickable
|
||||
themeBtn widget.Clickable
|
||||
|
||||
toastMsg string
|
||||
toastLevel StatusLevel
|
||||
toastUntil time.Time
|
||||
|
||||
// fontUpgrade carries CJK faces parsed off the UI goroutine.
|
||||
fontUpgrade chan []font.FontFace
|
||||
|
||||
// needsTick is set during layout when the current frame shows something
|
||||
// that changes with wall-clock time — relative timestamps, uptime, a
|
||||
// running step's elapsed counter. When it is false the periodic refresh is
|
||||
// skipped and the window stops repainting altogether.
|
||||
//
|
||||
// This is not micro-optimisation: a full repaint costs tens of
|
||||
// milliseconds under software rendering (Gio stencils every rounded
|
||||
// rectangle and icon as a path), so a once-a-second refresh of a screen
|
||||
// with nothing time-dependent on it is pure waste.
|
||||
needsTick atomic.Bool
|
||||
}
|
||||
|
||||
// New builds the application.
|
||||
func New(opt Options) *App {
|
||||
logger := opt.Logger
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
fonts := LoadFonts()
|
||||
th := NewTheme(fonts, opt.StartDark)
|
||||
|
||||
a := &App{
|
||||
opt: opt,
|
||||
logger: logger.With("from", "gui"),
|
||||
th: th,
|
||||
fonts: fonts,
|
||||
current: pageOverview,
|
||||
fontUpgrade: make(chan []font.FontFace, 1),
|
||||
}
|
||||
a.nav = []navEntry{
|
||||
{id: pageOverview, label: KNavOverview, icon: IconGrid},
|
||||
{id: pagePeers, label: KNavPeers, icon: IconNodes},
|
||||
{id: pageLan, label: KNavLan, icon: IconBroadcast},
|
||||
{id: pageDiag, label: KNavDiag, icon: IconPulse},
|
||||
{id: pageLogs, label: KNavLogs, icon: IconList},
|
||||
{id: pageSettings, label: KNavSettings, icon: IconSliders},
|
||||
}
|
||||
a.overview = newOverviewPage()
|
||||
a.peers = newPeersPage()
|
||||
a.lan = newLanPage()
|
||||
a.diag = newDiagPage(a)
|
||||
a.logs = newLogsPage(a)
|
||||
a.settings = newSettingsPage(a)
|
||||
a.splash = newSplashView()
|
||||
a.overlay = newLogOverlay()
|
||||
return a
|
||||
}
|
||||
|
||||
// Run opens the window and drives the event loop. It returns when the window
|
||||
// closes.
|
||||
func (a *App) Run(ctx context.Context) error {
|
||||
w := new(app.Window)
|
||||
w.Option(
|
||||
app.Title("tslink"),
|
||||
app.Size(unit.Dp(1120), unit.Dp(740)),
|
||||
app.MinSize(unit.Dp(880), unit.Dp(560)),
|
||||
)
|
||||
a.win = w
|
||||
|
||||
go a.watch(ctx, w)
|
||||
go a.upgradeFonts()
|
||||
|
||||
var ops op.Ops
|
||||
for {
|
||||
switch e := w.Event().(type) {
|
||||
case app.DestroyEvent:
|
||||
return e.Err
|
||||
case app.FrameEvent:
|
||||
gtx := app.NewContext(&ops, e)
|
||||
a.applyFontUpgrade()
|
||||
a.layout(gtx)
|
||||
e.Frame(gtx.Ops)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// upgradeFonts parses the system CJK font off the UI goroutine. The splash
|
||||
// screen exists partly to cover this: a 20 MB font collection takes long
|
||||
// enough to parse that doing it inline would stall the first frame.
|
||||
func (a *App) upgradeFonts() {
|
||||
if !a.fonts.HasCJK || a.fonts.CJKPath == "" {
|
||||
return
|
||||
}
|
||||
faces, err := LoadCJKFaces(a.fonts.CJKPath, a.logger)
|
||||
if err != nil {
|
||||
a.logger.Warn("failed to load cjk font, relying on system fallback",
|
||||
"path", a.fonts.CJKPath, "err", err)
|
||||
return
|
||||
}
|
||||
if len(faces) == 0 {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case a.fontUpgrade <- faces:
|
||||
if a.win != nil {
|
||||
a.win.Invalidate()
|
||||
}
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) applyFontUpgrade() {
|
||||
select {
|
||||
case faces := <-a.fontUpgrade:
|
||||
merged := append(append([]font.FontFace(nil), a.fonts.Collection...), faces...)
|
||||
a.fonts.Collection = merged
|
||||
a.th.Shaper = text.NewShaper(text.WithCollection(merged))
|
||||
a.logger.Debug("shaper upgraded with cjk faces", "faces", len(faces))
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
// watch coalesces change notifications from every data source into window
|
||||
// invalidations, capped so a burst of log lines cannot drive the render loop.
|
||||
func (a *App) watch(ctx context.Context, w *app.Window) {
|
||||
var chans []<-chan struct{}
|
||||
var cancels []func()
|
||||
defer func() {
|
||||
for _, c := range cancels {
|
||||
c()
|
||||
}
|
||||
}()
|
||||
|
||||
if a.opt.Supervisor != nil {
|
||||
ch, cancel := a.opt.Supervisor.Subscribe()
|
||||
chans = append(chans, ch)
|
||||
cancels = append(cancels, cancel)
|
||||
}
|
||||
if a.opt.Logs != nil {
|
||||
ch, cancel := a.opt.Logs.Subscribe()
|
||||
chans = append(chans, ch)
|
||||
cancels = append(cancels, cancel)
|
||||
}
|
||||
|
||||
// A ticker keeps relative timestamps ("3m ago") and the live latency
|
||||
// column honest even when nothing else changed.
|
||||
tick := time.NewTicker(time.Second)
|
||||
defer tick.Stop()
|
||||
|
||||
dirty := false
|
||||
throttle := time.NewTicker(70 * time.Millisecond)
|
||||
defer throttle.Stop()
|
||||
|
||||
agg := make(chan struct{}, 1)
|
||||
for _, ch := range chans {
|
||||
go func(ch <-chan struct{}) {
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case _, ok := <-ch:
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case agg <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
}(ch)
|
||||
}
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-agg:
|
||||
dirty = true
|
||||
case <-tick.C:
|
||||
if a.needsTick.Load() {
|
||||
dirty = true
|
||||
}
|
||||
case <-throttle.C:
|
||||
if dirty {
|
||||
dirty = false
|
||||
w.Invalidate()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// state returns the current supervisor snapshot, or a zero value.
|
||||
func (a *App) state() core.State {
|
||||
if a.opt.Supervisor == nil {
|
||||
return core.State{}
|
||||
}
|
||||
return a.opt.Supervisor.Snapshot()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Clipboard + toast
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// copyToClipboard puts s on the system clipboard and shows a confirmation.
|
||||
func (a *App) copyToClipboard(gtx C, s string, msg string) {
|
||||
gtx.Execute(clipboard.WriteCmd{
|
||||
Type: "application/text",
|
||||
Data: io.NopCloser(strings.NewReader(s)),
|
||||
})
|
||||
if msg == "" {
|
||||
msg = a.th.T(KCopied)
|
||||
}
|
||||
a.notify(msg, LevelOK)
|
||||
}
|
||||
|
||||
// notify shows a transient message at the bottom of the window.
|
||||
func (a *App) notify(msg string, level StatusLevel) {
|
||||
a.toastMsg = msg
|
||||
a.toastLevel = level
|
||||
a.toastUntil = time.Now().Add(3200 * time.Millisecond)
|
||||
if a.win != nil {
|
||||
a.win.Invalidate()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Layout
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func (a *App) layout(gtx C) D {
|
||||
th := a.th
|
||||
paint.Fill(gtx.Ops, th.P.Bg)
|
||||
|
||||
st := a.state()
|
||||
|
||||
// A terminal error screen has nothing that ages; everything else does
|
||||
// (uptime, "last seen", a running step's timer).
|
||||
a.needsTick.Store(st.Phase != core.PhaseError && st.Phase != core.PhaseStopped)
|
||||
|
||||
// Handle nav clicks before drawing so the click lands on this frame.
|
||||
for i := range a.nav {
|
||||
if a.nav[i].click.Clicked(gtx) {
|
||||
a.current = a.nav[i].id
|
||||
}
|
||||
}
|
||||
if a.overlayBtn.Clicked(gtx) {
|
||||
a.overlay.visible = !a.overlay.visible
|
||||
}
|
||||
if a.themeBtn.Clicked(gtx) {
|
||||
th.SetDark(!th.Dark)
|
||||
}
|
||||
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Stacked(func(gtx C) D {
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
if !st.Ready() {
|
||||
// The splash owns the whole window until the service is up.
|
||||
return a.splash.Layout(a, gtx, st)
|
||||
}
|
||||
return a.shell(gtx, st)
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
return a.overlay.Layout(a, gtx, !st.Ready())
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
gtx.Constraints.Min = gtx.Constraints.Max
|
||||
return a.layoutToast(gtx)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
// shell draws the sidebar plus the active page.
|
||||
func (a *App) shell(gtx C, st core.State) D {
|
||||
compact := gtx.Constraints.Max.X < gtx.Dp(1000)
|
||||
return layout.Flex{Axis: layout.Horizontal}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return a.sidebar(gtx, compact)
|
||||
}),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return a.header(gtx, st) }),
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Inset{
|
||||
Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceLG,
|
||||
}.Layout(gtx, func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return a.page(gtx, st)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) page(gtx C, st core.State) D {
|
||||
switch a.current {
|
||||
case pagePeers:
|
||||
return a.peers.Layout(a, gtx, st)
|
||||
case pageLan:
|
||||
return a.lan.Layout(a, gtx, st)
|
||||
case pageDiag:
|
||||
return a.diag.Layout(a, gtx, st)
|
||||
case pageLogs:
|
||||
return a.logs.Layout(a, gtx, st)
|
||||
case pageSettings:
|
||||
return a.settings.Layout(a, gtx, st)
|
||||
default:
|
||||
return a.overview.Layout(a, gtx, st)
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) sidebar(gtx C, compact bool) D {
|
||||
th := a.th
|
||||
w := gtx.Dp(212)
|
||||
if compact {
|
||||
w = gtx.Dp(64)
|
||||
}
|
||||
gtx.Constraints.Min.X = w
|
||||
gtx.Constraints.Max.X = w
|
||||
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D {
|
||||
size := image.Pt(w, gtx.Constraints.Max.Y)
|
||||
paint.FillShape(gtx.Ops, th.P.BgElevated, clip.Rect{Max: size}.Op())
|
||||
// Hairline separating rail from content.
|
||||
paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{
|
||||
Min: image.Pt(size.X-1, 0), Max: size,
|
||||
}.Op())
|
||||
return D{Size: size}
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
gtx.Constraints.Min.X = w
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D { return a.brand(gtx, compact) }),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
children := make([]layout.FlexChild, 0, len(a.nav))
|
||||
for i := range a.nav {
|
||||
children = append(children, layout.Rigid(func(gtx C) D {
|
||||
return a.navItem(gtx, &a.nav[i], compact)
|
||||
}))
|
||||
}
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx, children...)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) brand(gtx C, compact bool) D {
|
||||
th := a.th
|
||||
return layout.Inset{
|
||||
Top: SpaceXL, Bottom: SpaceLG, Left: SpaceLG, Right: SpaceLG,
|
||||
}.Layout(gtx, func(gtx C) D {
|
||||
if compact {
|
||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||
return IconBroadcast(gtx, gtx.Dp(22), th.P.Accent)
|
||||
})
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return IconBroadcast(gtx, gtx.Dp(20), th.P.Accent)
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
l := th.Text(SizeSubtitle, th.P.TextPri, "tslink")
|
||||
l.Font.Weight = font.Bold
|
||||
return l.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(OneLine(th.Caption(th.T(KAppSubtitle))).Layout),
|
||||
)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) navItem(gtx C, n *navEntry, compact bool) D {
|
||||
th := a.th
|
||||
selected := a.current == n.id
|
||||
fg := th.P.TextSec
|
||||
if selected {
|
||||
fg = th.P.TextPri
|
||||
} else if n.click.Hovered() {
|
||||
fg = th.P.TextPri
|
||||
}
|
||||
|
||||
return n.click.Layout(gtx, func(gtx C) D {
|
||||
return layout.Inset{Left: SpaceSM, Right: SpaceSM, Top: 2, Bottom: 2}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D {
|
||||
size := gtx.Constraints.Min
|
||||
switch {
|
||||
case selected:
|
||||
FillRRect(gtx, size, RadiusSM, WithAlpha(th.P.Accent, 0.16))
|
||||
paint.FillShape(gtx.Ops, th.P.Accent, clip.UniformRRect(
|
||||
image.Rect(0, size.Y/2-gtx.Dp(8), gtx.Dp(3), size.Y/2+gtx.Dp(8)),
|
||||
gtx.Dp(2)).Op(gtx.Ops))
|
||||
case n.click.Hovered():
|
||||
FillRRect(gtx, size, RadiusSM, th.P.SurfaceHi)
|
||||
}
|
||||
return D{Size: size}
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
pad := layout.Inset{Top: 9, Bottom: 9, Left: SpaceMD, Right: SpaceMD}
|
||||
if compact {
|
||||
pad = layout.Inset{Top: 10, Bottom: 10}
|
||||
}
|
||||
return pad.Layout(gtx, func(gtx C) D {
|
||||
if compact {
|
||||
return layout.Center.Layout(gtx, func(gtx C) D {
|
||||
return n.icon(gtx, gtx.Dp(19), fg)
|
||||
})
|
||||
}
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return n.icon(gtx, gtx.Dp(17), fg)
|
||||
}),
|
||||
HGap(SpaceMD),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
l := th.Text(SizeBody, fg, th.T(n.label))
|
||||
if selected {
|
||||
l.Font.Weight = font.Medium
|
||||
}
|
||||
return l.Layout(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) header(gtx C, st core.State) D {
|
||||
th := a.th
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D {
|
||||
size := gtx.Constraints.Min
|
||||
paint.FillShape(gtx.Ops, th.P.Border, clip.Rect{
|
||||
Min: image.Pt(0, size.Y-1), Max: size,
|
||||
}.Op())
|
||||
return D{Size: size}
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
gtx.Constraints.Min.X = gtx.Constraints.Max.X
|
||||
return layout.Inset{
|
||||
Left: SpaceXL, Right: SpaceXL, Top: SpaceLG, Bottom: SpaceMD,
|
||||
}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return th.Title(a.pageTitle()).Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D { return a.statusPill(gtx, st) }),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
icon := IconList
|
||||
level := LevelNeutral
|
||||
if a.overlay.visible {
|
||||
level = LevelInfo
|
||||
}
|
||||
return th.IconButton(gtx, &a.overlayBtn, icon, level)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return th.IconButton(gtx, &a.themeBtn, IconGlobe, LevelNeutral)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) pageTitle() string {
|
||||
th := a.th
|
||||
for _, n := range a.nav {
|
||||
if n.id == a.current {
|
||||
return th.T(n.label)
|
||||
}
|
||||
}
|
||||
return "tslink"
|
||||
}
|
||||
|
||||
func (a *App) statusPill(gtx C, st core.State) D {
|
||||
th := a.th
|
||||
var (
|
||||
label string
|
||||
level StatusLevel
|
||||
pulse bool
|
||||
)
|
||||
switch st.Phase {
|
||||
case core.PhaseReady:
|
||||
// Steady state: no animation. See [Theme.StatusDot].
|
||||
label, level = th.T(KStateRunning), LevelOK
|
||||
case core.PhaseStarting:
|
||||
label, level = th.T(KStateConnecting), LevelInfo
|
||||
pulse = true
|
||||
case core.PhaseRetrying:
|
||||
label, level = th.T(KStateRetrying), LevelWarn
|
||||
pulse = true
|
||||
case core.PhaseError:
|
||||
label, level = th.T(KStateError), LevelFail
|
||||
case core.PhaseStopped:
|
||||
label, level = th.T(KStateStopped), LevelNeutral
|
||||
default:
|
||||
label, level = th.T(KStateStarting), LevelNeutral
|
||||
}
|
||||
|
||||
fg := th.StatusColor(level)
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D {
|
||||
FillRRect(gtx, gtx.Constraints.Min, RadiusPill, WithAlpha(fg, 0.13))
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
return layout.Inset{Top: 5, Bottom: 5, Left: SpaceMD, Right: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return th.StatusDot(gtx, level, pulse)
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(th.Text(SizeCaption, fg, label).Layout),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func (a *App) layoutToast(gtx C) D {
|
||||
if a.toastMsg == "" || time.Now().After(a.toastUntil) {
|
||||
return D{}
|
||||
}
|
||||
th := a.th
|
||||
// Keep repainting until the toast expires.
|
||||
gtx.Execute(op.InvalidateCmd{At: a.toastUntil})
|
||||
|
||||
return layout.S.Layout(gtx, func(gtx C) D {
|
||||
return layout.Inset{Bottom: Space2XL}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Stack{}.Layout(gtx,
|
||||
layout.Expanded(func(gtx C) D {
|
||||
FillRRect(gtx, gtx.Constraints.Min, RadiusSM, th.P.SurfaceHi)
|
||||
StrokeRRect(gtx, gtx.Constraints.Min, RadiusSM, 1, th.P.Border)
|
||||
return D{Size: gtx.Constraints.Min}
|
||||
}),
|
||||
layout.Stacked(func(gtx C) D {
|
||||
return layout.Inset{
|
||||
Top: SpaceSM, Bottom: SpaceSM, Left: SpaceLG, Right: SpaceLG,
|
||||
}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
return th.StatusDot(gtx, a.toastLevel, false)
|
||||
}),
|
||||
HGap(SpaceSM),
|
||||
layout.Rigid(th.Text(SizeBody, th.P.TextPri, a.toastMsg).Layout),
|
||||
)
|
||||
})
|
||||
}),
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Section heading used by pages
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// sectionTitle renders a page-level heading with an optional trailing widget.
|
||||
func (a *App) sectionTitle(gtx C, title, subtitle string, trailing layout.Widget) D {
|
||||
th := a.th
|
||||
return layout.Inset{Bottom: SpaceMD}.Layout(gtx, func(gtx C) D {
|
||||
return layout.Flex{Alignment: layout.Middle}.Layout(gtx,
|
||||
layout.Flexed(1, func(gtx C) D {
|
||||
return layout.Flex{Axis: layout.Vertical}.Layout(gtx,
|
||||
layout.Rigid(func(gtx C) D {
|
||||
l := th.Text(SizeSubtitle, th.P.TextPri, title)
|
||||
l.Font.Weight = font.SemiBold
|
||||
return l.Layout(gtx)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if subtitle == "" {
|
||||
return D{}
|
||||
}
|
||||
return th.Caption(subtitle).Layout(gtx)
|
||||
}),
|
||||
)
|
||||
}),
|
||||
layout.Rigid(func(gtx C) D {
|
||||
if trailing == nil {
|
||||
return D{}
|
||||
}
|
||||
return trailing(gtx)
|
||||
}),
|
||||
)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user