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:
parent
23fdeabad4
commit
8f185341b3
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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}},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user