diff --git a/gui/app.go b/gui/app.go index 120fafe..add921d 100644 --- a/gui/app.go +++ b/gui/app.go @@ -63,7 +63,10 @@ type App struct { th *Theme fonts *FontSet - win *app.Window + // win is the window currently on screen. It is replaced when the splash + // hands off to the shell, so background goroutines load it through + // [App.invalidate] rather than capturing a single window. + win atomic.Pointer[app.Window] nav []navEntry current pageID @@ -78,14 +81,6 @@ type App struct { themeBtn widget.Clickable - // grown records that the window has already been resized from its compact - // splash dimensions to the full shell. The splash sizes the window to just - // its progress bar and checklist, so the transition to the shell has to grow - // it — exactly once, or a user who resized the window would have it snapped - // back every time the service restarted. Atomic because the resize runs on - // the watch goroutine, not the UI one. - grown atomic.Bool - toastMsg string toastLevel StatusLevel toastUntil time.Time @@ -138,65 +133,97 @@ func New(opt Options) *App { return a } -// Run opens the window and drives the event loop. It returns when the window -// closes. +// Run shows the GUI and returns when it closes. +// +// It opens two windows in sequence: a compact splash sized to its progress +// checklist during boot, then a full-size shell once the service is ready. +// Each window is created at its final size. Growing a window at runtime — which +// is what an in-place splash-to-shell transition would need — is unreliable +// across compositors (Wayland in particular refuses client-driven resizes on +// some of them), so opening a correctly sized window is the dependable path. func (a *App) Run(ctx context.Context) error { - w := new(app.Window) - // Opens at splash size; grown to the shell dimensions below once the - // service is ready. - w.Option( - app.Title("tslink"), - app.Size(splashWindowW, splashWindowH), - app.MinSize(splashMinW, splashMinH), - ) - a.win = w - - go a.watch(ctx, w) + go a.watch(ctx) go a.upgradeFonts() + + // The splash runs until the service is ready, then closes itself and asks + // the caller to open the shell. Any other exit — the user closing the + // window, or ctx being cancelled — quits. + proceed, err := a.runWindow(ctx, false) + if err != nil || !proceed || ctx.Err() != nil { + return err + } + _, err = a.runWindow(ctx, true) + return err +} + +// runWindow creates one window and drives its event loop: the compact splash +// (shell=false) or the full-size shell (shell=true). +// +// It reports proceed=true only for the splash's ready handoff — the service +// came up, so the splash closed itself and the caller should open the shell. +// A window closed by the user or by ctx cancellation returns proceed=false, +// which quits the app. +func (a *App) runWindow(ctx context.Context, shell bool) (proceed bool, err error) { + w := new(app.Window) + if shell { + w.Option( + app.Title("tslink"), + app.Size(shellWindowW, shellWindowH), + app.MinSize(shellMinW, shellMinH), + ) + } else { + w.Option( + app.Title("tslink"), + app.Size(splashWindowW, splashWindowH), + app.MinSize(splashMinW, splashMinH), + ) + } + a.win.Store(w) + // Ctrl+C at the terminal cancels ctx. Without this the supervisor tears - // down but the window survives, dropping the user back to the splash — the - // GUI is the process, so cancelling it has to close the window too. + // down but the window survives — the GUI is the process, so cancelling it + // has to close the window too. Scoped to this window and stopped when the + // loop returns, so it never reaches across the handoff to the next one. + stop := make(chan struct{}) + defer close(stop) go func() { - <-ctx.Done() - w.Perform(system.ActionClose) + select { + case <-ctx.Done(): + w.Perform(system.ActionClose) + case <-stop: + } }() + // handoff records that we closed the splash because the service came up, so + // the resulting DestroyEvent means "open the shell" rather than "quit". + handoff := false var ops op.Ops for { switch e := w.Event().(type) { case app.DestroyEvent: - return e.Err + return handoff, e.Err case app.FrameEvent: gtx := app.NewContext(&ops, e) a.applyFontUpgrade() - a.layout(gtx) + // The splash window always draws the splash, even on the frame + // where the service first reports ready: otherwise the shell would + // flash cramped in the compact window for one frame before handoff. + a.layout(gtx, !shell) e.Frame(gtx.Ops) - // After the frame, so the resize is not applied midway through - // laying one out against the old constraints. Once grown this is a - // single atomic load. - if !a.grown.Load() && a.state().Ready() { - a.grow(w) + if !shell && a.state().Ready() { + handoff = true + w.Perform(system.ActionClose) } } } } -// grow resizes the window from its compact splash dimensions to the full shell, -// once, when the service comes up. -// -// It must run on the goroutine that drives the event loop. On Linux the driver -// executes Window.Option's work inline on the calling goroutine rather than -// handing it to a UI thread, so calling this from anywhere else would mutate -// driver state concurrently with event dispatch. -func (a *App) grow(w *app.Window) { - if a.grown.Swap(true) { - return +// invalidate schedules a repaint of whichever window is currently shown. It is +// a no-op before the first window exists and is safe from any goroutine. +func (a *App) invalidate() { + if w := a.win.Load(); w != nil { + w.Invalidate() } - w.Option( - app.Size(shellWindowW, shellWindowH), - app.MinSize(shellMinW, shellMinH), - ) - w.Invalidate() } // upgradeFonts parses the system CJK font off the UI goroutine. The splash @@ -217,9 +244,7 @@ func (a *App) upgradeFonts() { } select { case a.fontUpgrade <- faces: - if a.win != nil { - a.win.Invalidate() - } + a.invalidate() default: } } @@ -237,7 +262,7 @@ func (a *App) applyFontUpgrade() { // 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) { +func (a *App) watch(ctx context.Context) { var chans []<-chan struct{} var cancels []func() defer func() { @@ -299,7 +324,7 @@ func (a *App) watch(ctx context.Context, w *app.Window) { case <-throttle.C: if dirty { dirty = false - w.Invalidate() + a.invalidate() } } } @@ -346,16 +371,17 @@ 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() - } + a.invalidate() } // --------------------------------------------------------------------------- // Layout // --------------------------------------------------------------------------- -func (a *App) layout(gtx C) D { +// layout draws one frame. forceSplash keeps the splash on screen even once the +// service is ready, which the compact splash window uses so the shell never +// flashes cramped in it before the handoff to the full-size window. +func (a *App) layout(gtx C, forceSplash bool) D { th := a.th paint.Fill(gtx.Ops, th.P.Bg) @@ -378,7 +404,7 @@ func (a *App) layout(gtx C) D { return layout.Stack{}.Layout(gtx, layout.Stacked(func(gtx C) D { gtx.Constraints.Min = gtx.Constraints.Max - if !st.Ready() { + if forceSplash || !st.Ready() { // The splash owns the whole window until the service is up. return a.splash.Layout(a, gtx, st) } diff --git a/gui/page_diag.go b/gui/page_diag.go index a90fbd2..e2d513d 100644 --- a/gui/page_diag.go +++ b/gui/page_diag.go @@ -106,9 +106,7 @@ func (p *diagPage) run() { } p.progress[pr.Key] = pr p.mu.Unlock() - if a.win != nil { - a.win.Invalidate() - } + a.invalidate() }, }) p.mu.Lock() @@ -117,9 +115,7 @@ func (p *diagPage) run() { p.lastRun = time.Now() p.cancel = nil p.mu.Unlock() - if a.win != nil { - a.win.Invalidate() - } + a.invalidate() }() } diff --git a/gui/page_logs.go b/gui/page_logs.go index e8bb1c7..f6ec40a 100644 --- a/gui/page_logs.go +++ b/gui/page_logs.go @@ -205,9 +205,7 @@ func (p *logsPage) startUpload(a *App, text string) { } else { a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK) } - if a.win != nil { - a.win.Invalidate() - } + a.invalidate() }() } diff --git a/gui/render_test.go b/gui/render_test.go index 4713ebc..c8b7319 100644 --- a/gui/render_test.go +++ b/gui/render_test.go @@ -7,7 +7,6 @@ import ( "testing" "time" - "gioui.org/app" "gioui.org/io/input" "gioui.org/layout" "gioui.org/op" @@ -321,25 +320,17 @@ func TestTrFallsBackToEnglish(t *testing.T) { } } -// TestGrowOnce guards the window-resize latch. Growing more than once would -// snap a window the user had deliberately resized back to the shell default -// every time the service restarted. -func TestGrowOnce(t *testing.T) { +// TestLayoutForceSplash exercises the top-level frame that runWindow drives. +// The splash window passes forceSplash=true; the shell window passes false. +// With no supervisor the state is not ready, so both must fall to the splash +// branch and lay out without panicking — the guard for the compile-time change +// to layout's signature and the forceSplash branch it added. +func TestLayoutForceSplash(t *testing.T) { a := testApp(t) - // A Window with no driver queues options instead of touching a display, - // which is what makes this testable without one. - w := new(app.Window) - - if a.grown.Load() { - t.Fatal("a fresh App must not be marked grown") + for _, forceSplash := range []bool{true, false} { + gtx, _ := newTestContext(image.Pt(int(shellWindowW), int(shellWindowH))) + a.layout(gtx, forceSplash) } - a.grow(w) - if !a.grown.Load() { - t.Fatal("grow() must latch") - } - // Must be a no-op now. - a.grow(w) - a.grow(w) } // TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a