Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion native/cmd/lich-native/browser_model_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
"time"

Expand Down Expand Up @@ -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())
}
}
Expand Down
15 changes: 7 additions & 8 deletions native/cmd/lich-native/sidebar_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
)

Expand All @@ -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)
Expand Down
15 changes: 8 additions & 7 deletions native/cmd/lich-native/sidebar_services_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
28 changes: 17 additions & 11 deletions native/gioterm/render_rows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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() }
Expand Down Expand Up @@ -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{}
Expand Down
31 changes: 31 additions & 0 deletions native/gioterm/render_rows_cursor_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 9 additions & 4 deletions native/ui/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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())
}
Expand Down
12 changes: 7 additions & 5 deletions native/ui/spinner_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
4 changes: 2 additions & 2 deletions native/ui/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading