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
renderer.Draw(gtx, currentElems)
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 {
logic.InputChan() <- events
}

View File

@ -72,7 +72,8 @@ type State struct {
MaxScroll ui.Dp // max scroll offset (content height - viewport height)
Elems []ui.Element
// 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
SortOrderLabel string // label text for sort order toggle
SearchQuery string // current search query
@ -107,7 +108,7 @@ func (s *State) layout() []ui.Element {
if s.page == BrowserPage {
newQuery := s.SearchEditor.Text()
if newQuery != s.SearchQuery {
s.BrowserScrollOffset = 0
s.BrowserScrollOffset = 0 // reset on query change
}
s.SearchQuery = newQuery
}
@ -140,31 +141,37 @@ func HandleScroll(data any) {
}
// 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) {
delta := data.(int) // pixels
deltaDp := ui.ToDp(ui.Px(delta), TheState.scale)
rowHeight := ui.Dp(48)
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
TheState.BrowserScrollOffset += deltaDp
if TheState.BrowserScrollOffset < 0 {
TheState.BrowserScrollOffset = 0
}
maxOffset := len(getFilteredEntries()) - visibleBrowserRows(TheState.PixelHeight, TheState.scale)
if TheState.BrowserScrollOffset > maxOffset {
TheState.BrowserScrollOffset = maxOffset
maxScroll := computeBrowserMaxScroll()
if TheState.BrowserScrollOffset > maxScroll {
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.
func visibleBrowserRows(pixelHeight int, scale float32) int {
margin := 10
@ -397,14 +404,25 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
X: margin, Y: listY,
W: contentWidth, H: listHeight,
}
// Store list height for scroll clamping calculation
TheState.BrowserListHeight = listHeight
// Filter entries by search query (case-insensitive substring match)
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(
"browser_list",
visibleEntries,
listRegion,
TheState.BrowserScrollOffset,
TheState.BrowserScrollOffset, // Dp, not row index
-1, // Selected: none
[]ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}},
)

View File

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