Fix scroll clipping: ListView now clips scroll gestures to its region

- Add clippableElement interface for elements that need their own clip region
- ListView implements NeedsClip() so drawElement pushes a clip before Draw
- RegisterScroll no longer has its own clip; relies on element clip context
- Aligns with Gio's clip-based gesture registration model
- Fix scroll events firing outside the visible list area on browser page

Also:
- Update touch.md documentation to reflect clip-based interaction model
- Remove debug logging from ListView.Draw
- Remove TODO-click-fixes.md (all items resolved)
This commit is contained in:
Greg Pomerantz 2026-05-28 14:56:12 -04:00
parent ad3049dd22
commit 23fdeabad4
4 changed files with 233 additions and 86 deletions

View File

@ -8,7 +8,7 @@ Pad avoids high-level widgets and uses the following low-level Gioui ops and eve
| Op (submitted in paint) | Event / Gesture | Use Case | | Op (submitted in paint) | Event / Gesture | Use Case |
|---|---|---| |---|---|---|
| `gesture.Click.Add` | `gesture.ClickEvent` | Tap detection for UI elements (buttons, status bar labels, icons). Click area is clipped to painted content. | | `gesture.Click.Add` | `gesture.ClickEvent` | Tap detection for UI elements (buttons, status bar labels, icons). |
| `pointer.InputOp` | `pointer.Event` | Editor hit-region. Captures `Press`, `Release`, `Move`, `Drag`, `Scroll` for cursor movement, selection, scrolling. | | `pointer.InputOp` | `pointer.Event` | Editor hit-region. Captures `Press`, `Release`, `Move`, `Drag`, `Scroll` for cursor movement, selection, scrolling. |
| `key.InputOp` | `key.Event` | Enables keyboard focus. Captures `Edit` (text insertion), `Press` (Backspace, Enter, Arrows). | | `key.InputOp` | `key.Event` | Enables keyboard focus. Captures `Edit` (text insertion), `Press` (Backspace, Enter, Arrows). |
| `key.FocusOp` | — | Requests keyboard focus for the active element. | | `key.FocusOp` | — | Requests keyboard focus for the active element. |
@ -19,7 +19,7 @@ Pad avoids high-level widgets and uses the following low-level Gioui ops and eve
Because Logic does not have access to `*op.Ops`, the Op/Event flow is split: Because Logic does not have access to `*op.Ops`, the Op/Event flow is split:
1. **Logic → Elements**: Logic computes `[]Element`. Interactive elements declare `Interaction{Gesture, Handler}` entries. 1. **Logic → Elements**: Logic computes `[]Element`. Interactive elements declare `Interaction{Gesture, Handler}` entries.
2. **Renderer → Ops**: During `drawElement`, the renderer registers interactions (creates `gesture.Click` instances) and submits ops into `*op.Ops`. For text elements, `click.Add()` is called inside the text's content clip. 2. **Renderer → Ops**: During `drawElement`, the renderer registers interactions (creates `gesture.Click` instances) and submits ops into `*op.Ops`. Click registration happens within the element's clip context.
3. **Gioui → Events**: Gioui returns events via `w.Event()`. Gesture events are polled from `input.Source` via `click.Update(q)`. 3. **Gioui → Events**: Gioui returns events via `w.Event()`. Gesture events are polled from `input.Source` via `click.Update(q)`.
4. **Main → Logic**: The Main goroutine batches gesture events into `[]InputEvent` (each carrying its own handler) and sends them to Logic via `inputChan`. 4. **Main → Logic**: The Main goroutine batches gesture events into `[]InputEvent` (each carrying its own handler) and sends them to Logic via `inputChan`.
5. **Logic → State**: Logic calls `evt.Handler(evt.Data)`. Handlers are static functions that access global `TheState` directly. 5. **Logic → State**: Logic calls `evt.Handler(evt.Data)`. Handlers are static functions that access global `TheState` directly.
@ -36,7 +36,35 @@ The editor operates in three coordinate spaces:
### 2.1 Hit Regions ### 2.1 Hit Regions
Each interactive element registers its hit region via `gesture.Click.Add(gtx.Ops)`, called inside the element's content clip. The click area is the bounding box of the painted content (text glyphs, icon image, or full element region), not the element's declared region. Each interactive element registers its hit region via `gesture.Click.Add(gtx.Ops)`. The click area is determined by the **current clip context** on the operation stack at the time `Add()` is called, not by explicit screen coordinates.
**Gio's Clip-Based Model**: Gio doesn't need explicit screen coordinates for click registration. Instead, it uses the clip stack to determine the click area. When you call `clip.Rect{...}.Push(gtx.Ops)`, you add a clipping region. When you call `.Pop()`, you remove it. Everything drawn or registered between Push and Pop is constrained to that region.
**The Correct Pattern**:
```go
// 1. Set the clip to the element's bounds
clipRect := clip.Rect{
Min: image.Point{X: pxMinX, Y: pxMinY},
Max: image.Point{X: pxMaxX, Y: pxMaxY},
}.Push(gtx.Ops)
// 2. Register the click area (uses current clip)
click.Add(gtx.Ops)
// 3. Draw the element (also clipped to same region)
drawElement(gtx)
// 4. Pop the clip
clipRect.Pop()
```
**Why This Works**: The clip stack ensures that:
1. Click registration happens within the same coordinate system as drawing
2. The click area is automatically constrained to the element's visible bounds
3. Nested elements inherit their parent's clip context
4. No manual coordinate conversion is needed
**Critical Detail**: The click area matches the **drawn content position**, not the entire clip region. If text is positioned on the right side of a parent region, the click area will be at the text's actual position, not the entire parent clip.
When Logic receives a click event, the `InputEvent` carries its own handler function. No tag-based dispatch is needed — the handler knows exactly what to do. When Logic receives a click event, the `InputEvent` carries its own handler function. No tag-based dispatch is needed — the handler knows exactly what to do.
@ -383,7 +411,7 @@ Interactive elements declare their behavior at construction time via the `Intera
**Flow**: **Flow**:
1. **Registration**: `drawElement` registers interactions via `registerInteraction`. For `Tap` gestures, a `*gesture.Click` is created and stored in the renderer's `clicks` map, keyed by element ID. The handler is stored alongside it. 1. **Registration**: `drawElement` registers interactions via `registerInteraction`. For `Tap` gestures, a `*gesture.Click` is created and stored in the renderer's `clicks` map, keyed by element ID. The handler is stored alongside it.
2. **Click area**: `click.Add(gtx.Ops)` is called inside the element's content clip (e.g., the text bounding box for labels, the icon image area for icons). Only the painted content is clickable. 2. **Click area**: `click.Add(gtx.Ops)` is called within the element's clip context. The clip defines both the coordinate system and the clipping bounds for the click area. The click area matches the drawn content position within that clip, not the entire clip region.
3. **Polling**: After `renderer.Draw()`, `CheckGestures()` polls all registered `gesture.Click` instances via `click.Update(q)`. 3. **Polling**: After `renderer.Draw()`, `CheckGestures()` polls all registered `gesture.Click` instances via `click.Update(q)`.
4. **Routing**: Click events are returned as `InputEvent{Handler, Data}`. The main loop sends them to the logic goroutine via `inputChan`. 4. **Routing**: Click events are returned as `InputEvent{Handler, Data}`. The main loop sends them to the logic goroutine via `inputChan`.
5. **Execution**: The logic goroutine calls `evt.Handler(evt.Data)`. Handlers are static functions (e.g., `ToggleWordWrap`, `DoCut`) that access global `TheState` directly. 5. **Execution**: The logic goroutine calls `evt.Handler(evt.Data)`. Handlers are static functions (e.g., `ToggleWordWrap`, `DoCut`) that access global `TheState` directly.

View File

@ -1,6 +1,10 @@
package editor package editor
import ( import (
"fmt"
"strings"
"gioui.org/widget"
"pad/internal/ui" "pad/internal/ui"
) )
@ -71,6 +75,8 @@ type State struct {
BrowserScrollOffset int // index of first visible browser entry BrowserScrollOffset int // index of first visible browser entry
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
SearchEditor widget.Editor // Gio editor for search input
// Editor state // Editor state
ActiveFilename string // filename shown in editor status bar ActiveFilename string // filename shown in editor status bar
} }
@ -97,6 +103,14 @@ func (s *State) Scale() float32 {
func (s *State) layout() []ui.Element { func (s *State) layout() []ui.Element {
dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale) dpW := ui.ToDp(ui.Px(s.PixelWidth), s.scale)
dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale) dpH := ui.ToDp(ui.Px(s.PixelHeight), s.scale)
// Sync search query from Editor widget, reset scroll on query change
if s.page == BrowserPage {
newQuery := s.SearchEditor.Text()
if newQuery != s.SearchQuery {
s.BrowserScrollOffset = 0
}
s.SearchQuery = newQuery
}
switch s.page { switch s.page {
case BrowserPage: case BrowserPage:
s.Elems = BrowserLayout(dpW, dpH, s.SortMode) s.Elems = BrowserLayout(dpW, dpH, s.SortMode)
@ -140,11 +154,12 @@ func HandleBrowserScroll(data any) {
rowDelta = -1 rowDelta = -1
} }
} }
fmt.Printf("HandleBrowserScroll: %d\n", delta)
TheState.BrowserScrollOffset += rowDelta TheState.BrowserScrollOffset += rowDelta
if TheState.BrowserScrollOffset < 0 { if TheState.BrowserScrollOffset < 0 {
TheState.BrowserScrollOffset = 0 TheState.BrowserScrollOffset = 0
} }
maxOffset := len(browserEntries) - visibleBrowserRows(TheState.PixelHeight, TheState.scale) maxOffset := len(getFilteredEntries()) - visibleBrowserRows(TheState.PixelHeight, TheState.scale)
if TheState.BrowserScrollOffset > maxOffset { if TheState.BrowserScrollOffset > maxOffset {
TheState.BrowserScrollOffset = maxOffset TheState.BrowserScrollOffset = maxOffset
} }
@ -184,6 +199,7 @@ func OpenFile(data any) {
// ToggleSortOrder cycles the browser sort mode between date and name. // ToggleSortOrder cycles the browser sort mode between date and name.
func ToggleSortOrder(data any) { func ToggleSortOrder(data any) {
fmt.Printf("[ToggleSortOrder] called, mode was %v\n", TheState.SortMode)
TheState.SortMode = (TheState.SortMode + 1) % 2 TheState.SortMode = (TheState.SortMode + 1) % 2
switch TheState.SortMode { switch TheState.SortMode {
case SortByDateNewest: case SortByDateNewest:
@ -191,6 +207,7 @@ func ToggleSortOrder(data any) {
case SortByNameAsc: case SortByNameAsc:
TheState.SortOrderLabel = "Name" TheState.SortOrderLabel = "Name"
} }
fmt.Printf("[ToggleSortOrder] mode is now %v\n", TheState.SortMode)
} }
// EditorLayout computes the element tree for the editor page. // EditorLayout computes the element tree for the editor page.
@ -277,6 +294,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
return []ui.Element{statusBar, editor, bottomBar} return []ui.Element{statusBar, editor, bottomBar}
} }
// getFilteredEntries returns browser entries filtered by the current search query.
func getFilteredEntries() []ui.ListItem {
var filtered []ui.ListItem
query := strings.ToLower(TheState.SearchQuery)
if query == "" {
filtered = browserEntries
} else {
for _, entry := range browserEntries {
if strings.Contains(strings.ToLower(entry.Text), query) {
filtered = append(filtered, entry)
}
}
}
return filtered
}
// browserEntries is a static list of sample files for the browser page. // browserEntries is a static list of sample files for the browser page.
// It is longer than the viewport so scrolling can be tested. // It is longer than the viewport so scrolling can be tested.
var browserEntries = []ui.ListItem{ var browserEntries = []ui.ListItem{
@ -317,15 +350,12 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
margin := ui.Dp(10) margin := ui.Dp(10)
contentWidth := screenWidth - margin*2 contentWidth := screenWidth - margin*2
// --- Header: directory name + sort toggle --- // --- Header bar: directory name + sort toggle (gray background like editor StatusBar) ---
headerHeight := ui.Dp(24) headerHeight := ui.Dp(24)
headerRegion := ui.Region{ headerRegion := ui.Region{
X: margin, Y: margin, X: margin, Y: margin,
W: contentWidth, H: headerHeight, W: contentWidth, H: headerHeight,
} }
header := ui.NewLabel("My Documents", 16, headerRegion, ui.AlignStart, "", nil)
// --- Sort toggle ---
var sortLabel string var sortLabel string
switch sortMode { switch sortMode {
case SortByDateNewest: case SortByDateNewest:
@ -333,11 +363,15 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
case SortByNameAsc: case SortByNameAsc:
sortLabel = "Name ↑" sortLabel = "Name ↑"
} }
sortRegion := ui.Region{ headerBar := ui.NewContainer(
X: contentWidth - ui.Dp(60), Y: ui.Dp(2), W: ui.Dp(60), H: headerHeight, headerRegion,
} ui.Color{R: 230, G: 230, B: 230, A: 255},
sortToggle := ui.NewLabel(sortLabel, 12, sortRegion, ui.AlignEnd, "sort", []ui.Element{
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}}) ui.NewLabel("My Documents", 16, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignStart, "", nil),
ui.NewLabel(sortLabel, 12, ui.Region{X: 0, Y: 0, W: contentWidth, H: headerHeight}, ui.AlignEnd, "sort",
[]ui.Interaction{{Gesture: ui.Tap, Handler: ToggleSortOrder}}),
},
)
// --- Search bar --- // --- Search bar ---
searchHeight := ui.Dp(36) searchHeight := ui.Dp(36)
@ -346,19 +380,15 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
X: margin, Y: searchY, X: margin, Y: searchY,
W: contentWidth, H: searchHeight, W: contentWidth, H: searchHeight,
} }
searchBar := ui.NewTextField( searchBar := ui.NewGioEditor("search_bar", searchRegion, &TheState.SearchEditor)
"search_bar", // id TheState.SearchEditor.SingleLine = true
"", // empty query
searchRegion, var searchPlaceholder ui.Element
searchRegion.W, if TheState.SearchEditor.Len() == 0 {
ui.Dp(0), searchPlaceholder = ui.NewLabel("Search…", 14,
nil, ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(8), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(16)},
)
// 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) ui.AlignStart, "", nil)
}
// --- ListView --- // --- ListView ---
listY := searchY + searchHeight + margin/2 listY := searchY + searchHeight + margin/2
@ -367,7 +397,9 @@ 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,
} }
visibleEntries := browserEntries[TheState.BrowserScrollOffset:] // Filter entries by search query (case-insensitive substring match)
filteredEntries := getFilteredEntries()
visibleEntries := filteredEntries[TheState.BrowserScrollOffset:]
list := ui.NewListView( list := ui.NewListView(
"browser_list", "browser_list",
visibleEntries, visibleEntries,
@ -382,11 +414,13 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
list.RowFilenames[i] = entry.Text list.RowFilenames[i] = entry.Text
} }
return []ui.Element{ elems := []ui.Element{
header, headerBar,
sortToggle,
searchBar, searchBar,
searchPlaceholder,
list,
} }
if searchPlaceholder != nil {
elems = append(elems, searchPlaceholder)
}
elems = append(elems, list)
return elems
} }

View File

@ -3,10 +3,15 @@ package ui
import ( import (
"fmt" "fmt"
"image" "image"
"image/color"
"gioui.org/font"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint"
"gioui.org/unit" "gioui.org/unit"
"gioui.org/widget"
) )
// Region defines a screen area in device-independent pixels (Dp). // Region defines a screen area in device-independent pixels (Dp).
@ -76,6 +81,8 @@ func (l Label) Draw(gtx layout.Context, r *Renderer) {
if col == (Color{}) { if col == (Color{}) {
col = Color{R: 0, G: 0, B: 0, A: 255} col = Color{R: 0, G: 0, B: 0, A: 255}
} }
// Click registration is handled by registerInteraction within drawElement,
// which correctly applies the container offset. No separate RegisterClick needed.
r.drawText(gtx, l.Text, l.FontSize, l.region, l.Align, col, l.id) r.drawText(gtx, l.Text, l.FontSize, l.region, l.Align, col, l.id)
} }
func (l Label) ID() string { return l.id } func (l Label) ID() string { return l.id }
@ -111,15 +118,8 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
if img == nil { if img == nil {
return return
} }
// Register click area if this icon has interactions // Click registration is handled by registerInteraction within drawElement,
if cr, ok := r.clicks[i.id]; ok && cr.click != nil { // which correctly applies the container offset. No separate RegisterClick needed.
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 // Auto-scale: if Size is 0, use region dimensions so the icon fills its region
w, h := Dp(0), Dp(0) w, h := Dp(0), Dp(0)
if i.Size == 0 { if i.Size == 0 {
@ -204,6 +204,7 @@ type ListView struct {
func (lv ListView) Region() Region { return lv.region } func (lv ListView) Region() Region { return lv.region }
func (lv ListView) Visible() bool { return lv.visible } func (lv ListView) Visible() bool { return lv.visible }
func (lv ListView) ID() string { return lv.id } func (lv ListView) ID() string { return lv.id }
func (lv ListView) NeedsClip() bool { return true }
func (lv ListView) Interactions() []Interaction { func (lv ListView) Interactions() []Interaction {
// ListView registers its scroll in Draw, not via registerInteraction. // ListView registers its scroll in Draw, not via registerInteraction.
// Filter out Scroll so registerInteraction only handles Tap. // Filter out Scroll so registerInteraction only handles Tap.
@ -217,8 +218,10 @@ func (lv ListView) Interactions() []Interaction {
} }
// Draw renders each list item as a row of text. // Draw renders each list item as a row of text.
// The scroll gesture is registered via the normal interaction path so it
// is clipped to the list region by the element's clip context.
func (lv ListView) Draw(gtx layout.Context, r *Renderer) { func (lv ListView) Draw(gtx layout.Context, r *Renderer) {
// Register scroll gesture first, so click gestures registered later are checked first // Register scroll gesture - clip is already active from drawElement
if len(lv.interactions) > 0 { if len(lv.interactions) > 0 {
var scrollHandler func(any) var scrollHandler func(any)
for _, interaction := range lv.interactions { for _, interaction := range lv.interactions {
@ -500,6 +503,59 @@ func NewSpacer(height Dp) Spacer {
} }
} }
// GioEditor wraps a Gio widget.Editor for single-line or multiline text input.
type GioEditor struct {
id string
region Region
visible bool
interactions []Interaction
Editor *widget.Editor
}
func (ge GioEditor) Region() Region { return ge.region }
func (ge GioEditor) Visible() bool { return ge.visible }
func (ge GioEditor) ID() string { return ge.id }
func (ge GioEditor) Interactions() []Interaction { return ge.interactions }
// Draw renders the Gio Editor widget.
func (ge GioEditor) Draw(gtx layout.Context, r *Renderer) {
ge.Editor.SingleLine = true
// Position and clip to the editor's region
stack := op.Offset(image.Pt(int(r.toPx(ge.region.X)), int(r.toPx(ge.region.Y)))).Push(gtx.Ops)
defer stack.Pop()
// Draw a simple background for the search bar
rect := image.Rectangle{Max: image.Pt(int(r.toPx(ge.region.W)), int(r.toPx(ge.region.H)))}
paint.FillShape(gtx.Ops, color.NRGBA{R: 245, G: 245, B: 245, A: 255}, clip.Rect(rect).Op())
// Create paint color macros for text and selection colors
textMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: 0, G: 0, B: 0, A: 255}}.Add(gtx.Ops)
textColor := textMacro.Stop()
selectionMacro := op.Record(gtx.Ops)
paint.ColorOp{Color: color.NRGBA{R: 200, G: 220, B: 255, A: 255}}.Add(gtx.Ops)
selectionColor := selectionMacro.Stop()
// Set constraints so the editor knows its size for hit-testing
gtx.Constraints = layout.Exact(rect.Size())
// Use our RegisterClick to ensure the full region is clickable
r.RegisterClick(gtx, ge.id, ge.region, nil)
ge.Editor.Layout(gtx, r.shp, font.Font{}, r.theme.FontSize, textColor, selectionColor)
}
// NewGioEditor creates a GioEditor element wrapping a widget.Editor.
func NewGioEditor(id string, region Region, editor *widget.Editor) GioEditor {
return GioEditor{
id: id,
region: region,
visible: true,
Editor: editor,
}
}
// --- Utility types --- // --- Utility types ---
// OpenFile is called by the browser list when a file row is tapped. // OpenFile is called by the browser list when a file row is tapped.

View File

@ -122,30 +122,25 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
// registerInteraction registers a gesture for an element. // registerInteraction registers a gesture for an element.
// Supports Tap and Scroll. // Supports Tap and Scroll.
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context, region Region) { // The clip context must already be set to the element's bounds before calling this.
func (r *Renderer) registerInteraction(id string, interaction Interaction, gtx layout.Context) {
switch interaction.Gesture { switch interaction.Gesture {
case Tap: case Tap:
reg, ok := r.clicks[id] reg, ok := r.clicks[id]
if !ok { if !ok {
reg = clickReg{click: &gesture.Click{}} reg = clickReg{click: &gesture.Click{}}
r.clicks[id] = reg
} }
// click.Add() is called inside drawText, within the text clip
reg.handler = interaction.Handler reg.handler = interaction.Handler
r.clicks[id] = reg r.clicks[id] = reg
// Register click within current clip context
reg.click.Add(gtx.Ops)
case Scroll: case Scroll:
reg, ok := r.scrolls[id] reg, ok := r.scrolls[id]
if !ok { if !ok {
reg = scrollReg{scroll: &gesture.Scroll{}} reg = scrollReg{scroll: &gesture.Scroll{}}
r.scrolls[id] = reg
} }
// Clip scroll gesture to element region // Register scroll within current clip context
scrollClip := 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.scroll.Add(gtx.Ops) reg.scroll.Add(gtx.Ops)
scrollClip.Pop()
reg.handler = interaction.Handler reg.handler = interaction.Handler
r.scrolls[id] = reg r.scrolls[id] = reg
} }
@ -161,7 +156,7 @@ func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, h
} }
reg.handler = handler reg.handler = handler
r.clicks[id] = reg r.clicks[id] = reg
// Clip click to row region // Clip to the specified region for click area
clickClip := clip.Rect{ clickClip := clip.Rect{
Min: image.Point{X: int(r.toPx(region.X)), Y: int(r.toPx(region.Y))}, 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))}, Max: image.Point{X: int(r.toPx(region.X + region.W)), Y: int(r.toPx(region.Y + region.H))},
@ -171,9 +166,8 @@ func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, h
} }
// RegisterScroll registers a scroll gesture for a rectangular region. // RegisterScroll registers a scroll gesture for a rectangular region.
// Called by elements during their Draw method, after paint operations. // Should be called while the element's clip is active (e.g., inside a container
// No clip is pushed — the scroll gesture is registered at the current area, // or clippable element's Draw method).
// similar to Gio's layout.List.
func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) { func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) {
reg, ok := r.scrolls[id] reg, ok := r.scrolls[id]
if !ok { if !ok {
@ -189,13 +183,15 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
var events []InputEvent var events []InputEvent
for _, reg := range r.clicks { for _, reg := range r.clicks {
evt, ok := reg.click.Update(q) evt, ok := reg.click.Update(q)
if ok && evt.Kind == gesture.KindClick { if ok {
if evt.Kind == gesture.KindClick {
events = append(events, InputEvent{ events = append(events, InputEvent{
Handler: reg.handler, Handler: reg.handler,
Data: evt, Data: evt,
}) })
} }
} }
}
for _, reg := range r.scrolls { for _, reg := range r.scrolls {
// gesture.Scroll.Update returns scroll delta in pixels. // gesture.Scroll.Update returns scroll delta in pixels.
// ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet). // ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet).
@ -224,14 +220,13 @@ func (r *Renderer) LastLineY() Dp {
return r.lastLineY return r.lastLineY
} }
func (r *Renderer) drawElement(gtx layout.Context, e Element) { // clippableElement is implemented by elements that need their own clip region
// Register interactions before drawing // around all their content and interaction registrations.
if interactive, ok := e.(Interactive); ok { type clippableElement interface {
for _, interaction := range interactive.Interactions() { NeedsClip() bool
r.registerInteraction(interactive.ID(), interaction, gtx, e.Region())
}
} }
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
reg := e.Region() reg := e.Region()
if container, ok := e.(Container); ok { if container, ok := e.(Container); ok {
@ -247,8 +242,39 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
} }
offset.Pop() offset.Pop()
clipRect.Pop() clipRect.Pop()
} else if clippable, ok := e.(clippableElement); ok && clippable.NeedsClip() {
// Clip to element bounds before drawing and registering interactions
clipRect := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
e.Draw(gtx, r)
clipRect.Pop()
} else { } else {
// Leaf element: no self-clip (region may be zero-sized; clip is handled by parent container) // Leaf element: register click handlers, then draw.
// For text elements, click registration happens inside Draw after shaping.
if interactive, ok := e.(Interactive); ok {
for _, interaction := range interactive.Interactions() {
// Set up click handler (but don't call Add for text elements)
if interaction.Gesture == Tap {
reg, ok := r.clicks[interactive.ID()]
if !ok {
reg = clickReg{click: &gesture.Click{}}
}
reg.handler = interaction.Handler
r.clicks[interactive.ID()] = reg
}
// For non-text elements, register click immediately
if _, isLabel := e.(Label); !isLabel {
elemClip := clip.Rect{
Min: image.Point{X: int(r.toPx(reg.X)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(reg.X + reg.W)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops)
r.registerInteraction(interactive.ID(), interaction, gtx)
elemClip.Pop()
}
}
}
e.Draw(gtx, r) e.Draw(gtx, r)
} }
} }
@ -293,14 +319,17 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
drawX = reg.X + reg.W - textW drawX = reg.X + reg.W - textW
} }
// Clip to just the text area, register click inside the clip // Clip to just the text area
textClip := clip.Rect{ textClip := clip.Rect{
Min: image.Point{X: int(r.toPx(drawX)), Y: int(r.toPx(reg.Y))}, Min: image.Point{X: int(r.toPx(drawX)), Y: int(r.toPx(reg.Y))},
Max: image.Point{X: int(r.toPx(drawX + textW)), Y: int(r.toPx(reg.Y + reg.H))}, Max: image.Point{X: int(r.toPx(drawX + textW)), Y: int(r.toPx(reg.Y + reg.H))},
}.Push(gtx.Ops) }.Push(gtx.Ops)
if cr, ok := r.clicks[id]; ok && cr.click != nil { // Register click within the text clip if this is an interactive label
cr.click.Add(gtx.Ops) if id != "" {
if reg, ok := r.clicks[id]; ok && reg.click != nil {
reg.click.Add(gtx.Ops)
}
} }
// Layout again (iterator consumed) and draw // Layout again (iterator consumed) and draw
@ -358,7 +387,7 @@ func (r *Renderer) drawLine(gtx layout.Context, line []text.Glyph, x, y Dp, col
} }
// drawWrappedText shapes text once with word wrap and draws display lines inline. // drawWrappedText shapes text once with word wrap and draws display lines inline.
// One LayoutString call no double-shaping. The shaper handles word boundary // One LayoutString call - no double-shaping. The shaper handles word boundary
// detection via WrapHeuristically. Long words overflow the wrap width. // detection via WrapHeuristically. Long words overflow the wrap width.
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent // Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
// of glyph metrics. The shaper's first.Y accounts for line spacing. // of glyph metrics. The shaper's first.Y accounts for line spacing.
@ -373,7 +402,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)), PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)),
MinWidth: 0, MinWidth: 0,
MaxWidth: int(r.toPx(wrapWidth)), MaxWidth: int(r.toPx(wrapWidth)),
MaxLines: 0, // unlimited wrap at MaxWidth MaxLines: 0, // unlimited - wrap at MaxWidth
LineHeight: fixed.I(gtx.Sp(lineHeightSp)), LineHeight: fixed.I(gtx.Sp(lineHeightSp)),
LineHeightScale: 1.0, // use LineHeight directly, don't scale LineHeightScale: 1.0, // use LineHeight directly, don't scale
WrapPolicy: text.WrapHeuristically, WrapPolicy: text.WrapHeuristically,
@ -387,7 +416,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
}.Push(gtx.Ops) }.Push(gtx.Ops)
// Y position: region top minus scroll offset. // Y position: region top minus scroll offset.
// The shaper's first.Y handles line spacing each line's first.Y is // The shaper's first.Y handles line spacing - each line's first.Y is
// ascent + lineHeight × lineIndex. drawLine adds first.Y to y, // ascent + lineHeight × lineIndex. drawLine adds first.Y to y,
// so passing the same y for all lines gives correct baseline spacing. // so passing the same y for all lines gives correct baseline spacing.
y := reg.Y - scrollOffset y := reg.Y - scrollOffset