diff --git a/cmd/pad/main.go b/cmd/pad/main.go index 7646ac9..31ecdf1 100644 --- a/cmd/pad/main.go +++ b/cmd/pad/main.go @@ -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 } diff --git a/internal/editor/state.go b/internal/editor/state.go index 55a32de..98f84a6 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -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}}, ) diff --git a/internal/ui/element.go b/internal/ui/element.go index 7f8bc01..f5bd5be 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -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,