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 @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- `lich quit` no longer fails with "EOF" when lich quits before answering: it waits for the port to close instead of calling the lost reply a refusal.
- On Wayland, the native window shows the desktop's own cursor, in its theme and size, where it could show another theme. On a monitor with a smaller scale than the largest one, the cursor no longer jumps as it enters the window.
- On Linux, the native window groups under lich's own icon in the dock, where it showed up as a separate app with a generic icon.
- In the native window's Review and a pull request's Files, the scrollbar keeps its size as you scroll, where it grew and shrank with whichever file filled the view. Dragging it now moves through the diff evenly, not file by file.

## [0.71.2] - 2026-10-19

Expand Down
1 change: 1 addition & 0 deletions native/cmd/lich-native/dock_review.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ type reviewPanel struct {
cards map[string]*fileCard
order []string
list widget.List
heights itemHeights
batch commentBatch
actions reviewActions

Expand Down
2 changes: 1 addition & 1 deletion native/cmd/lich-native/dock_review_body.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ func (p *reviewPanel) fileList(gtx C, th *ui.Theme) {
if squeezed {
count++
}
th.ScrollArea(&p.list).Layout(gtx, count, func(gtx C, i int) D {
p.scrollArea(gtx, th, count, func(gtx C, i int) D {
if squeezed {
if i == 0 {
return p.squeezedNote(gtx, th)
Expand Down
91 changes: 91 additions & 0 deletions native/cmd/lich-native/dock_review_scroll.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package main

import (
"image"

"gioui.org/layout"
"gioui.org/unit"

"github.com/lichdotdev/lich/native/ui"
)

// itemHeights is each list item's height as last laid out, so the scrollbar
// measures the files instead of guessing: layout.List sizes the list as its
// visible items' average times their count, and a 6-line card beside a
// 250-line one swings that guess with every scroll.
type itemHeights struct {
key heightsKey
px []int // 0 for an item not laid out since the key last changed
}

// heightsKey is what resizes every card at once, offscreen ones included.
type heightsKey struct {
width int
split, hiding bool
bulk int
family string
size unit.Sp
}

func (h *itemHeights) reset(key heightsKey, count int) {
if key == h.key && len(h.px) == count {
return
}
h.key, h.px = key, make([]int, count)
}

// scrollSpan is where a viewport of the given height sits over the items, as
// fractions of their total height, and that total. An item not laid out yet
// counts as the average of those that were.
func scrollSpan(px []int, first, offset, viewport int) (start, end float32, total int) {
measured, sum := 0, 0
for _, h := range px {
if h > 0 {
measured++
sum += h
}
}
if measured == 0 {
return 0, 1, 0
}
average := sum / measured
above := 0
for i, h := range px {
if h == 0 {
h = average
}
total += h
if i < first {
above += h
}
}
top := float32(above+offset) / float32(total)
start = min(max(top, 0), 1)
end = min(max(top+float32(viewport)/float32(total), 0), 1)
return start, end, total
}

// scrollArea is th.ScrollArea's list for items of very different heights:
// the bar is drawn from the heights measured in p.heights, not layout.List's
// estimate.
func (p *reviewPanel) scrollArea(gtx C, th *ui.Theme, count int, item layout.ListElement) {
p.heights.reset(heightsKey{width: gtx.Constraints.Max.X, split: p.split, hiding: p.hiding,
bulk: p.bulk, family: p.family, size: p.size}, count)
bar := th.ScrollArea(&p.list).ScrollbarStyle
barWidth := gtx.Dp(bar.Width())
inner := gtx
inner.Constraints.Max.X = max(inner.Constraints.Max.X-barWidth, 0)
inner.Constraints.Min.X = max(inner.Constraints.Min.X-barWidth, 0)
dims := p.list.List.Layout(inner, count, func(gtx C, i int) D {
dims := item(gtx, i)
p.heights.px[i] = dims.Size.Y
return dims
})
pos := p.list.Position
start, end, total := scrollSpan(p.heights.px, pos.First, pos.Offset, dims.Size.Y)
gtx.Constraints.Min = image.Pt(dims.Size.X+barWidth, dims.Size.Y)
layout.E.Layout(gtx, func(gtx C) D { return bar.Layout(gtx, layout.Vertical, start, end) })
if d := p.list.ScrollDistance(); d != 0 {
p.list.Position.Offset += int(d * float32(total))
}
}
61 changes: 61 additions & 0 deletions native/cmd/lich-native/dock_review_scroll_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package main

import (
"math"
"testing"
)

func near(a, b float32) bool { return math.Abs(float64(a-b)) < 1e-6 }

// A short card and a long one: the thumb keeps its length whichever of them
// fills the viewport.
func TestScrollSpanThumbHoldsAcrossUnevenCards(t *testing.T) {
px := []int{60, 300, 2500}
start, end, total := scrollSpan(px, 0, 0, 800)
if total != 2860 || start != 0 || !near(end, 800.0/2860) {
t.Fatalf("top: start %v end %v total %d", start, end, total)
}
start, end, _ = scrollSpan(px, 2, 1000, 800)
if !near(start, 1360.0/2860) || !near(end, 2160.0/2860) {
t.Fatalf("inside the long card: start %v end %v", start, end)
}
}

func TestScrollSpanCountsUnmeasuredAsAverage(t *testing.T) {
start, end, total := scrollSpan([]int{100, 0, 300}, 2, 0, 100)
if total != 600 || !near(start, 300.0/600) || !near(end, 400.0/600) {
t.Fatalf("start %v end %v total %d", start, end, total)
}
}

func TestScrollSpanNothingMeasuredIsNotScrollable(t *testing.T) {
if start, end, total := scrollSpan([]int{0, 0}, 0, 0, 500); start != 0 || end != 1 || total != 0 {
t.Fatalf("start %v end %v total %d", start, end, total)
}
}

func TestScrollSpanClampsPastTheEnd(t *testing.T) {
if _, end, _ := scrollSpan([]int{100, 100}, 1, 50, 500); end != 1 {
t.Fatalf("end %v", end)
}
}

func TestItemHeightsResetOnlyWhenTheKeyOrCountMoves(t *testing.T) {
var h itemHeights
key := heightsKey{width: 600}
h.reset(key, 2)
h.px[0] = 40
h.reset(key, 2)
if h.px[0] != 40 {
t.Fatal("same key and count dropped the heights")
}
h.reset(heightsKey{width: 700}, 2)
if h.px[0] != 0 {
t.Fatal("a new width kept the old heights")
}
h.px[0] = 40
h.reset(heightsKey{width: 700}, 3)
if len(h.px) != 3 || h.px[0] != 0 {
t.Fatal("a new count kept the old heights")
}
}
Loading