diff --git a/CHANGELOG.md b/CHANGELOG.md index 68a23199..1ef7e4a2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -36,6 +36,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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. +- While a session works, the window draws 24 frames a second, film's rate, instead of 30, and output from the session's terminal is drawn on the busy ring's next frame instead of asking for frames of its own: a streaming agent and a spinning ring cost one frame rate, not the sum of the two. What you type still shows the moment the terminal echoes it. - The lich window uses half the processor it did while a session works. Every icon, ring and arc on screen was rebuilt from its outline on every frame; each is now built once and kept on the graphics card. ## [0.83.0] - 2026-10-29 diff --git a/native/cmd/lich-native/main.go b/native/cmd/lich-native/main.go index e53ef576..b5e41564 100644 --- a/native/cmd/lich-native/main.go +++ b/native/cmd/lich-native/main.go @@ -222,11 +222,12 @@ func run(w *app.Window, rt backend.Runtime) error { th.Shaper = text.NewShaper(text.WithCollection(append(append(gofont.Collection(), sans...), faces...))) th.Fg, th.Bg = ui.Dark.Foreground, ui.Dark.Background + beat := &ui.LoopBeat{} u := &window{ ctx: ctx, client: client, th: th, win: w, zoom: defaultZoom, browserZoom: defaultZoom, browserViews: newBrowserQueue(), - theme: &ui.Theme{Palette: ui.Dark, Shaper: th.Shaper, Sans: fonts.Sans, Mono: faces[0].Font.Typeface, Surface: ui.Dark.Canvas}, + theme: &ui.Theme{Palette: ui.Dark, Shaper: th.Shaper, Sans: fonts.Sans, Mono: faces[0].Font.Typeface, Surface: ui.Dark.Canvas, Beat: beat}, model: newModel(client, w.Invalidate), - terms: &terminals{client: client, invalidate: w.Invalidate, renderer: *flagRenderer, family: *flagFont, + terms: &terminals{client: client, invalidate: w.Invalidate, beat: beat, renderer: *flagRenderer, family: *flagFont, size: unit.Sp(*flagSize), byID: map[string]*termView{}}, } u.appearance = &appearance{ctx: ctx, client: client, invalidate: w.Invalidate} diff --git a/native/cmd/lich-native/sidebar_services_test.go b/native/cmd/lich-native/sidebar_services_test.go index 6ccb6b4a..d15e2bdb 100644 --- a/native/cmd/lich-native/sidebar_services_test.go +++ b/native/cmd/lich-native/sidebar_services_test.go @@ -30,8 +30,8 @@ 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, inPulse); !ok || !at.Equal(inPulse.Add(42*time.Millisecond)) { + t.Errorf("mid-pulse the beat asked for %v (asked %v), want the next frame 42ms 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) diff --git a/native/cmd/lich-native/terminals.go b/native/cmd/lich-native/terminals.go index 93cbb4a0..d312d80b 100644 --- a/native/cmd/lich-native/terminals.go +++ b/native/cmd/lich-native/terminals.go @@ -105,9 +105,16 @@ type terminals struct { client *backend.Client stream *backend.Stream invalidate func() - renderer string - family string - size unit.Sp + // beat is the window's looping frame: output that lands while one is + // due rides it, so a busy ring and a streaming agent cost one frame + // rate, not two. + beat *ui.LoopBeat + // sent is when the window last wrote to a PTY. What comes back in the + // next echoWindow is the answer to a keystroke, drawn at once. + sent atomic.Int64 + renderer string + family string + size unit.Sp // unzoomed is the window's own scale, which terminals draw at whatever // the interface zoom: a zoom step never changes a grid's cols×rows. unzoomed unit.Metric @@ -193,11 +200,21 @@ func (ts *terminals) onOutput(id string, data []byte) { return } t.vt.Write(data) - if shown { + if shown && !ts.rides(time.Now()) { ts.invalidate() } } +// echoWindow is how long after a write to the PTY its output still counts as +// the echo of that write. +const echoWindow = 200 * time.Millisecond + +// rides reports whether output at now can wait for the looping frame already +// due, which is never while a keystroke's echo may be in it. +func (ts *terminals) rides(now time.Time) bool { + return ts.beat.Pending(now) && now.UnixNano()-ts.sent.Load() > int64(echoWindow) +} + // onReplay seeds a terminal attach left waiting with its session's tail. func (ts *terminals) onReplay(id string, tail []byte) { ts.mu.Lock() @@ -232,6 +249,7 @@ func (ts *terminals) send(id string, b []byte) { if t != nil && t.readOnly.Load() { return } + ts.sent.Store(time.Now().UnixNano()) if err := stream.Send(context.Background(), id, b); err != nil { log.Printf("send to %s: %v", id, err) } diff --git a/native/cmd/lich-native/terminals_rides_test.go b/native/cmd/lich-native/terminals_rides_test.go new file mode 100644 index 00000000..c9644889 --- /dev/null +++ b/native/cmd/lich-native/terminals_rides_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "testing" + "time" + + "gioui.org/io/input" + "gioui.org/layout" + "gioui.org/op" + "gioui.org/unit" + + "github.com/lichdotdev/lich/native/gioterm" + "github.com/lichdotdev/lich/native/ui" +) + +// ridingTerminals is a window's terminals with one shown session and the +// window's beat, counting the frames output asks for. +func ridingTerminals(t *testing.T) (*terminals, *ui.Theme, *int) { + t.Helper() + frames := 0 + beat := &ui.LoopBeat{} + ts := &terminals{byID: map[string]*termView{}, shown: map[string]bool{"s": true}, beat: beat, invalidate: func() { frames++ }} + vt, err := gioterm.New(80, 24, 10, 20, func([]byte) {}) + if err != nil { + t.Fatal(err) + } + tv := &termView{id: "s", vt: vt} + tv.seeded.Store(true) + ts.byID["s"] = tv + th := &ui.Theme{Palette: ui.Dark, Surface: ui.Dark.Background, Beat: beat} + return ts, th, &frames +} + +// spin draws a looping animation at now, as a busy ring does each frame. +func spin(th *ui.Theme, now time.Time) { + var r input.Router + gtx := layout.Context{Ops: new(op.Ops), Now: now, Source: r.Source(), Metric: unit.Metric{PxPerDp: 1, PxPerSp: 1}} + th.Loop(gtx) +} + +func TestOutputRidesTheLoopingFrame(t *testing.T) { + ts, th, frames := ridingTerminals(t) + ts.onOutput("s", []byte("a")) + if *frames != 1 { + t.Fatalf("with nothing looping, output asked for %d frames, want its own", *frames) + } + spin(th, time.Now()) + ts.onOutput("s", []byte("b")) + if *frames != 1 { + t.Errorf("with a looping frame due, output asked for %d frames, want to ride it", *frames) + } + spin(th, time.Now().Add(-ui.LoopFrame)) + ts.onOutput("s", []byte("c")) + if *frames != 2 { + t.Errorf("with the looping frame past due, output asked for %d frames, want its own", *frames) + } +} + +func TestAKeystrokesEchoNeverWaits(t *testing.T) { + ts, th, frames := ridingTerminals(t) + spin(th, time.Now()) + ts.sent.Store(time.Now().UnixNano()) + ts.onOutput("s", []byte("a")) + if *frames != 1 { + t.Errorf("output right after a write to the PTY asked for %d frames, want one at once", *frames) + } + ts.sent.Store(time.Now().Add(-echoWindow - time.Millisecond).UnixNano()) + ts.onOutput("s", []byte("b")) + if *frames != 1 { + t.Errorf("output past the echo window asked for %d frames, want to ride the loop", *frames) + } +} diff --git a/native/ui/spinner.go b/native/ui/spinner.go index fdf94b67..df9844ae 100644 --- a/native/ui/spinner.go +++ b/native/ui/spinner.go @@ -4,6 +4,7 @@ import ( "image" "image/color" "math" + "sync/atomic" "time" "gioui.org/f32" @@ -21,16 +22,34 @@ 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 +// this is the window's rate while one runs: 24fps, film's rate, reads as +// smooth on a glyph, where the display's rate cost 60% of a laptop GPU +// (measured 2026-11-01). +const LoopFrame = 42 * time.Millisecond + +// LoopBeat is when the next looping frame is due, shared by every Theme of a +// window: output that arrives before it waits for that frame instead of +// asking for one of its own. +type LoopBeat struct { + due atomic.Int64 +} + +// Pending reports whether a looping frame is due after now. +func (b *LoopBeat) Pending(now time.Time) bool { + return b != nil && b.due.Load() > now.UnixNano() +} // 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)}) + if th.Still { + return + } + at := gtx.Now.Add(LoopFrame) + if th.Beat != nil { + th.Beat.due.Store(at.UnixNano()) } + gtx.Execute(op.InvalidateCmd{At: at}) } var icSpinner = icons.Lucide("loader-circle") diff --git a/native/ui/spinner_test.go b/native/ui/spinner_test.go index 859e9ac0..a19e13df 100644 --- a/native/ui/spinner_test.go +++ b/native/ui/spinner_test.go @@ -18,10 +18,10 @@ func loopWake(w func(C) D) (time.Time, bool) { // 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) { +// 42ms later, never at once. +func TestLoopingAnimationsRedrawAt24fps(t *testing.T) { th := testTheme() - want := time.Unix(100, 0).Add(33 * time.Millisecond) + want := time.Unix(100, 0).Add(42 * 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) }, @@ -32,6 +32,28 @@ func TestLoopingAnimationsRedrawAt30fps(t *testing.T) { } } +func TestLoopRecordsTheFrameItAskedFor(t *testing.T) { + th := testTheme() + th.Beat = &LoopBeat{} + now := time.Unix(100, 0) + if th.Beat.Pending(now) { + t.Fatal("a beat nobody asked for is pending") + } + loopWake(func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) }) + if !th.Beat.Pending(now.Add(41 * time.Millisecond)) { + t.Error("41ms after the spinner drew, its frame is still to come") + } + if th.Beat.Pending(now.Add(42 * time.Millisecond)) { + t.Error("42ms after the spinner drew, its frame is due") + } + th.Still = true + th.Beat = &LoopBeat{} + loopWake(func(gtx C) D { return th.Spinner(gtx, 16, th.Foreground) }) + if th.Beat.Pending(now) { + t.Error("a still window's spinner recorded a frame it did not ask for") + } +} + func TestStillThemeHoldsTheFrame(t *testing.T) { th := testTheme() th.Still = true diff --git a/native/ui/theme.go b/native/ui/theme.go index 8afd0beb..ca59e1ea 100644 --- a/native/ui/theme.go +++ b/native/ui/theme.go @@ -102,6 +102,9 @@ type Theme struct { // 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 + // Beat is the window's, kept across On: where Loop records the frame + // it asked for. + Beat *LoopBeat } // On returns a copy of th drawing on surface, for the children of a widget