diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ef7e4a2d..9e0919b8f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -35,8 +35,9 @@ 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. +- 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 24 times a second, and 8 times a second while the lich window is not focused, so a session's ring is still seen turning from another monitor. A running service's dot beats every six seconds instead of every two and a half: each beat is a burst of frames, and the old pace was eight frames a second all day for a dot. - 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 terminal no longer redraws the cursor's row on every frame while the cursor stays put; the row is recomposed when the cursor moves or its text changes. - 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/browser_model_test.go b/native/cmd/lich-native/browser_model_test.go index 17eb61be9..74e8887f5 100644 --- a/native/cmd/lich-native/browser_model_test.go +++ b/native/cmd/lich-native/browser_model_test.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "time" @@ -198,7 +199,10 @@ func TestBrowserFrameSurplusDecodesOnlyTheNewest(t *testing.T) { if _, f, _ := m.browserView("orch"); f.size != image.Pt(9, 6) { t.Fatalf("frame size %v, want the newest", f.size) } - if logged.Len() > 0 { + // Decoding the stale frame logs under the event's name. The log is the + // process's: a layout save of an earlier test, failing after that test's + // backend closed, lands here too and is not this test's to judge. + if strings.Contains(logged.String(), name) { t.Fatalf("decoded a frame a newer one replaced: %s", logged.String()) } } diff --git a/native/cmd/lich-native/sidebar_services.go b/native/cmd/lich-native/sidebar_services.go index 1b960e0d5..0b2b79757 100644 --- a/native/cmd/lich-native/sidebar_services.go +++ b/native/cmd/lich-native/sidebar_services.go @@ -233,12 +233,13 @@ func beatingDot(gtx C, th *ui.Theme) D { } // 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. +// 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. Every frame costs the window the same whatever +// it shows (about 0.3% of a laptop GPU, measured 2026-11-01), and a beat +// every 2.4s was 8 frames a second all day for a 14dp dot. const ( - pulseEvery = 2400 * time.Millisecond + pulseEvery = 6 * time.Second pulseLength = 800 * time.Millisecond ) @@ -247,9 +248,7 @@ const ( 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 { - if !th.Still { - gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(pulseEvery - at)}) - } + gtx.Execute(op.InvalidateCmd{At: gtx.Now.Add(pulseEvery - at)}) return } phase := float32(at) / float32(pulseLength) diff --git a/native/cmd/lich-native/sidebar_services_test.go b/native/cmd/lich-native/sidebar_services_test.go index d15e2bdb3..4955e83ad 100644 --- a/native/cmd/lich-native/sidebar_services_test.go +++ b/native/cmd/lich-native/sidebar_services_test.go @@ -28,18 +28,19 @@ func beatWake(th *ui.Theme, now time.Time) (time.Time, bool) { 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) + beat := time.UnixMilli(6 * 1000) inPulse, resting := beat.Add(100*time.Millisecond), beat.Add(1000*time.Millisecond) 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) + if at, ok := beatWake(th, resting); !ok || !at.Equal(beat.Add(6*time.Second)) { + t.Errorf("at rest the beat asked for %v (asked %v), want the next beat 6s on", 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) - } + if at, ok := beatWake(th, inPulse); !ok || !at.Equal(inPulse.Add(125*time.Millisecond)) { + t.Errorf("on a still window mid-pulse the beat asked for %v (asked %v), want the slow frame 125ms on", at, ok) + } + if at, ok := beatWake(th, resting); !ok || !at.Equal(beat.Add(6*time.Second)) { + t.Errorf("on a still window at rest the beat asked for %v (asked %v), want the next beat", at, ok) } } diff --git a/native/gioterm/render_rows.go b/native/gioterm/render_rows.go index 0287ed188..595ef6a05 100644 --- a/native/gioterm/render_rows.go +++ b/native/gioterm/render_rows.go @@ -22,15 +22,16 @@ type rowRenderer struct { size unit.Sp imgs []*image.RGBA ops []paint.ImageOp - // cursorRow is the row the cursor was composed into last frame, -1 for none. - cursorRow int + // cursorRow and cursorX are where the cursor was composed last frame, + // -1 for none: a row is recomposed for the cursor only when it moves. + cursorRow, cursorX int // clear is what the rows' default background was composed in: the // terminal's own, or transparent while an image lies under the text. clear color.NRGBA } func newRowRenderer(r *glyphs.Rasterizer, size unit.Sp) *rowRenderer { - return &rowRenderer{r: r, size: size, cursorRow: -1} + return &rowRenderer{r: r, size: size, cursorRow: -1, cursorX: -1} } func (rr *rowRenderer) Cell() (w, h int) { return rr.r.CellW(), rr.r.CellH() } @@ -61,17 +62,22 @@ func (rr *rowRenderer) draw(gtx layout.Context, sn snap, cells []cell, dirty []u dirty[y] = 1 } } - // The cursor lives inside its row's image, so the row it left and the row - // it is on are both recomposed. - cursorRow := -1 + // The cursor lives inside its row's image, so when it moves the row it + // left and the row it is on are both recomposed; where it stays, its + // row's image already holds it. + cursorRow, cursorX := -1, -1 if sn.cursorVisible && sn.cursorX < sn.cols && sn.cursorY < sn.rows { - cursorRow = sn.cursorY - dirty[cursorRow] = 1 + cursorRow, cursorX = sn.cursorY, sn.cursorX } - if rr.cursorRow >= 0 && rr.cursorRow < sn.rows { - dirty[rr.cursorRow] = 1 + if cursorRow != rr.cursorRow || cursorX != rr.cursorX { + if cursorRow >= 0 { + dirty[cursorRow] = 1 + } + if rr.cursorRow >= 0 && rr.cursorRow < sn.rows { + dirty[rr.cursorRow] = 1 + } } - rr.cursorRow = cursorRow + rr.cursorRow, rr.cursorX = cursorRow, cursorX clear := rgba(sn.bg) if len(below) > 0 { clear = color.NRGBA{} diff --git a/native/gioterm/render_rows_cursor_test.go b/native/gioterm/render_rows_cursor_test.go new file mode 100644 index 000000000..41d854fb8 --- /dev/null +++ b/native/gioterm/render_rows_cursor_test.go @@ -0,0 +1,31 @@ +package gioterm + +import "testing" + +// The cursor is composed into its row's image, so a cursor that stays put +// costs nothing: the row is only recomposed when the cursor moves, and then +// the row it left comes with it. +func TestRowsRecomposeForTheCursorOnlyWhenItMoves(t *testing.T) { + r, err := NewRenderer(RenderRows, "monospace", 14) + if err != nil { + t.Fatal(err) + } + vt, err := New(40, 6, 8, 16, func([]byte) {}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(vt.Close) + vt.Write([]byte("hello")) + drawFrame(t, r, vt) + if _, redrawn := drawFrame(t, r, vt); redrawn != 0 { + t.Errorf("with nothing changed, redrew %d rows, want none", redrawn) + } + vt.Write([]byte("!")) + if _, redrawn := drawFrame(t, r, vt); redrawn != 1 { + t.Errorf("with the cursor moved along its row, redrew %d rows, want that row", redrawn) + } + vt.Write([]byte("\r\n")) + if _, redrawn := drawFrame(t, r, vt); redrawn != 2 { + t.Errorf("with the cursor moved to the next row, redrew %d rows, want the row it left and the one it is on", redrawn) + } +} diff --git a/native/ui/spinner.go b/native/ui/spinner.go index df9844aed..412a53c72 100644 --- a/native/ui/spinner.go +++ b/native/ui/spinner.go @@ -27,6 +27,10 @@ const spinLap = time.Second // (measured 2026-11-01). const LoopFrame = 42 * time.Millisecond +// StillFrame is LoopFrame for a window without focus: an eye across the +// room still sees the ring turn, at a fifth of the frames. +const StillFrame = 125 * 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. @@ -39,13 +43,14 @@ 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. +// Loop asks for a looping animation's next frame: LoopFrame from now, or +// StillFrame while the window is Still. func (th *Theme) Loop(gtx layout.Context) { + frame := LoopFrame if th.Still { - return + frame = StillFrame } - at := gtx.Now.Add(LoopFrame) + at := gtx.Now.Add(frame) if th.Beat != nil { th.Beat.due.Store(at.UnixNano()) } diff --git a/native/ui/spinner_test.go b/native/ui/spinner_test.go index a19e13df6..65dae0aad 100644 --- a/native/ui/spinner_test.go +++ b/native/ui/spinner_test.go @@ -49,20 +49,22 @@ func TestLoopRecordsTheFrameItAskedFor(t *testing.T) { 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") + if !th.Beat.Pending(now.Add(124 * time.Millisecond)) { + t.Error("a still window's spinner did not record the slow frame it asked for") } } -func TestStillThemeHoldsTheFrame(t *testing.T) { +// A window without focus is still seen: its animations go on at 8fps. +func TestStillThemeSlowsTheLoop(t *testing.T) { th := testTheme() th.Still = true + want := time.Unix(100, 0).Add(125 * 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 { - t.Errorf("%s on a still window asked for a frame at %v, want none", name, at) + if at, ok := loopWake(w); !ok || !at.Equal(want) { + t.Errorf("%s on a still window asked for its next frame at %v (asked %v), want %v", name, at, ok, want) } } } diff --git a/native/ui/theme.go b/native/ui/theme.go index ca59e1ea4..88fb7efcd 100644 --- a/native/ui/theme.go +++ b/native/ui/theme.go @@ -99,8 +99,8 @@ 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 is set while the window has no focus: looping animations slow + // to StillFrame, a window seen from across the room. Still bool // Beat is the window's, kept across On: where Loop records the frame // it asked for.