diff --git a/CHANGELOG.md b/CHANGELOG.md index 3784e928..9b7166e7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/native/cmd/lich-native/dock_review.go b/native/cmd/lich-native/dock_review.go index 564cbfde..b35d65e8 100644 --- a/native/cmd/lich-native/dock_review.go +++ b/native/cmd/lich-native/dock_review.go @@ -71,6 +71,7 @@ type reviewPanel struct { cards map[string]*fileCard order []string list widget.List + heights itemHeights batch commentBatch actions reviewActions diff --git a/native/cmd/lich-native/dock_review_body.go b/native/cmd/lich-native/dock_review_body.go index e069963e..eab95348 100644 --- a/native/cmd/lich-native/dock_review_body.go +++ b/native/cmd/lich-native/dock_review_body.go @@ -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) diff --git a/native/cmd/lich-native/dock_review_scroll.go b/native/cmd/lich-native/dock_review_scroll.go new file mode 100644 index 00000000..244b0fad --- /dev/null +++ b/native/cmd/lich-native/dock_review_scroll.go @@ -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)) + } +} diff --git a/native/cmd/lich-native/dock_review_scroll_test.go b/native/cmd/lich-native/dock_review_scroll_test.go new file mode 100644 index 00000000..87d79564 --- /dev/null +++ b/native/cmd/lich-native/dock_review_scroll_test.go @@ -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") + } +}