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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions native/cmd/lich-native/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions native/cmd/lich-native/pulls_view.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
2 changes: 1 addition & 1 deletion native/cmd/lich-native/settings_profiles_dialogs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
2 changes: 1 addition & 1 deletion native/cmd/lich-native/settings_updates.go
Original file line number Diff line number Diff line change
Expand Up @@ -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),
)
Expand Down
7 changes: 1 addition & 6 deletions native/cmd/lich-native/sidebar_card.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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":
Expand Down
2 changes: 1 addition & 1 deletion native/cmd/lich-native/sidebar_rail_footer.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
14 changes: 8 additions & 6 deletions native/cmd/lich-native/sidebar_services.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down
45 changes: 45 additions & 0 deletions native/cmd/lich-native/sidebar_services_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 1 addition & 1 deletion native/cmd/lich-native/topbar_tabs.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
28 changes: 21 additions & 7 deletions native/ui/spinner.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,32 +46,32 @@ 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)
radius, width := lucideCircleRadius*scale, lucideStroke*scale
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)}
}

Expand Down
44 changes: 44 additions & 0 deletions native/ui/spinner_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
3 changes: 3 additions & 0 deletions native/ui/theme.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 4 additions & 4 deletions native/ui/toast_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down
Loading