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
2 changes: 1 addition & 1 deletion dropdown.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down
41 changes: 28 additions & 13 deletions list.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
}
Expand All @@ -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
Expand All @@ -365,15 +380,15 @@ func (l *ListBox) scrollToSelected() {
if sel < 0 {
return
}
if sel < l.ScrollRow {
if sel < l.ScrollRow().Get() {
l.ScrollTo(sel)
return
}
vr := l.visibleRows()
if vr <= 0 {
return
}
if sel >= l.ScrollRow+vr {
if sel >= l.ScrollRow().Get()+vr {
l.ScrollTo(sel - vr + 1)
}
}
Expand Down
25 changes: 19 additions & 6 deletions list_dataview_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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())
}
}
2 changes: 1 addition & 1 deletion list_reorder_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
46 changes: 23 additions & 23 deletions scroll_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand All @@ -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())
Expand All @@ -718,45 +718,45 @@ 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())
}
}

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())
}
}

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())
}
}

Expand All @@ -766,19 +766,19 @@ 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())
}
}

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())
}
}

Expand Down
26 changes: 13 additions & 13 deletions scrolldrag_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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.
Expand All @@ -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())
}
}

Expand All @@ -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})
}
Expand Down
20 changes: 10 additions & 10 deletions scrollinput_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
}
}

Expand Down Expand Up @@ -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())
}
}

Expand All @@ -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())
}
}
Loading
Loading