Implement per-pixel smooth scrolling for ListView

- Changed BrowserScrollOffset and ListView.ScrollOffset from int (row index)
  to ui.Dp (pixel offset) for smooth per-pixel scrolling
- Updated HandleBrowserScroll to use raw Dp delta instead of row-based
  scrolling with minimum ±1 row jumps
- Fixed search not triggering new frames by detecting query changes in
  main.go FrameEvent handler
- Fixed click misalignment by indexing RowFilenames with local index i
  instead of rowGlobalIndex
- Fixed bottom clamping using actual BrowserListHeight and corrected
  formula: maxScroll = totalRows * rowHeight - BrowserListHeight
This commit is contained in:
Greg Pomerantz 2026-05-28 16:14:11 -04:00
parent 23fdeabad4
commit 8f185341b3
3 changed files with 56 additions and 29 deletions

View File

@ -59,6 +59,13 @@ func run(w *app.Window) error {
currentElems := elems currentElems := elems
renderer.Draw(gtx, currentElems) renderer.Draw(gtx, currentElems)
logic.DisplayLineChan() <- int(renderer.LastLineY()) logic.DisplayLineChan() <- int(renderer.LastLineY())
// Check for search query change — trigger a new frame if the
// widget.Editor text differs from the logic's stored query.
// This ensures the filtered list updates as the user types.
newQuery := logic.State().SearchEditor.Text()
if newQuery != logic.State().SearchQuery {
logic.InputChan() <- []ui.InputEvent{}
}
if events := renderer.CheckGestures(e.Source, gtx.Metric); len(events) > 0 { if events := renderer.CheckGestures(e.Source, gtx.Metric); len(events) > 0 {
logic.InputChan() <- events logic.InputChan() <- events
} }

View File

@ -72,7 +72,8 @@ type State struct {
MaxScroll ui.Dp // max scroll offset (content height - viewport height) MaxScroll ui.Dp // max scroll offset (content height - viewport height)
Elems []ui.Element Elems []ui.Element
// Browser state // Browser state
BrowserScrollOffset int // index of first visible browser entry BrowserScrollOffset ui.Dp // pixel-level scroll offset for browser list
BrowserListHeight ui.Dp // height of the list region, set during layout
SortMode SortMode // sort mode for browser list SortMode SortMode // sort mode for browser list
SortOrderLabel string // label text for sort order toggle SortOrderLabel string // label text for sort order toggle
SearchQuery string // current search query SearchQuery string // current search query
@ -107,7 +108,7 @@ func (s *State) layout() []ui.Element {
if s.page == BrowserPage { if s.page == BrowserPage {
newQuery := s.SearchEditor.Text() newQuery := s.SearchEditor.Text()
if newQuery != s.SearchQuery { if newQuery != s.SearchQuery {
s.BrowserScrollOffset = 0 s.BrowserScrollOffset = 0 // reset on query change
} }
s.SearchQuery = newQuery s.SearchQuery = newQuery
} }
@ -140,31 +141,37 @@ func HandleScroll(data any) {
} }
// HandleBrowserScroll updates the browser list scroll offset. // HandleBrowserScroll updates the browser list scroll offset.
// The delta is in pixels; convert to Dp, then to row index offset. // The delta is in pixels; convert to Dp for smooth per-pixel scrolling.
func HandleBrowserScroll(data any) { func HandleBrowserScroll(data any) {
delta := data.(int) // pixels delta := data.(int) // pixels
deltaDp := ui.ToDp(ui.Px(delta), TheState.scale) deltaDp := ui.ToDp(ui.Px(delta), TheState.scale)
rowHeight := ui.Dp(48) TheState.BrowserScrollOffset += deltaDp
rowDelta := int(deltaDp / rowHeight)
// Ensure at least ±1 for non-zero scroll events
if rowDelta == 0 && delta != 0 {
if delta > 0 {
rowDelta = 1
} else {
rowDelta = -1
}
}
fmt.Printf("HandleBrowserScroll: %d\n", delta)
TheState.BrowserScrollOffset += rowDelta
if TheState.BrowserScrollOffset < 0 { if TheState.BrowserScrollOffset < 0 {
TheState.BrowserScrollOffset = 0 TheState.BrowserScrollOffset = 0
} }
maxOffset := len(getFilteredEntries()) - visibleBrowserRows(TheState.PixelHeight, TheState.scale) maxScroll := computeBrowserMaxScroll()
if TheState.BrowserScrollOffset > maxOffset { if TheState.BrowserScrollOffset > maxScroll {
TheState.BrowserScrollOffset = maxOffset TheState.BrowserScrollOffset = maxScroll
} }
} }
// computeBrowserMaxScroll returns the maximum scroll offset in Dp
// so the last row is fully visible at the bottom of the list.
func computeBrowserMaxScroll() ui.Dp {
rowHeight := ui.Dp(48)
totalRows := len(getFilteredEntries())
// Calculate max scroll such that the last row's bottom edge
// aligns with the list region's bottom edge.
// lastRowBottom = totalRows * rowHeight
// We want: lastRowBottom - maxScroll = BrowserListHeight
// Therefore: maxScroll = totalRows * rowHeight - BrowserListHeight
maxScroll := ui.Dp(totalRows)*rowHeight - TheState.BrowserListHeight
if maxScroll < 0 {
return 0
}
return maxScroll
}
// visibleBrowserRows estimates how many browser rows fit in the viewport. // visibleBrowserRows estimates how many browser rows fit in the viewport.
func visibleBrowserRows(pixelHeight int, scale float32) int { func visibleBrowserRows(pixelHeight int, scale float32) int {
margin := 10 margin := 10
@ -397,14 +404,25 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
X: margin, Y: listY, X: margin, Y: listY,
W: contentWidth, H: listHeight, W: contentWidth, H: listHeight,
} }
// Store list height for scroll clamping calculation
TheState.BrowserListHeight = listHeight
// Filter entries by search query (case-insensitive substring match) // Filter entries by search query (case-insensitive substring match)
filteredEntries := getFilteredEntries() filteredEntries := getFilteredEntries()
visibleEntries := filteredEntries[TheState.BrowserScrollOffset:] // Compute first visible row from Dp scroll offset
rowHeight := ui.Dp(48)
firstVisibleRow := int(TheState.BrowserScrollOffset / rowHeight)
if firstVisibleRow < 0 {
firstVisibleRow = 0
}
if firstVisibleRow > len(filteredEntries) {
firstVisibleRow = len(filteredEntries)
}
visibleEntries := filteredEntries[firstVisibleRow:]
list := ui.NewListView( list := ui.NewListView(
"browser_list", "browser_list",
visibleEntries, visibleEntries,
listRegion, listRegion,
TheState.BrowserScrollOffset, TheState.BrowserScrollOffset, // Dp, not row index
-1, // Selected: none -1, // Selected: none
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}}, []ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}},
) )

