diff --git a/dropdown.go b/dropdown.go index 56ad950..7f3c922 100644 --- a/dropdown.go +++ b/dropdown.go @@ -296,7 +296,7 @@ func (d *DropDown) DrawPopover(p painter.Painter, theme *Theme) { // scrolling so options beyond PopoverMaxRows are painted (and a scrollbar // appears) when the popover is scrolled. At popScroll == 0 this is // byte-identical to an unscrolled list. - lb.ScrollRow = d.clampedPopScroll() + lb.ScrollRow().Set(d.clampedPopScroll()) lb.SetBounds(d.PopoverBounds()) lb.Draw(p, theme) } diff --git a/list.go b/list.go index e72dbaa..4f053f3 100644 --- a/list.go +++ b/list.go @@ -87,13 +87,15 @@ type ListBox struct { // Nil-guarded; never called while Reorderable is false. OnReorder func(from, to int) - // ScrollRow is the index of the row painted at the very top of the - // widget's bounds. Reads through Draw/OnEvent are clamped to - // [0, maxScrollRow()] on the fly (see clampedScrollRow), so setting - // this directly to an out-of-range value is safe -- it just behaves - // as whichever in-range value it clamps to. Prefer ScrollTo/ScrollBy, - // which clamp + write back immediately. - ScrollRow int + // scrollRow is the index of the row painted at the very top of the + // widget's bounds. The reactive scroll position is MVVM-only: it lives in + // an unexported Observable exposed via [ListBox.ScrollRow]; there is no + // settable ScrollRow field. Reads through Draw/OnEvent are clamped to + // [0, maxScrollRow()] on the fly (see clampedScrollRow), so Setting it + // directly to an out-of-range value is safe -- it just behaves as + // whichever in-range value it clamps to. Prefer ScrollTo/ScrollBy, which + // clamp + write back immediately. + scrollRow *mvvm.Observable[int] // selected holds the multi-selection set. Only consulted for // rendering/queries when MultiSelect is true, but the mutator @@ -126,6 +128,7 @@ func NewListBox(items []string) *ListBox { return &ListBox{ Items: items, selectedRow: mvvm.NewObservable(-1), + scrollRow: mvvm.NewObservable(0), RowHeight: scaled(18), pressedRow: -1, dropIndicator: -1, @@ -143,6 +146,18 @@ func (l *ListBox) Selected() *mvvm.Observable[int] { return l.selectedRow } +// ScrollRow is the index of the top visible row as a shared [mvvm.Observable]: +// a host binds it (Set / Subscribe / two-way) — there is no settable ScrollRow +// field. A wheel scroll, an arrow-key scroll-into-view, or a scrollbar drag +// Sets it (always clamped, see ScrollTo/ScrollBy); Draw reads it back. A bare +// &ListBox{} lazy-inits the observable to 0; NewListBox seeds it to 0 too. +func (l *ListBox) ScrollRow() *mvvm.Observable[int] { + if l.scrollRow == nil { + l.scrollRow = mvvm.NewObservable(0) + } + return l.scrollRow +} + // rowHeight is the effective per-row pixel height used for every layout and // hit-test: the configured RowHeight clamped UP to the density minimum hit // target via [TouchTarget]. Under [DensityCompact] the clamp is a pass-through @@ -327,7 +342,7 @@ func (l *ListBox) maxScrollRow() int { // directly, or left stale after Items shrank) never paints or // hit-tests outside the valid window. func (l *ListBox) clampedScrollRow() int { - s := l.ScrollRow + s := l.ScrollRow().Get() if s < 0 { s = 0 } @@ -341,14 +356,14 @@ func (l *ListBox) clampedScrollRow() int { // [0, maxScrollRow()], and writes the clamped value back to // ScrollRow. func (l *ListBox) ScrollTo(row int) { - l.ScrollRow = row - l.ScrollRow = l.clampedScrollRow() + l.ScrollRow().Set(row) + l.ScrollRow().Set(l.clampedScrollRow()) } // ScrollBy shifts ScrollRow by delta rows (negative scrolls up), // clamped exactly like ScrollTo. func (l *ListBox) ScrollBy(delta int) { - l.ScrollTo(l.ScrollRow + delta) + l.ScrollTo(l.ScrollRow().Get() + delta) } // scrollToSelected nudges ScrollRow so Selected stays within the @@ -365,7 +380,7 @@ func (l *ListBox) scrollToSelected() { if sel < 0 { return } - if sel < l.ScrollRow { + if sel < l.ScrollRow().Get() { l.ScrollTo(sel) return } @@ -373,7 +388,7 @@ func (l *ListBox) scrollToSelected() { if vr <= 0 { return } - if sel >= l.ScrollRow+vr { + if sel >= l.ScrollRow().Get()+vr { l.ScrollTo(sel - vr + 1) } } diff --git a/list_dataview_test.go b/list_dataview_test.go index 19f9d3e..95f4d41 100644 --- a/list_dataview_test.go +++ b/list_dataview_test.go @@ -65,7 +65,7 @@ func TestListBoxItemRendererOnlyVisibleRows(t *testing.T) { items := []string{"0", "1", "2", "3", "4", "5", "6", "7", "8", "9"} lb := NewListBox(items) lb.SetBounds(Rect{X: 0, Y: 0, W: 100, H: 2 * 18}) // 2 rows visible - lb.ScrollRow = 3 + lb.ScrollRow().Set(3) var indices []int lb.ItemRenderer = func(p painter.Painter, _ *Theme, _ Rect, index int, _ string, _ bool, _ RGBA) { @@ -97,24 +97,37 @@ func TestListBoxItemRendererActuallyPaints(t *testing.T) { } // TestListBoxSelectedObservable covers the zero-value lazy-init of the Selected -// accessor and the host binding path: a ListBox built as a bare struct (no -// NewListBox) still yields a usable Observable that lazy-inits to 0, Setting it -// from outside is reflected by the widget + notifies subscribers (there is no -// imperative Selected field), and NewListBox seeds the anchor to -1. +// and ScrollRow accessors and the host binding path: a ListBox built as a bare +// struct (no NewListBox) still yields usable Observables that lazy-init to 0, +// Setting them from outside is reflected by the widget + notifies subscribers +// (there is no imperative Selected/ScrollRow field), and NewListBox seeds the +// anchor to -1 (scroll to 0). func TestListBoxSelectedObservable(t *testing.T) { - lb := &ListBox{} // no NewListBox -> selectedRow Observable is nil until accessed + lb := &ListBox{} // no NewListBox -> Observables are nil until accessed if lb.Selected().Get() != 0 { t.Fatalf("bare &ListBox{} Selected = %d, want 0 (lazy-init)", lb.Selected().Get()) } + if lb.ScrollRow().Get() != 0 { + t.Fatalf("bare &ListBox{} ScrollRow = %d, want 0 (lazy-init)", lb.ScrollRow().Get()) + } seen := -99 lb.Selected().Subscribe(func(v int) { seen = v }) lb.Selected().Set(3) // a host drives the selection through the Observable if lb.Selected().Get() != 3 || seen != 3 { t.Fatalf("host Set: Selected=%d subscriber=%d, want 3/3", lb.Selected().Get(), seen) } + scrolled := -99 + lb.ScrollRow().Subscribe(func(v int) { scrolled = v }) + lb.ScrollRow().Set(2) // a host drives the scroll offset through the Observable + if lb.ScrollRow().Get() != 2 || scrolled != 2 { + t.Fatalf("host Set: ScrollRow=%d subscriber=%d, want 2/2", lb.ScrollRow().Get(), scrolled) + } nb := NewListBox([]string{"a", "b"}) if nb.Selected().Get() != -1 { t.Fatalf("NewListBox Selected = %d, want -1 (no selection)", nb.Selected().Get()) } + if nb.ScrollRow().Get() != 0 { + t.Fatalf("NewListBox ScrollRow = %d, want 0", nb.ScrollRow().Get()) + } } diff --git a/list_reorder_test.go b/list_reorder_test.go index c7c7f6a..1d95f4b 100644 --- a/list_reorder_test.go +++ b/list_reorder_test.go @@ -320,7 +320,7 @@ func TestListBoxDragReorderRespectsScrollRow(t *testing.T) { l.Reorderable = true l.RowHeight = 20 l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 60}) // 3 visible rows -> overflow - l.ScrollRow = 5 // window = rows [5,8) + l.ScrollRow().Set(5) // window = rows [5,8) // Local Y=5 is in the top half of local slot 0 -> absolute row 5, not // row 0 -- proves rowInsertionIndex reads through ScrollRow. diff --git a/scroll_list_test.go b/scroll_list_test.go index e1b2eef..fbd8a5e 100644 --- a/scroll_list_test.go +++ b/scroll_list_test.go @@ -584,7 +584,7 @@ func TestListBoxWindowedDrawOnlyPaintsVisibleRows(t *testing.T) { l := NewListBox(items) l.RowHeight = 20 l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) // exactly 5 rows visible - l.ScrollRow = 3 // window = rows [3,8) + l.ScrollRow().Set(3) // window = rows [3,8) l.Selected().Set(5) // in-window buf := makeSurface(w, h) l.Draw(newP(buf, w), theme) @@ -686,11 +686,11 @@ func TestListBoxScrollRowClampBothEnds(t *testing.T) { l := NewListBox(make([]string, 20)) l.RowHeight = 20 l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) // 5 visible -> maxScrollRow = 15 - l.ScrollRow = -5 + l.ScrollRow().Set(-5) if got := l.clampedScrollRow(); got != 0 { t.Fatalf("negative ScrollRow should clamp to 0; got %d", got) } - l.ScrollRow = 1000 + l.ScrollRow().Set(1000) if got := l.clampedScrollRow(); got != 15 { t.Fatalf("ScrollRow should clamp to maxScrollRow=15; got %d", got) } @@ -702,7 +702,7 @@ func TestListBoxClickWithScrollRowSelectsCorrectRow(t *testing.T) { l.OnActivate = func(i int) { got = i } l.RowHeight = 20 l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) - l.ScrollRow = 3 + l.ScrollRow().Set(3) l.OnEvent(Event{Kind: EventClick, X: 5, Y: 25}) // local slot 1 -> abs row 4 if l.Selected().Get() != 4 { t.Fatalf("Selected = %d, want 4", l.Selected().Get()) @@ -718,21 +718,21 @@ func TestListBoxScrollToAndScrollByClamp(t *testing.T) { l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) // maxScrollRow = 15 l.ScrollTo(1000) - if l.ScrollRow != 15 { - t.Fatalf("ScrollTo(1000) = %d, want 15", l.ScrollRow) + if l.ScrollRow().Get() != 15 { + t.Fatalf("ScrollTo(1000) = %d, want 15", l.ScrollRow().Get()) } l.ScrollTo(-1000) - if l.ScrollRow != 0 { - t.Fatalf("ScrollTo(-1000) = %d, want 0", l.ScrollRow) + if l.ScrollRow().Get() != 0 { + t.Fatalf("ScrollTo(-1000) = %d, want 0", l.ScrollRow().Get()) } l.ScrollTo(5) l.ScrollBy(3) - if l.ScrollRow != 8 { - t.Fatalf("ScrollBy(3) from 5 = %d, want 8", l.ScrollRow) + if l.ScrollRow().Get() != 8 { + t.Fatalf("ScrollBy(3) from 5 = %d, want 8", l.ScrollRow().Get()) } l.ScrollBy(-100) - if l.ScrollRow != 0 { - t.Fatalf("ScrollBy(-100) should clamp to 0; got %d", l.ScrollRow) + if l.ScrollRow().Get() != 0 { + t.Fatalf("ScrollBy(-100) should clamp to 0; got %d", l.ScrollRow().Get()) } } @@ -740,11 +740,11 @@ func TestListBoxScrollToSelectedNoSelectionIsNoOp(t *testing.T) { l := NewListBox(make([]string, 20)) l.RowHeight = 20 l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) - l.ScrollRow = 3 + l.ScrollRow().Set(3) // Selected stays at -1 (NewListBox default). l.scrollToSelected() - if l.ScrollRow != 3 { - t.Fatalf("scrollToSelected with Selected=-1 must be a no-op; ScrollRow=%d, want 3", l.ScrollRow) + if l.ScrollRow().Get() != 3 { + t.Fatalf("scrollToSelected with Selected=-1 must be a no-op; ScrollRow=%d, want 3", l.ScrollRow().Get()) } } @@ -752,11 +752,11 @@ func TestListBoxScrollToSelectedScrollsUp(t *testing.T) { l := NewListBox(make([]string, 20)) l.RowHeight = 20 l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) // 5 visible - l.ScrollRow = 10 + l.ScrollRow().Set(10) l.Selected().Set(2) // above the window l.scrollToSelected() - if l.ScrollRow != 2 { - t.Fatalf("ScrollRow = %d, want 2 (scrolled up to Selected)", l.ScrollRow) + if l.ScrollRow().Get() != 2 { + t.Fatalf("ScrollRow = %d, want 2 (scrolled up to Selected)", l.ScrollRow().Get()) } } @@ -766,8 +766,8 @@ func TestListBoxScrollToSelectedScrollsDown(t *testing.T) { l.SetBounds(Rect{X: 0, Y: 0, W: 50, H: 100}) // 5 visible, window starts [0,5) l.Selected().Set(9) // below the window l.scrollToSelected() - if l.ScrollRow != 5 { // Selected - vr + 1 = 9-5+1 - t.Fatalf("ScrollRow = %d, want 5", l.ScrollRow) + if l.ScrollRow().Get() != 5 { // Selected - vr + 1 = 9-5+1 + t.Fatalf("ScrollRow = %d, want 5", l.ScrollRow().Get()) } } @@ -775,10 +775,10 @@ func TestListBoxScrollToSelectedZeroVisibleRowsIsNoOp(t *testing.T) { l := NewListBox(make([]string, 5)) l.RowHeight = 0 // -> visibleRows() == 0 l.Selected().Set(2) - l.ScrollRow = 0 + l.ScrollRow().Set(0) l.scrollToSelected() - if l.ScrollRow != 0 { - t.Fatalf("vr<=0 branch must be a no-op; ScrollRow=%d, want 0", l.ScrollRow) + if l.ScrollRow().Get() != 0 { + t.Fatalf("vr<=0 branch must be a no-op; ScrollRow=%d, want 0", l.ScrollRow().Get()) } } diff --git a/scrolldrag_test.go b/scrolldrag_test.go index 1a272e5..1ebf12c 100644 --- a/scrolldrag_test.go +++ b/scrolldrag_test.go @@ -145,21 +145,21 @@ func TestListBoxScrollbarDragPagesAndSelects(t *testing.T) { // Geometry: visibleRows=4, contentH=360, max=16, thumbH=10, thumb [0,10). trackX := 100 - scrollbarWidth // 88 - before := l.ScrollRow + before := l.ScrollRow().Get() l.OnEvent(Event{Kind: EventClick, X: trackX + 3, Y: 3}) if !l.sbDrag.active { t.Fatal("pressing the thumb should begin a drag") } l.OnEvent(Event{Kind: EventMouseDrag, X: trackX + 3, Y: 30}) - if l.ScrollRow != 9 { // (27*16+25)/50 = 9.14 -> 9 - t.Fatalf("mid drag ScrollRow = %d, want 9", l.ScrollRow) + if l.ScrollRow().Get() != 9 { // (27*16+25)/50 = 9.14 -> 9 + t.Fatalf("mid drag ScrollRow = %d, want 9", l.ScrollRow().Get()) } - if !(l.ScrollRow > before && l.ScrollRow < 16) { - t.Fatalf("mid drag ScrollRow = %d, want strictly between %d and 16", l.ScrollRow, before) + if !(l.ScrollRow().Get() > before && l.ScrollRow().Get() < 16) { + t.Fatalf("mid drag ScrollRow = %d, want strictly between %d and 16", l.ScrollRow().Get(), before) } l.OnEvent(Event{Kind: EventMouseDrag, X: trackX + 3, Y: 60}) - if l.ScrollRow != 16 { - t.Fatalf("drag past the end ScrollRow = %d, want 16 (clamped)", l.ScrollRow) + if l.ScrollRow().Get() != 16 { + t.Fatalf("drag past the end ScrollRow = %d, want 16 (clamped)", l.ScrollRow().Get()) } l.OnEvent(Event{Kind: EventMouseUp}) if l.sbDrag.active { @@ -169,8 +169,8 @@ func TestListBoxScrollbarDragPagesAndSelects(t *testing.T) { // Track press below the thumb pages down. l.ScrollTo(0) l.OnEvent(Event{Kind: EventClick, X: trackX + 3, Y: 40}) - if l.ScrollRow != 4 { - t.Fatalf("page-down ScrollRow = %d, want 4", l.ScrollRow) + if l.ScrollRow().Get() != 4 { + t.Fatalf("page-down ScrollRow = %d, want 4", l.ScrollRow().Get()) } // A press left of the scrollbar still selects a row. @@ -180,8 +180,8 @@ func TestListBoxScrollbarDragPagesAndSelects(t *testing.T) { if l.Selected().Get() != 1 { t.Fatalf("content press should select row 1, got %d", l.Selected().Get()) } - if l.ScrollRow != 0 { - t.Fatalf("selecting must not scroll: ScrollRow = %d, want 0", l.ScrollRow) + if l.ScrollRow().Get() != 0 { + t.Fatalf("selecting must not scroll: ScrollRow = %d, want 0", l.ScrollRow().Get()) } } @@ -197,8 +197,8 @@ func TestListBoxScrollbarNoTravelWhenBarelyOverflowing(t *testing.T) { trackX := 100 - scrollbarWidth l.OnEvent(Event{Kind: EventClick, X: trackX + 3, Y: 2}) // grab the immovable thumb l.OnEvent(Event{Kind: EventMouseDrag, X: trackX + 3, Y: 18}) - if l.ScrollRow != 0 { - t.Fatalf("an immovable thumb must keep ScrollRow at 0, got %d", l.ScrollRow) + if l.ScrollRow().Get() != 0 { + t.Fatalf("an immovable thumb must keep ScrollRow at 0, got %d", l.ScrollRow().Get()) } l.OnEvent(Event{Kind: EventMouseUp}) } diff --git a/scrollinput_test.go b/scrollinput_test.go index 08c9dbf..37d3682 100644 --- a/scrollinput_test.go +++ b/scrollinput_test.go @@ -35,18 +35,18 @@ func TestListBoxWheelScroll(t *testing.T) { } lb.OnEvent(Event{Kind: EventScroll, Delta: 2}) - if lb.ScrollRow != 2 { - t.Fatalf("wheel down 2: ScrollRow=%d, want 2", lb.ScrollRow) + if lb.ScrollRow().Get() != 2 { + t.Fatalf("wheel down 2: ScrollRow=%d, want 2", lb.ScrollRow().Get()) } // Clamp at the bottom: an over-large delta pins to maxScrollRow. lb.OnEvent(Event{Kind: EventScroll, Delta: 1000}) - if lb.ScrollRow != max { - t.Fatalf("wheel to end: ScrollRow=%d, want %d", lb.ScrollRow, max) + if lb.ScrollRow().Get() != max { + t.Fatalf("wheel to end: ScrollRow=%d, want %d", lb.ScrollRow().Get(), max) } // Clamp at the top: can't go negative. lb.OnEvent(Event{Kind: EventScroll, Delta: -1000}) - if lb.ScrollRow != 0 { - t.Fatalf("wheel to top: ScrollRow=%d, want 0", lb.ScrollRow) + if lb.ScrollRow().Get() != 0 { + t.Fatalf("wheel to top: ScrollRow=%d, want 0", lb.ScrollRow().Get()) } } @@ -276,8 +276,8 @@ func TestContainerForwardsScrollToChildUnderPointer(t *testing.T) { // A wheel event at a point inside the container (container-local coords) // must reach the ListBox and scroll it. c.OnEvent(Event{Kind: EventScroll, X: 30, Y: 30, Delta: 3}) - if lb.ScrollRow != 3 { - t.Fatalf("container did not forward EventScroll: ListBox ScrollRow=%d, want 3", lb.ScrollRow) + if lb.ScrollRow().Get() != 3 { + t.Fatalf("container did not forward EventScroll: ListBox ScrollRow=%d, want 3", lb.ScrollRow().Get()) } } @@ -294,7 +294,7 @@ func TestVBoxForwardsScrollToChildUnderPointer(t *testing.T) { if bottom.ScrollRow != 2 { t.Fatalf("VBox forwarded to wrong child: TreeTable ScrollRow=%d, want 2", bottom.ScrollRow) } - if top.ScrollRow != 0 { - t.Fatalf("ListBox should not have scrolled: %d", top.ScrollRow) + if top.ScrollRow().Get() != 0 { + t.Fatalf("ListBox should not have scrolled: %d", top.ScrollRow().Get()) } } diff --git a/wave3b_keyboard_test.go b/wave3b_keyboard_test.go index c3c5538..2a36c65 100644 --- a/wave3b_keyboard_test.go +++ b/wave3b_keyboard_test.go @@ -38,8 +38,8 @@ func newCursorListBox() *ListBox { func lbVisible(t *testing.T, lb *ListBox) { t.Helper() vr := lb.visibleRows() - if lb.Selected().Get() < lb.ScrollRow || lb.Selected().Get() >= lb.ScrollRow+vr { - t.Fatalf("cursor %d outside window [%d,%d)", lb.Selected().Get(), lb.ScrollRow, lb.ScrollRow+vr) + if lb.Selected().Get() < lb.ScrollRow().Get() || lb.Selected().Get() >= lb.ScrollRow().Get()+vr { + t.Fatalf("cursor %d outside window [%d,%d)", lb.Selected().Get(), lb.ScrollRow().Get(), lb.ScrollRow().Get()+vr) } } @@ -50,15 +50,15 @@ func TestListBoxKeyCursorAndActivate(t *testing.T) { // First ArrowDown with no selection lands on row 0 (stays visible). lb.OnEvent(kd3b("ArrowDown")) - if lb.Selected().Get() != 0 || lb.ScrollRow != 0 { - t.Fatalf("ArrowDown from none: Selected=%d ScrollRow=%d", lb.Selected().Get(), lb.ScrollRow) + if lb.Selected().Get() != 0 || lb.ScrollRow().Get() != 0 { + t.Fatalf("ArrowDown from none: Selected=%d ScrollRow=%d", lb.Selected().Get(), lb.ScrollRow().Get()) } lbVisible(t, lb) // End jumps to the last row and auto-scrolls so it is visible. lb.OnEvent(kd3b("End")) - if lb.Selected().Get() != 19 || lb.ScrollRow != 17 { - t.Fatalf("End: Selected=%d ScrollRow=%d, want 19/17", lb.Selected().Get(), lb.ScrollRow) + if lb.Selected().Get() != 19 || lb.ScrollRow().Get() != 17 { + t.Fatalf("End: Selected=%d ScrollRow=%d, want 19/17", lb.Selected().Get(), lb.ScrollRow().Get()) } lbVisible(t, lb) @@ -70,15 +70,15 @@ func TestListBoxKeyCursorAndActivate(t *testing.T) { // Home jumps back to the first row and scrolls up to it. lb.OnEvent(kd3b("Home")) - if lb.Selected().Get() != 0 || lb.ScrollRow != 0 { - t.Fatalf("Home: Selected=%d ScrollRow=%d", lb.Selected().Get(), lb.ScrollRow) + if lb.Selected().Get() != 0 || lb.ScrollRow().Get() != 0 { + t.Fatalf("Home: Selected=%d ScrollRow=%d", lb.Selected().Get(), lb.ScrollRow().Get()) } lbVisible(t, lb) // PageDown moves one page and keeps the cursor visible. lb.OnEvent(kd3b("PageDown")) // 0 -> 3 - if lb.Selected().Get() != 3 || lb.ScrollRow != 1 { - t.Fatalf("PageDown: Selected=%d ScrollRow=%d, want 3/1", lb.Selected().Get(), lb.ScrollRow) + if lb.Selected().Get() != 3 || lb.ScrollRow().Get() != 1 { + t.Fatalf("PageDown: Selected=%d ScrollRow=%d, want 3/1", lb.Selected().Get(), lb.ScrollRow().Get()) } lbVisible(t, lb)