diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b750ae55..e60cb781c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -32,6 +32,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - A file input in the session browser, such as a profile picture upload, opens the system's file chooser when you click it, and takes several files when the input allows it. Before, the click did nothing. - Leave on a page in the session browser that asks before you go no longer reloads the page instead on a busy machine; the browser goes where it was going. - An agent clicking a point in the session browser no longer misses it after you move lich to another monitor, resize the browser pane or change its zoom. Its screenshots are now in the same pixels as its clicks, which a HiDPI monitor or a page zoom had made up to 1.5 times larger, and a click at a point read off a screenshot taken before the pane changed size is refused, telling the agent to take a new one. +- lich no longer keeps the GPU busy while a session works. A project tab's spinner redrew the whole window at the monitor's refresh rate, and with sessions busy in more than one project lich held over half of a laptop GPU, taking it from games and everything else. Every spinner, the sidebar's busy ring and a running service's beat now redraw at most 30 times a second, and hold still while the lich window is not focused, moving again when you come back to it. ## [0.83.0] - 2026-10-29 diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index d85f023f8..e434e1ce5 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -347,6 +347,10 @@ func run(w *app.Window, rt backend.Runtime) error { u.model.setFocused(u.ctx, e.Config.Focused) u.pulls.setFocused(e.Config.Focused) u.toaster.SetHidden(!e.Config.Focused) + if still := !e.Config.Focused; still != u.theme.Still { + u.theme.Still = still + w.Invalidate() + } case *app.ClosingEvent: u.onClosing(e) case app.DropEvent: diff --git a/native/cmd/lich-native/pulls_view.go b/native/cmd/lich-native/pulls_view.go index c88f973de..1c7e7aff2 100644 --- a/native/cmd/lich-native/pulls_view.go +++ b/native/cmd/lich-native/pulls_view.go @@ -384,10 +384,10 @@ func stat(gtx C, th *ui.Theme, ic *icons.Icon, text string, fg color.NRGBA) D { ) } -// pullGlyph is ic at size, icPullRunning drawn as a ui.Ring. +// pullGlyph is ic at size, icPullRunning drawn as a BusyRing. func pullGlyph(gtx C, th *ui.Theme, ic *icons.Icon, size unit.Dp, fg color.NRGBA) D { if ic == icPullRunning { - return ui.Ring(gtx, size, fg, th.RingTrack(fg)) + return th.BusyRing(gtx, size, fg, th.RingTrack(fg)) } return ic.Layout(gtx, size, fg) } diff --git a/native/cmd/lich-native/settings_profiles_dialogs.go b/native/cmd/lich-native/settings_profiles_dialogs.go index 9593bcc7e..2bfd275e3 100644 --- a/native/cmd/lich-native/settings_profiles_dialogs.go +++ b/native/cmd/lich-native/settings_profiles_dialogs.go @@ -486,7 +486,7 @@ func pluginResult(gtx C, th *ui.Theme, r backend.ProfilePluginResult) D { // spinnerLine is LoaderCircle turning one lap a second beside a muted label. func spinnerLine(gtx C, th *ui.Theme, label string) D { return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { return ui.Spinner(gtx, ui.Space(4), th.MutedForeground) }), + layout.Rigid(func(gtx C) D { return th.Spinner(gtx, ui.Space(4), th.MutedForeground) }), ui.Gap(2), layout.Rigid(th.Text(ui.TextSM, label).Muted().Weight(font.Normal).Layout), ) diff --git a/native/cmd/lich-native/settings_updates.go b/native/cmd/lich-native/settings_updates.go index 542cbb3fd..09db03052 100644 --- a/native/cmd/lich-native/settings_updates.go +++ b/native/cmd/lich-native/settings_updates.go @@ -176,7 +176,7 @@ func busyButton(gtx C, th *ui.Theme, b ui.ButtonStyle, busy bool) D { return b.Layout(gtx) } return layout.Flex{Alignment: layout.Middle}.Layout(gtx, - layout.Rigid(func(gtx C) D { return ui.Spinner(gtx, ui.Space(4), th.MutedForeground) }), + layout.Rigid(func(gtx C) D { return th.Spinner(gtx, ui.Space(4), th.MutedForeground) }), ui.Gap(2), layout.Rigid(b.Layout), ) diff --git a/native/cmd/lich-native/sidebar_card.go b/native/cmd/lich-native/sidebar_card.go index 9048fc99f..7b4d97248 100644 --- a/native/cmd/lich-native/sidebar_card.go +++ b/native/cmd/lich-native/sidebar_card.go @@ -334,11 +334,6 @@ func (s *sidebar) statusRing(gtx C, th *ui.Theme, sv *sessionView) D { return s.morph.glyph(gtx, glyphSession+sv.ID, func(gtx C) D { return s.statusGlyph(gtx, th, sv) }) } -// spinnerFrame is how often a busy session's ring redraws. The ring shares -// the window's frame, so its rate is the whole window's repaint rate: 30fps -// reads as smooth on a 22px ring and costs half of a display's. -const spinnerFrame = 33 * time.Millisecond - func (s *sidebar) statusGlyph(gtx C, th *ui.Theme, sv *sessionView) D { size := gtx.Dp(22) stroke := float32(gtx.Dp(1.25)) @@ -349,7 +344,7 @@ func (s *sidebar) statusGlyph(gtx C, th *ui.Theme, sv *sessionView) D { ui.StrokeArc(gtx, center, radius, stroke, 0, 2*math.Pi, th.Over(ui.Alpha(th.MutedForeground, 0.25))) turn := float32(time.Since(s.started).Seconds()) * 2 * math.Pi ui.StrokeArc(gtx, center, radius, stroke, turn, math.Pi/2, th.MutedForeground) - gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(spinnerFrame)}) + th.Loop(gtx) case "waiting": ui.StrokeArc(gtx, center, radius, stroke, 0, 2*math.Pi, th.ToneWait) case "done": diff --git a/native/cmd/lich-native/sidebar_rail_footer.go b/native/cmd/lich-native/sidebar_rail_footer.go index dc909de37..5472e27e2 100644 --- a/native/cmd/lich-native/sidebar_rail_footer.go +++ b/native/cmd/lich-native/sidebar_rail_footer.go @@ -103,7 +103,7 @@ func railServicesBadge(gtx C, th *ui.Theme, t servicesTally) D { r := float32(gtx.Dp(3)) center := f32.Pt(float32(size.X)-r, r) if !t.failed { - pulseRing(gtx, center, r, float32(gtx.Dp(3)), c) + pulseRing(gtx, th, center, r, float32(gtx.Dp(3)), c) } ui.StrokeArc(gtx, center, r+float32(gtx.Dp(1)), float32(gtx.Dp(2)), 0, 2*math.Pi, th.Surface) ui.StrokeArc(gtx, center, r/2, r, 0, 2*math.Pi, c) diff --git a/native/cmd/lich-native/sidebar_services.go b/native/cmd/lich-native/sidebar_services.go index 977fe65c6..1b960e0d5 100644 --- a/native/cmd/lich-native/sidebar_services.go +++ b/native/cmd/lich-native/sidebar_services.go @@ -227,13 +227,13 @@ func beatingDot(gtx C, th *ui.Theme) D { size := gtx.Dp(14) center := f32.Pt(float32(size)/2, float32(size)/2) dot := float32(gtx.Dp(3.5)) - pulseRing(gtx, center, dot, float32(gtx.Dp(3.5)), th.TonePass) + pulseRing(gtx, th, center, dot, float32(gtx.Dp(3.5)), th.TonePass) ui.StrokeArc(gtx, center, dot/2, dot, 0, 2*math.Pi, th.TonePass) return D{Size: image.Pt(size, size)} } -// The running dot beats: a ring grows out of it for pulseLength, drawn at the -// busy spinner's rate, then rests until pulseEvery comes round. It beats for +// The running dot beats: a ring grows out of it for pulseLength, drawn at +// ui.LoopFrame, then rests until pulseEvery comes round. It beats for // as long as a service runs, hours, so the rest is what keeps it cheap: no // frame is asked for in it. Measured with one service up, a continuous pulse // at a tenth of that rate already held the window at 6% of a core. @@ -244,16 +244,18 @@ const ( // pulseRing is that beat around center: a ring from radius to radius+reach // that fades as it grows, and the next frame it needs. -func pulseRing(gtx C, center f32.Point, radius, reach float32, c color.NRGBA) { +func pulseRing(gtx C, th *ui.Theme, center f32.Point, radius, reach float32, c color.NRGBA) { at := time.Duration(gtx.Now.UnixMilli()%pulseEvery.Milliseconds()) * time.Millisecond if at >= pulseLength { - gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(pulseEvery - at)}) + if !th.Still { + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(pulseEvery - at)}) + } return } phase := float32(at) / float32(pulseLength) eased := 1 - (1-phase)*(1-phase) ui.StrokeArc(gtx, center, radius+reach*eased, float32(gtx.Dp(1.5)), 0, 2*math.Pi, ui.Alpha(c, 0.7*(1-phase))) - gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(spinnerFrame)}) + th.Loop(gtx) } // stateDot is a service state as a 14dp glyph. diff --git a/native/cmd/lich-native/sidebar_services_test.go b/native/cmd/lich-native/sidebar_services_test.go new file mode 100644 index 000000000..6ccb6b4a5 --- /dev/null +++ b/native/cmd/lich-native/sidebar_services_test.go @@ -0,0 +1,45 @@ +package main + +import ( + "testing" + "time" + + "gioui.org/f32" + "gioui.org/font/gofont" + "gioui.org/io/input" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/text" + "gioui.org/unit" + + "github.com/lichdotdev/lich/native/ui" +) + +// beatWake draws a running service's beat at the instant now and reads the +// frame it asks for next. +func beatWake(th *ui.Theme, now time.Time) (time.Time, bool) { + var r input.Router + ops := new(op.Ops) + gtx := layout.Context{Ops: ops, Now: now, Source: r.Source(), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}} + pulseRing(gtx, th, f32.Pt(7, 7), 3, 3, th.TonePass) + r.Frame(ops) + return r.WakeupTime() +} + +func TestServiceBeatPacing(t *testing.T) { + th := &ui.Theme{Palette: ui.Dark, Shaper: text.NewShaper(text.WithCollection(gofont.Collection())), Surface: ui.Dark.Background} + beat := time.UnixMilli(2400 * 1000) + inPulse, resting := beat.Add(100*time.Millisecond), beat.Add(1000*time.Millisecond) + if at, ok := beatWake(th, inPulse); !ok || !at.Equal(inPulse.Add(33*time.Millisecond)) { + t.Errorf("mid-pulse the beat asked for %v (asked %v), want the next frame 33ms on", at, ok) + } + if at, ok := beatWake(th, resting); !ok || !at.Equal(beat.Add(2400*time.Millisecond)) { + t.Errorf("at rest the beat asked for %v (asked %v), want the next beat", at, ok) + } + th.Still = true + for _, now := range []time.Time{inPulse, resting} { + if at, ok := beatWake(th, now); ok { + t.Errorf("on a still window the beat asked for a frame at %v, want none", at) + } + } +} diff --git a/native/cmd/lich-native/topbar_tabs.go b/native/cmd/lich-native/topbar_tabs.go index eb49c7bfb..a5ddac551 100644 --- a/native/cmd/lich-native/topbar_tabs.go +++ b/native/cmd/lich-native/topbar_tabs.go @@ -308,7 +308,7 @@ func (t *topbar) tabBadge(gtx C, th *ui.Theme, status string) D { var draw func(gtx C) D switch status { case statusBusy, statusCompacting: - draw = func(gtx C) D { return ui.Spinner(gtx, tabGlyph, th.MutedForeground) } + draw = func(gtx C) D { return th.Spinner(gtx, tabGlyph, th.MutedForeground) } case statusWaiting: draw = func(gtx C) D { return icTabWaiting.Layout(gtx, tabGlyph, th.ToneWait) } case statusDone: diff --git a/native/ui/spinner.go b/native/ui/spinner.go index c35bb6a25..1ac4cb963 100644 --- a/native/ui/spinner.go +++ b/native/ui/spinner.go @@ -19,9 +19,23 @@ import ( // spinLap is animate-spin's: one turn a second. const spinLap = time.Second +// LoopFrame is how often a looping animation (a spinner, the busy ring, a +// running service's beat) redraws. Every frame repaints the whole window, so +// this is the window's rate while one runs: 30fps reads as smooth on a glyph, +// where the display's rate cost 60% of a laptop GPU (measured 2026-11-01). +const LoopFrame = 33 * time.Millisecond + +// Loop asks for a looping animation's next frame, LoopFrame from now, unless +// the window is Still: then the animation holds the frame it stands at. +func (th *Theme) Loop(gtx layout.Context) { + if !th.Still { + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(LoopFrame)}) + } +} + var icSpinner = icons.Lucide("loader-circle") -// Ring draws on Lucide's 24-unit grid: loader-circle's r=10 circle and +// BusyRing draws on Lucide's 24-unit grid: loader-circle's r=10 circle and // stroke-width 2, under the sidebar busy ring's 25% track. const ( lucideViewBox = 24 @@ -32,24 +46,24 @@ const ( // Spinner is LoaderCircle under animate-spin, size square, and asks for the // next frame while it is drawn. -func Spinner(gtx layout.Context, size unit.Dp, fg color.NRGBA) layout.Dimensions { +func (th *Theme) Spinner(gtx layout.Context, size unit.Dp, fg color.NRGBA) layout.Dimensions { turn := float64(gtx.Now.UnixNano()%int64(spinLap)) / float64(spinLap) half := float32(gtx.Dp(size)) / 2 defer op.Affine(f32.Affine2D{}.Rotate(f32.Pt(half, half), float32(turn*2*math.Pi))).Push(gtx.Ops).Pop() - gtx.Execute(op.InvalidateCmd{}) + th.Loop(gtx) return icSpinner.Layout(gtx, size, fg) } -// RingTrack is the faint full circle a Ring of fg turns over, composited +// RingTrack is the faint full circle a BusyRing of fg turns over, composited // onto th's surface. func (th *Theme) RingTrack(fg color.NRGBA) color.NRGBA { return th.Over(Alpha(fg, ringTrackOpacity)) } -// Ring is the busy ring at a Lucide circle's size and weight, so it lines up +// BusyRing is the busy ring at a Lucide circle's size and weight, so it lines up // with the glyphs beside it: track all the way round under a quarter arc of // fg turning once a lap. It asks for the next frame while it is drawn. -func Ring(gtx layout.Context, size unit.Dp, fg, track color.NRGBA) layout.Dimensions { +func (th *Theme) BusyRing(gtx layout.Context, size unit.Dp, fg, track color.NRGBA) layout.Dimensions { px := gtx.Dp(size) scale := float32(px) / lucideViewBox center := f32.Pt(float32(px)/2, float32(px)/2) @@ -57,7 +71,7 @@ func Ring(gtx layout.Context, size unit.Dp, fg, track color.NRGBA) layout.Dimens StrokeArc(gtx, center, radius, width, 0, 2*math.Pi, track) turn := float32(gtx.Now.UnixNano()%int64(spinLap)) / float32(spinLap) * 2 * math.Pi StrokeArc(gtx, center, radius, width, turn, math.Pi/2, fg) - gtx.Execute(op.InvalidateCmd{}) + th.Loop(gtx) return layout.Dimensions{Size: image.Pt(px, px)} } diff --git a/native/ui/spinner_test.go b/native/ui/spinner_test.go new file mode 100644 index 000000000..773cce171 --- /dev/null +++ b/native/ui/spinner_test.go @@ -0,0 +1,44 @@ +package ui + +import ( + "testing" + "time" + + "gioui.org/io/input" +) + +// loopWake draws w once at now and reads the frame it asks for next. +func loopWake(w func(C) D) (time.Time, bool) { + var r input.Router + tabsFrame(&r, time.Unix(100, 0), w) + return r.WakeupTime() +} + +// Every frame of a looping animation repaints the whole window: one asking +// at the display's rate held a laptop GPU at 60%, so the next frame comes +// 33ms later, never at once. +func TestLoopingAnimationsRedrawAt30fps(t *testing.T) { + th := testTheme() + want := time.Unix(100, 0).Add(33 * time.Millisecond) + for name, w := range map[string]func(C) D{ + "Spinner": func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) }, + "BusyRing": func(gtx C) D { return th.BusyRing(gtx, 16, th.Foreground, th.Border) }, + } { + if at, ok := loopWake(w); !ok || !at.Equal(want) { + t.Errorf("%s asked for its next frame at %v (asked %v), want %v", name, at, ok, want) + } + } +} + +func TestStillThemeHoldsTheFrame(t *testing.T) { + th := testTheme() + th.Still = true + for name, w := range map[string]func(C) D{ + "Spinner": func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) }, + "BusyRing": func(gtx C) D { return th.BusyRing(gtx, 16, th.Foreground, th.Border) }, + } { + if at, ok := loopWake(w); ok { + t.Errorf("%s on a still window asked for a frame at %v, want none", name, at) + } + } +} diff --git a/native/ui/theme.go b/native/ui/theme.go index 5b3e9e2bb..8afd0beb4 100644 --- a/native/ui/theme.go +++ b/native/ui/theme.go @@ -99,6 +99,9 @@ type Theme struct { // composites translucent tokens onto. A widget that paints a background // hands its children On(that background). Surface color.NRGBA + // Still is set while the window has no focus: looping animations hold + // their frame instead of repainting a window nobody is looking at. + Still bool } // On returns a copy of th drawing on surface, for the children of a widget diff --git a/native/ui/toast_body.go b/native/ui/toast_body.go index 6469427b2..024028aa0 100644 --- a/native/ui/toast_body.go +++ b/native/ui/toast_body.go @@ -310,7 +310,7 @@ func (s ToasterStyle) rowBody(gtx C, th *Theme, e *toastEntry, ic *toastGlyph, l height = max(height, content.size.Y) if ic != nil { height = max(height, gtx.Dp(toastIconBox)) - ic.layout(gtx, (height-gtx.Dp(toastIconBox))/2) + ic.layout(gtx, th, (height-gtx.Dp(toastIconBox))/2) } content.at(gtx, image.Pt(lead, (height-content.size.Y)/2)) x := lead + content.size.X @@ -334,7 +334,7 @@ func (s ToasterStyle) wrappedBody(gtx C, th *Theme, e *toastEntry, ic *toastGlyp height := content.size.Y if ic != nil { height = max(height, gtx.Dp(toastIconBox)) - ic.layout(gtx, 0) + ic.layout(gtx, th, 0) } content.at(gtx, image.Pt(lead, 0)) if len(buttons) == 0 { @@ -433,12 +433,12 @@ func toastGlyphOf(th *Theme, e *toastEntry) *toastGlyph { // layout draws the glyph in its 16px box, top pixels down, as [data-icon] // and its svg's negative margins place it: a 20px one overflows the box. -func (g *toastGlyph) layout(gtx C, top int) { +func (g *toastGlyph) layout(gtx C, th *Theme, top int) { x := -gtx.Dp(toastIconMarginStart) - gtx.Dp(toastIconSVGStart) y := top + (gtx.Dp(toastIconBox)-gtx.Dp(g.size))/2 defer op.Offset(image.Pt(x, y)).Push(gtx.Ops).Pop() if g.spin { - Ring(gtx, g.size, g.color, g.track) + th.BusyRing(gtx, g.size, g.color, g.track) return } g.icon.Layout(gtx, g.size, g.color)