This commit is contained in:
iceBear67
2026-07-26 14:03:51 +00:00
parent 7eb35f82b1
commit b24cfcd560
4 changed files with 96 additions and 85 deletions
+77 -51
View File
@@ -63,7 +63,10 @@ type App struct {
th *Theme th *Theme
fonts *FontSet 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 nav []navEntry
current pageID current pageID
@@ -78,14 +81,6 @@ type App struct {
themeBtn widget.Clickable 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 toastMsg string
toastLevel StatusLevel toastLevel StatusLevel
toastUntil time.Time toastUntil time.Time
@@ -138,66 +133,98 @@ func New(opt Options) *App {
return a return a
} }
// Run opens the window and drives the event loop. It returns when the window // Run shows the GUI and returns when it closes.
// 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 { func (a *App) Run(ctx context.Context) error {
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) w := new(app.Window)
// Opens at splash size; grown to the shell dimensions below once the if shell {
// service is ready. w.Option(
app.Title("tslink"),
app.Size(shellWindowW, shellWindowH),
app.MinSize(shellMinW, shellMinH),
)
} else {
w.Option( w.Option(
app.Title("tslink"), app.Title("tslink"),
app.Size(splashWindowW, splashWindowH), app.Size(splashWindowW, splashWindowH),
app.MinSize(splashMinW, splashMinH), app.MinSize(splashMinW, splashMinH),
) )
a.win = w }
a.win.Store(w)
go a.watch(ctx, w)
go a.upgradeFonts()
// Ctrl+C at the terminal cancels ctx. Without this the supervisor tears // 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 // down but the window survives — the GUI is the process, so cancelling it
// GUI is the process, so cancelling it has to close the window too. // 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() { go func() {
<-ctx.Done() select {
case <-ctx.Done():
w.Perform(system.ActionClose) 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 var ops op.Ops
for { for {
switch e := w.Event().(type) { switch e := w.Event().(type) {
case app.DestroyEvent: case app.DestroyEvent:
return e.Err return handoff, e.Err
case app.FrameEvent: case app.FrameEvent:
gtx := app.NewContext(&ops, e) gtx := app.NewContext(&ops, e)
a.applyFontUpgrade() 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) e.Frame(gtx.Ops)
// After the frame, so the resize is not applied midway through if !shell && a.state().Ready() {
// laying one out against the old constraints. Once grown this is a handoff = true
// single atomic load. w.Perform(system.ActionClose)
if !a.grown.Load() && a.state().Ready() {
a.grow(w)
} }
} }
} }
} }
// grow resizes the window from its compact splash dimensions to the full shell, // invalidate schedules a repaint of whichever window is currently shown. It is
// once, when the service comes up. // a no-op before the first window exists and is safe from any goroutine.
// func (a *App) invalidate() {
// It must run on the goroutine that drives the event loop. On Linux the driver if w := a.win.Load(); w != nil {
// 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
}
w.Option(
app.Size(shellWindowW, shellWindowH),
app.MinSize(shellMinW, shellMinH),
)
w.Invalidate() w.Invalidate()
} }
}
// upgradeFonts parses the system CJK font off the UI goroutine. The splash // 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 // screen exists partly to cover this: a 20 MB font collection takes long
@@ -217,9 +244,7 @@ func (a *App) upgradeFonts() {
} }
select { select {
case a.fontUpgrade <- faces: case a.fontUpgrade <- faces:
if a.win != nil { a.invalidate()
a.win.Invalidate()
}
default: default:
} }
} }
@@ -237,7 +262,7 @@ func (a *App) applyFontUpgrade() {
// watch coalesces change notifications from every data source into window // watch coalesces change notifications from every data source into window
// invalidations, capped so a burst of log lines cannot drive the render loop. // 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 chans []<-chan struct{}
var cancels []func() var cancels []func()
defer func() { defer func() {
@@ -299,7 +324,7 @@ func (a *App) watch(ctx context.Context, w *app.Window) {
case <-throttle.C: case <-throttle.C:
if dirty { if dirty {
dirty = false dirty = false
w.Invalidate() a.invalidate()
} }
} }
} }
@@ -346,16 +371,17 @@ func (a *App) notify(msg string, level StatusLevel) {
a.toastMsg = msg a.toastMsg = msg
a.toastLevel = level a.toastLevel = level
a.toastUntil = time.Now().Add(3200 * time.Millisecond) a.toastUntil = time.Now().Add(3200 * time.Millisecond)
if a.win != nil { a.invalidate()
a.win.Invalidate()
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Layout // 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 th := a.th
paint.Fill(gtx.Ops, th.P.Bg) paint.Fill(gtx.Ops, th.P.Bg)
@@ -378,7 +404,7 @@ func (a *App) layout(gtx C) D {
return layout.Stack{}.Layout(gtx, return layout.Stack{}.Layout(gtx,
layout.Stacked(func(gtx C) D { layout.Stacked(func(gtx C) D {
gtx.Constraints.Min = gtx.Constraints.Max gtx.Constraints.Min = gtx.Constraints.Max
if !st.Ready() { if forceSplash || !st.Ready() {
// The splash owns the whole window until the service is up. // The splash owns the whole window until the service is up.
return a.splash.Layout(a, gtx, st) return a.splash.Layout(a, gtx, st)
} }
+2 -6
View File
@@ -106,9 +106,7 @@ func (p *diagPage) run() {
} }
p.progress[pr.Key] = pr p.progress[pr.Key] = pr
p.mu.Unlock() p.mu.Unlock()
if a.win != nil { a.invalidate()
a.win.Invalidate()
}
}, },
}) })
p.mu.Lock() p.mu.Lock()
@@ -117,9 +115,7 @@ func (p *diagPage) run() {
p.lastRun = time.Now() p.lastRun = time.Now()
p.cancel = nil p.cancel = nil
p.mu.Unlock() p.mu.Unlock()
if a.win != nil { a.invalidate()
a.win.Invalidate()
}
}() }()
} }
+1 -3
View File
@@ -205,9 +205,7 @@ func (p *logsPage) startUpload(a *App, text string) {
} else { } else {
a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK) a.notify(a.th.T(KLogsUploaded)+" "+res.URL, LevelOK)
} }
if a.win != nil { a.invalidate()
a.win.Invalidate()
}
}() }()
} }
+9 -18
View File
@@ -7,7 +7,6 @@ import (
"testing" "testing"
"time" "time"
"gioui.org/app"
"gioui.org/io/input" "gioui.org/io/input"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
@@ -321,25 +320,17 @@ func TestTrFallsBackToEnglish(t *testing.T) {
} }
} }
// TestGrowOnce guards the window-resize latch. Growing more than once would // TestLayoutForceSplash exercises the top-level frame that runWindow drives.
// snap a window the user had deliberately resized back to the shell default // The splash window passes forceSplash=true; the shell window passes false.
// every time the service restarted. // With no supervisor the state is not ready, so both must fall to the splash
func TestGrowOnce(t *testing.T) { // 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 := testApp(t)
// A Window with no driver queues options instead of touching a display, for _, forceSplash := range []bool{true, false} {
// which is what makes this testable without one. gtx, _ := newTestContext(image.Pt(int(shellWindowW), int(shellWindowH)))
w := new(app.Window) a.layout(gtx, forceSplash)
if a.grown.Load() {
t.Fatal("a fresh App must not be marked grown")
} }
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 // TestStatTilesUniformHeight guards the overview's top row. The tiles sit in a