diff --git a/internal/editor/logic.go b/internal/editor/logic.go index 74ab48a..b35d0c2 100644 --- a/internal/editor/logic.go +++ b/internal/editor/logic.go @@ -105,8 +105,10 @@ func (l *Logic) Run() { update.apply(l.state) l.frameChan <- l.state.layout() case y := <-l.lastLineYChan: - l.state.LastLineY = ui.Dp(y) - l.frameChan <- l.state.layout() + if ui.Dp(y) != l.state.LastLineY { + l.state.LastLineY = ui.Dp(y) + l.frameChan <- l.state.layout() + } case events := <-l.inputChan: for _, evt := range events { evt.Handler(evt.Data) diff --git a/internal/editor/state.go b/internal/editor/state.go index 92bc766..9122eca 100644 --- a/internal/editor/state.go +++ b/internal/editor/state.go @@ -4,6 +4,10 @@ import ( "pad/internal/ui" ) +func init() { + ui.OpenFile = OpenFile +} + // SampleText is a static lorem-ipsum text used for display-only testing. // Roughly 3 KB, filling a few pages of the editor. const SampleText = ` @@ -36,20 +40,48 @@ func EditorLineHeight() ui.Dp { return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale) } +// Page identifies which page the app is showing. +type Page int + +const ( + BrowserPage Page = iota + EditorPage +) + +// SortMode controls how the browser list is sorted. +type SortMode int + +const ( + SortByDateNewest SortMode = iota // default: newest modified first + SortByNameAsc +) + // State holds all application state owned by the logic goroutine. type State struct { - PixelWidth int // raw pixel width from Gio ConfigEvent - PixelHeight int // raw pixel height from Gio ConfigEvent + PixelWidth int // raw pixel width from Gio ConfigEvent + PixelHeight int // raw pixel height from Gio ConfigEvent scale float32 + page Page // current page (Browser or Editor) WordWrap bool - ScrollOffset ui.Dp // vertical scroll position in Dp - LastLineY ui.Dp // last line baseline offset from text origin, from renderer - MaxScroll ui.Dp // max scroll offset (content height - viewport height) + ScrollOffset ui.Dp // vertical scroll position in Dp + LastLineY ui.Dp // last line baseline offset from text origin, from renderer + MaxScroll ui.Dp // max scroll offset (content height - viewport height) Elems []ui.Element + // Browser state + BrowserScrollOffset int // index of first visible browser entry + SortMode SortMode // sort mode for browser list + SortOrderLabel string // label text for sort order toggle + // Editor state + ActiveFilename string // filename shown in editor status bar } func NewState() *State { - return &State{scale: 1.0} + return &State{ + scale: 1.0, + page: EditorPage, + SortMode: SortByDateNewest, + SortOrderLabel: "Date", + } } func (s *State) SetScale(scale float32) { @@ -65,7 +97,12 @@ func (s *State) Scale() float32 { func (s *State) layout() []ui.Element { dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale) - s.Elems = EditorLayout(dpW, dpH, s.WordWrap) + switch s.page { + case BrowserPage: + s.Elems = BrowserLayout(dpW, dpH, s.SortMode) + case EditorPage: + s.Elems = EditorLayout(dpW, dpH, s.WordWrap) + } return s.Elems } @@ -88,6 +125,74 @@ func HandleScroll(data any) { } } +// HandleBrowserScroll updates the browser list scroll offset. +// The delta is in pixels; convert to Dp, then to row index offset. +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 + } + } + TheState.BrowserScrollOffset += rowDelta + if TheState.BrowserScrollOffset < 0 { + TheState.BrowserScrollOffset = 0 + } + maxOffset := len(browserEntries) - visibleBrowserRows(TheState.PixelHeight, TheState.scale) + if TheState.BrowserScrollOffset > maxOffset { + TheState.BrowserScrollOffset = maxOffset + } +} + +// visibleBrowserRows estimates how many browser rows fit in the viewport. +func visibleBrowserRows(pixelHeight int, scale float32) int { + margin := 10 + headerHeight := 24 + searchHeight := 36 + gap := 3 // margin/2 * 2 in Dp + contentHeight := ui.ToDp(ui.Px(pixelHeight), float32(scale)) - ui.Dp(margin*2+headerHeight+searchHeight+gap) + rowHeight := ui.Dp(48) + rows := int(contentHeight / rowHeight) + if rows < 1 { + rows = 1 + } + return rows +} + +// GoToBrowser switches the app to the browser page. +func GoToBrowser(data any) { + TheState.page = BrowserPage +} + +// GoToEditor switches the app to the editor page. +func GoToEditor(data any) { + TheState.page = EditorPage +} + +// OpenFile sets the active filename and switches to the editor page. +// data is the filename string from the browser list. +func OpenFile(data any) { + TheState.ActiveFilename = data.(string) + TheState.page = EditorPage +} + +// ToggleSortOrder cycles the browser sort mode between date and name. +func ToggleSortOrder(data any) { + TheState.SortMode = (TheState.SortMode + 1) % 2 + switch TheState.SortMode { + case SortByDateNewest: + TheState.SortOrderLabel = "Date" + case SortByNameAsc: + TheState.SortOrderLabel = "Name" + } +} + // EditorLayout computes the element tree for the editor page. func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { margin := ui.Dp(10) @@ -99,16 +204,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { H: ui.Dp(52), } statusBarW := statusBarRegion.W + filename := TheState.ActiveFilename + if filename == "" { + filename = "untitled.txt" + } statusBar := ui.NewContainer( statusBarRegion, ui.Color{R: 230, G: 230, B: 230, A: 255}, []ui.Element{ // Row 1: filename - ui.NewLabel("a very long file name that probably will eventually need to be truncated.txt", 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil), - // Row 2: cut, copy, paste icons - ui.NewIcon("cut", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0), - ui.NewIcon("copy", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0), - ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0), + ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil), + // Row 2: back, cut, copy, paste icons + ui.NewIcon("back", ui.Region{X: ui.Dp(0), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, + []ui.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}), + ui.NewIcon("cut", ui.Region{X: ui.Dp(48), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil), + ui.NewIcon("copy", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil), + ui.NewIcon("paste", ui.Region{X: ui.Dp(144), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0, nil), }, ) @@ -155,6 +266,7 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { TheState.MaxScroll = maxScroll editor := ui.NewTextField( + "editor_text", SampleText, editorRegion, editorRegion.W, @@ -164,3 +276,117 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { return []ui.Element{statusBar, editor, bottomBar} } + +// browserEntries is a static list of sample files for the browser page. +// It is longer than the viewport so scrolling can be tested. +var browserEntries = []ui.ListItem{ + {Text: "notes-2025-01-15.txt", Subtext: "2025-01-15 • 4.2 KB"}, + {Text: "meeting-2025-01-14.txt", Subtext: "2025-01-14 • 1.8 KB"}, + {Text: "todo-2025-01-14.txt", Subtext: "2025-01-14 • 892 B"}, + {Text: "ideas-2025-01-13.txt", Subtext: "2025-01-13 • 2.1 KB"}, + {Text: "diary-2025-01-12.txt", Subtext: "2025-01-12 • 3.5 KB"}, + {Text: "bookmarks-2025-01-11.txt", Subtext: "2025-01-11 • 6.7 KB"}, + {Text: "inbox-2025-01-10.txt", Subtext: "2025-01-10 • 12 KB"}, + {Text: "archive-2025-01-09.txt", Subtext: "2025-01-09 • 28 KB"}, + {Text: "draft-article.txt", Subtext: "2025-01-08 • 5.3 KB"}, + {Text: "recipe-cole-sl.txt", Subtext: "2025-01-07 • 1.1 KB"}, + {Text: "project-plan.txt", Subtext: "2025-01-06 • 8.4 KB"}, + {Text: "weekly-review.txt", Subtext: "2025-01-05 • 2.9 KB"}, + {Text: "changelog-v2.txt", Subtext: "2025-01-04 • 15 KB"}, + {Text: "README.txt", Subtext: "2025-01-03 • 512 B"}, + {Text: "scratch-pad.txt", Subtext: "2025-01-02 • 736 B"}, + {Text: "old-notes.txt", Subtext: "2025-01-01 • 3.1 KB"}, + {Text: "backup-jan.txt", Subtext: "2024-12-31 • 42 KB"}, + {Text: "annual-review.txt", Subtext: "2024-12-30 • 9.8 KB"}, + {Text: "december-log.txt", Subtext: "2024-12-29 • 7.2 KB"}, + {Text: "random-thoughts.txt", Subtext: "2024-12-28 • 1.5 KB"}, + {Text: "shopping-list.txt", Subtext: "2024-12-27 • 248 B"}, + {Text: "travel-ideas.txt", Subtext: "2024-12-26 • 2.3 KB"}, + {Text: "music-queue.txt", Subtext: "2024-12-25 • 4.7 KB"}, + {Text: "movie-wishlist.txt", Subtext: "2024-12-24 • 890 B"}, + {Text: "book-read-list.txt", Subtext: "2024-12-23 • 3.6 KB"}, + {Text: "podcast-notes.txt", Subtext: "2024-12-22 • 5.1 KB"}, + {Text: "quotes.txt", Subtext: "2024-12-21 • 6.2 KB"}, + {Text: "vocab.txt", Subtext: "2024-12-20 • 1.9 KB"}, + {Text: "word-of-day.txt", Subtext: "2024-12-19 • 384 B"}, + {Text: "memories.txt", Subtext: "2024-12-18 • 11 KB"}, +} + +// BrowserLayout computes the element tree for the browser (file listing) page. +func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Element { + margin := ui.Dp(10) + contentWidth := screenWidth - margin*2 + + // --- Header: directory name + sort toggle --- + headerHeight := ui.Dp(24) + headerRegion := ui.Region{ + X: margin, Y: margin, + W: contentWidth, H: headerHeight, + } + header := ui.NewLabel("My Documents", 16, headerRegion, ui.AlignStart, "", nil) + + // --- Sort toggle --- + var sortLabel string + switch sortMode { + case SortByDateNewest: + sortLabel = "Date ↓" + case SortByNameAsc: + sortLabel = "Name ↑" + } + sortRegion := ui.Region{ + X: contentWidth - ui.Dp(60), Y: ui.Dp(2), W: ui.Dp(60), H: headerHeight, + } + sortToggle := ui.NewLabel(sortLabel, 12, sortRegion, ui.AlignEnd, "sort", + []ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}}) + + // --- Search bar --- + searchHeight := ui.Dp(36) + searchY := headerRegion.Y + headerRegion.H + margin/2 + searchRegion := ui.Region{ + X: margin, Y: searchY, + W: contentWidth, H: searchHeight, + } + searchBar := ui.NewTextField( + "search_bar", // id + "", // empty query + searchRegion, + searchRegion.W, + ui.Dp(0), + nil, + ) + // Override placeholder for search bar — TextField doesn't have a dedicated + // search variant yet, so we use a Label as a visual placeholder for now. + searchPlaceholder := ui.NewLabel("Search…", 14, + ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(2), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(4)}, + ui.AlignStart, "", nil) + + // --- ListView --- + listY := searchY + searchHeight + margin/2 + listHeight := screenHeight - listY - margin // fill remaining height + listRegion := ui.Region{ + X: margin, Y: listY, + W: contentWidth, H: listHeight, + } + visibleEntries := browserEntries[TheState.BrowserScrollOffset:] + list := ui.NewListView( + "browser_list", + visibleEntries, + listRegion, + TheState.BrowserScrollOffset, + -1, // Selected: none + []ui.Interaction{{Gesture: ui.Scroll, Handler: HandleBrowserScroll}}, + ) + // Set row filenames so ListView.Draw can wire up click handlers + list.RowFilenames = make([]string, len(visibleEntries)) + for i, entry := range visibleEntries { + list.RowFilenames[i] = entry.Text + } + + return []ui.Element{ + header, + sortToggle, + searchBar, + searchPlaceholder, + list, + } +} diff --git a/internal/ui/element.go b/internal/ui/element.go index 4f42f73..efbe344 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -1,7 +1,11 @@ package ui import ( + "fmt" + "image" + "gioui.org/layout" + "gioui.org/op/clip" "gioui.org/unit" ) @@ -107,6 +111,15 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) { if img == nil { return } + // Register click area if this icon has interactions + if cr, ok := r.clicks[i.id]; ok && cr.click != nil { + clickClip := clip.Rect{ + Min: image.Point{X: int(r.toPx(i.region.X)), Y: int(r.toPx(i.region.Y))}, + Max: image.Point{X: int(r.toPx(i.region.X+i.region.W)), Y: int(r.toPx(i.region.Y+i.region.H))}, + }.Push(gtx.Ops) + cr.click.Add(gtx.Ops) + clickClip.Pop() + } // Auto-scale: if Size is 0, use region dimensions so the icon fills its region w, h := Dp(0), Dp(0) if i.Size == 0 { @@ -117,12 +130,14 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) { func (i Icon) ID() string { return i.id } // NewIcon creates a visible Icon element. -func NewIcon(name string, region Region, size Dp) Icon { +func NewIcon(name string, region Region, size Dp, interactions []Interaction) Icon { return Icon{ - region: region, - visible: true, - Name: name, - Size: size, + id: name, + region: region, + visible: true, + interactions: interactions, + Name: name, + Size: size, } } @@ -154,8 +169,9 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { } // NewTextField creates a visible multiline TextField. -func NewTextField(value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField { +func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, interactions []Interaction) TextField { return TextField{ + id: id, region: region, visible: true, interactions: interactions, @@ -182,12 +198,99 @@ type ListView struct { Items []ListItem ScrollOffset int Selected int + RowFilenames []string // filenames for click navigation } func (lv ListView) Region() Region { return lv.region } func (lv ListView) Visible() bool { return lv.visible } func (lv ListView) ID() string { return lv.id } -func (lv ListView) Interactions() []Interaction { return lv.interactions } +func (lv ListView) Interactions() []Interaction { + // ListView registers its scroll in Draw, not via registerInteraction. + // Filter out Scroll so registerInteraction only handles Tap. + var filtered []Interaction + for _, interaction := range lv.interactions { + if interaction.Gesture != Scroll { + filtered = append(filtered, interaction) + } + } + return filtered +} + +// Draw renders each list item as a row of text. +func (lv ListView) Draw(gtx layout.Context, r *Renderer) { + // Register scroll gesture first, so click gestures registered later are checked first + if len(lv.interactions) > 0 { + var scrollHandler func(any) + for _, interaction := range lv.interactions { + if interaction.Gesture == Scroll { + scrollHandler = interaction.Handler + break + } + } + if scrollHandler != nil { + r.RegisterScroll(gtx, lv.id, lv.region, scrollHandler) + } + } + rowHeight := Dp(48) + for i, item := range lv.Items { + // 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, + 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] + } + r.RegisterClick(gtx, rowID, Region{ + X: lv.region.X, Y: y, + W: lv.region.W, H: rowHeight, + }, func(data any) { + OpenFile(filename) + }) + // Draw main text + textRegion := Region{ + X: lv.region.X + Dp(8), + Y: lv.region.Y + Dp(i)*rowHeight + Dp(4), + W: lv.region.W - Dp(32), + H: rowHeight - Dp(8), + } + r.drawText(gtx, item.Text, 14, textRegion, AlignStart, Color{R: 0, G: 0, B: 0, A: 255}, "") + // Draw subtext + if item.Subtext != "" { + subRegion := Region{ + X: lv.region.X + Dp(8), + Y: lv.region.Y + Dp(i)*rowHeight + rowHeight - Dp(22), + W: lv.region.W - Dp(32), + H: Dp(16), + } + r.drawText(gtx, item.Subtext, 12, subRegion, AlignStart, Color{R: 128, G: 128, B: 128, A: 255}, "") + } + } +} + +// NewListView creates a visible ListView element. +func NewListView(id string, items []ListItem, region Region, scrollOffset, selected int, interactions []Interaction) ListView { + return ListView{ + id: id, + region: region, + visible: true, + interactions: interactions, + Items: items, + ScrollOffset: scrollOffset, + Selected: selected, + } +} // ListItem is a single entry in a ListView. type ListItem struct { @@ -211,6 +314,24 @@ func (ai AlphaIndex) Visible() bool { return ai.visible } func (ai AlphaIndex) ID() string { return ai.id } func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions } +// Draw renders letters vertically along the right edge. +func (ai AlphaIndex) Draw(gtx layout.Context, r *Renderer) { + letterHeight := ai.region.H / Dp(len(ai.Letters)) + for i, letter := range ai.Letters { + region := Region{ + X: ai.region.X, + Y: ai.region.Y + Dp(i)*letterHeight, + W: ai.region.W, + H: letterHeight, + } + col := Color{R: 100, G: 100, B: 100, A: 255} + if letter == ai.ActiveLetter { + col = Color{R: 0, G: 100, B: 200, A: 255} + } + r.drawText(gtx, letter, 10, region, AlignCenter, col, "") + } +} + // NewAlphaIndex creates a visible AlphaIndex element. func NewAlphaIndex(region Region, letters []string) AlphaIndex { return AlphaIndex{ @@ -381,6 +502,10 @@ func NewSpacer(height Dp) Spacer { // --- Utility types --- +// OpenFile is called by the browser list when a file row is tapped. +// Set by the editor package after initialization. +var OpenFile func(any) + // Color is an RGBA color. type Color struct { R, G, B, A uint8 diff --git a/internal/ui/icons/back.png b/internal/ui/icons/back.png new file mode 100644 index 0000000..8d51f70 Binary files /dev/null and b/internal/ui/icons/back.png differ diff --git a/internal/ui/icons/back.svg b/internal/ui/icons/back.svg new file mode 100644 index 0000000..51be2ba --- /dev/null +++ b/internal/ui/icons/back.svg @@ -0,0 +1,3 @@ + + + diff --git a/internal/ui/icons/gen_icons.go b/internal/ui/icons/gen_icons.go index 81ca97d..6491f44 100644 --- a/internal/ui/icons/gen_icons.go +++ b/internal/ui/icons/gen_icons.go @@ -28,7 +28,7 @@ var iconTemplate = template.Must(template.New("").Parse(`// Code generated by ge package icons import ( - _ "embed" + "embed" ) //go:embed {{.Embeds}} diff --git a/internal/ui/icons/icons.go b/internal/ui/icons/icons.go index 4c8c643..c64b918 100644 --- a/internal/ui/icons/icons.go +++ b/internal/ui/icons/icons.go @@ -6,9 +6,15 @@ import ( "embed" ) -//go:embed copy.svg cut.svg paste.svg +//go:embed back.svg copy.svg cut.svg paste.svg var svgFS embed.FS +// backSVG contains the back icon SVG source. +var backSVG = ` + + +` + // copySVG contains the copy icon SVG source. var copySVG = ` @@ -17,16 +23,12 @@ var copySVG = ` - - - - - - - - - +var cutSVG = ` + + + + + ` diff --git a/internal/ui/render.go b/internal/ui/render.go index 70460af..b43cbcd 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -72,7 +72,7 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer { // loadIcons loads PNG icons from the embedded filesystem. func (r *Renderer) loadIcons() { - for _, name := range []string{"cut", "copy", "paste"} { + for _, name := range []string{"back", "cut", "copy", "paste"} { data, err := iconFS.ReadFile("icons/" + name + ".png") if err != nil { continue @@ -151,6 +151,39 @@ func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx l } } +// RegisterClick registers a click gesture for a rectangular region. +// Used by ListView to register per-row click areas. The click.Add() call +// is made within a clip so only the region is clickable. +func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, handler func(any)) { + reg, ok := r.clicks[id] + if !ok { + reg = clickReg{click: &gesture.Click{}} + } + reg.handler = handler + r.clicks[id] = reg + // Clip click to row region + clickClip := clip.Rect{ + Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))}, + Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))}, + }.Push(gtx.Ops) + reg.click.Add(gtx.Ops) + clickClip.Pop() +} + +// RegisterScroll registers a scroll gesture for a rectangular region. +// Called by elements during their Draw method, after paint operations. +// No clip is pushed — the scroll gesture is registered at the current area, +// similar to Gio's layout.List. +func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) { + reg, ok := r.scrolls[id] + if !ok { + reg = scrollReg{scroll: &gesture.Scroll{}} + } + reg.handler = handler + r.scrolls[id] = reg + reg.scroll.Add(gtx.Ops) +} + // CheckGestures checks all registered gestures and returns any events. func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { var events []InputEvent @@ -163,19 +196,19 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { }) } } - for _, reg := range r.scrolls { - // gesture.Scroll.Update returns scroll delta in pixels. - // ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet). - // With Min==Max==0, clampSplit consumes zero scroll. - delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical, - pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30}) - if delta != 0 { - events = append(events, InputEvent{ - Handler: reg.handler, - Data: delta, - }) + for _, reg := range r.scrolls { + // gesture.Scroll.Update returns scroll delta in pixels. + // ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet). + // With Min==Max==0, clampSplit consumes zero scroll. + delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical, + pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30}) + if delta != 0 { + events = append(events, InputEvent{ + Handler: reg.handler, + Data: delta, + }) + } } - } return events }