View File

@ -196,7 +196,7 @@ type ListView struct {
visible bool visible bool
interactions []Interaction interactions []Interaction
Items []ListItem Items []ListItem
ScrollOffset int ScrollOffset Dp // pixel-level scroll offset
Selected int Selected int
RowFilenames []string // filenames for click navigation RowFilenames []string // filenames for click navigation
} }
@ -235,25 +235,27 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
} }
} }
rowHeight := Dp(48) rowHeight := Dp(48)
firstVisibleRow := int(lv.ScrollOffset / rowHeight)
for i, item := range lv.Items { for i, item := range lv.Items {
// Account for scroll offset: row Y is shifted up by scroll amount
rowGlobalIndex := firstVisibleRow + i
y := lv.region.Y + Dp(rowGlobalIndex)*rowHeight - lv.ScrollOffset
// Only draw visible rows (virtualized) // Only draw visible rows (virtualized)
y := lv.region.Y + Dp(i)*rowHeight
if y+rowHeight < lv.region.Y || y > lv.region.Y+lv.region.H { if y+rowHeight < lv.region.Y || y > lv.region.Y+lv.region.H {
continue continue
} }
// Draw background for selected item // Draw background for selected item
if item.Selected || (lv.Selected == i) { if item.Selected || (lv.Selected == i) {
r.drawBg(gtx, Region{ r.drawBg(gtx, Region{
X: lv.region.X, Y: lv.region.Y + Dp(i)*rowHeight, X: lv.region.X, Y: y,
W: lv.region.W, H: rowHeight, W: lv.region.W, H: rowHeight,
}, Color{R: 200, G: 220, B: 255, A: 255}) }, Color{R: 200, G: 220, B: 255, A: 255})
} }
// Register click area for this row // Register click area for this row
rowGlobalIndex := lv.ScrollOffset + i
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex) rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
var filename string var filename string
if rowGlobalIndex < len(lv.RowFilenames) { if i < len(lv.RowFilenames) {
filename = lv.RowFilenames[rowGlobalIndex] filename = lv.RowFilenames[i]
} }
r.RegisterClick(gtx, rowID, Region{ r.RegisterClick(gtx, rowID, Region{
X: lv.region.X, Y: y, X: lv.region.X, Y: y,
@ -264,7 +266,7 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
// Draw main text // Draw main text
textRegion := Region{ textRegion := Region{
X: lv.region.X + Dp(8), X: lv.region.X + Dp(8),
Y: lv.region.Y + Dp(i)*rowHeight + Dp(4), Y: y + Dp(4),
W: lv.region.W - Dp(32), W: lv.region.W - Dp(32),
H: rowHeight - Dp(8), H: rowHeight - Dp(8),
} }
@ -273,7 +275,7 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
if item.Subtext != "" { if item.Subtext != "" {
subRegion := Region{ subRegion := Region{
X: lv.region.X + Dp(8), X: lv.region.X + Dp(8),
Y: lv.region.Y + Dp(i)*rowHeight + rowHeight - Dp(22), Y: y + rowHeight - Dp(22),
W: lv.region.W - Dp(32), W: lv.region.W - Dp(32),
H: Dp(16), H: Dp(16),
} }
@ -283,7 +285,7 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
} }
// NewListView creates a visible ListView element. // NewListView creates a visible ListView element.
func NewListView(id string, items []ListItem, region Region, scrollOffset, selected int, interactions []Interaction) ListView { func NewListView(id string, items []ListItem, region Region, scrollOffset Dp, selected int, interactions []Interaction) ListView {
return ListView{ return ListView{
id: id, id: id,
region: region, region: region,