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:
parent
ad3049dd22
commit
23fdeabad4
36
doc/touch.md
36
doc/touch.md
|
|
@ -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 |
|
||||
|---|---|---|
|
||||
| `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. |
|
||||
| `key.InputOp` | `key.Event` | Enables keyboard focus. Captures `Edit` (text insertion), `Press` (Backspace, Enter, Arrows). |
|
||||
| `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:
|
||||
|
||||
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)`.
|
||||
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.
|
||||
|
|
@ -36,7 +36,35 @@ The editor operates in three coordinate spaces:
|
|||
|
||||
### 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.
|
||||
|
||||
|
|
@ -383,7 +411,7 @@ Interactive elements declare their behavior at construction time via the `Intera
|
|||
|
||||
**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.
|
||||
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)`.
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package editor
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gioui.org/widget"
|
||||
"pad/internal/ui"
|
||||
)
|
||||
|
||||
|
|
@ -71,6 +75,8 @@ type State struct {
|
|||
BrowserScrollOffset int // index of first visible browser entry
|
||||
SortMode SortMode // sort mode for browser list
|
||||
SortOrderLabel string // label text for sort order toggle
|
||||
SearchQuery string // current search query
|
||||
SearchEditor widget.Editor // Gio editor for search input
|
||||
// Editor state
|
||||
ActiveFilename string // filename shown in editor status bar
|
||||
}
|
||||
|
|
@ -97,6 +103,14 @@ 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)
|
||||
// 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 {
|
||||
case BrowserPage:
|
||||
s.Elems = BrowserLayout(dpW, dpH, s.SortMode)
|
||||
|
|
@ -140,11 +154,12 @@ func HandleBrowserScroll(data any) {
|
|||
rowDelta = -1
|
||||
}
|
||||
}
|
||||
fmt.Printf("HandleBrowserScroll: %d\n", delta)
|
||||
TheState.BrowserScrollOffset += rowDelta
|
||||
if 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 {
|
||||
TheState.BrowserScrollOffset = maxOffset
|
||||
}
|
||||
|
|
@ -184,6 +199,7 @@ func OpenFile(data any) {
|
|||
|
||||
// ToggleSortOrder cycles the browser sort mode between date and name.
|
||||
func ToggleSortOrder(data any) {
|
||||
fmt.Printf("[ToggleSortOrder] called, mode was %v\n", TheState.SortMode)
|
||||
TheState.SortMode = (TheState.SortMode + 1) % 2
|
||||
switch TheState.SortMode {
|
||||
case SortByDateNewest:
|
||||
|
|
@ -191,6 +207,7 @@ func ToggleSortOrder(data any) {
|
|||
case SortByNameAsc:
|
||||
TheState.SortOrderLabel = "Name"
|
||||
}
|
||||
fmt.Printf("[ToggleSortOrder] mode is now %v\n", TheState.SortMode)
|
||||
}
|
||||
|
||||
// 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}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// It is longer than the viewport so scrolling can be tested.
|
||||
var browserEntries = []ui.ListItem{
|
||||
|
|
@ -317,15 +350,12 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
|
|||
margin := ui.Dp(10)
|
||||
contentWidth := screenWidth - margin*2
|
||||
|
||||
// --- Header: directory name + sort toggle ---
|
||||
// --- Header bar: directory name + sort toggle (gray background like editor StatusBar) ---
|
||||
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:
|
||||
|
|
@ -333,11 +363,15 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
|
|||
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}})
|
||||
headerBar := ui.NewContainer(
|
||||
headerRegion,
|
||||
ui.Color{R: 230, G: 230, B: 230, A: 255},
|
||||
[]ui.Element{
|
||||
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 ---
|
||||
searchHeight := ui.Dp(36)
|
||||
|
|
@ -346,19 +380,15 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
|
|||
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)},
|
||||
searchBar := ui.NewGioEditor("search_bar", searchRegion, &TheState.SearchEditor)
|
||||
TheState.SearchEditor.SingleLine = true
|
||||
|
||||
var searchPlaceholder ui.Element
|
||||
if TheState.SearchEditor.Len() == 0 {
|
||||
searchPlaceholder = ui.NewLabel("Search…", 14,
|
||||
ui.Region{X: margin + ui.Dp(8), Y: searchY + ui.Dp(8), W: contentWidth - ui.Dp(16), H: searchHeight - ui.Dp(16)},
|
||||
ui.AlignStart, "", nil)
|
||||
}
|
||||
|
||||
// --- ListView ---
|
||||
listY := searchY + searchHeight + margin/2
|
||||
|
|
@ -367,7 +397,9 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
|
|||
X: margin, Y: listY,
|
||||
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(
|
||||
"browser_list",
|
||||
visibleEntries,
|
||||
|
|
@ -382,11 +414,13 @@ func BrowserLayout(screenWidth, screenHeight ui.Dp, sortMode SortMode) []ui.Elem
|
|||
list.RowFilenames[i] = entry.Text
|
||||
}
|
||||
|
||||
return []ui.Element{
|
||||
header,
|
||||
sortToggle,
|
||||
elems := []ui.Element{
|
||||
headerBar,
|
||||
searchBar,
|
||||
searchPlaceholder,
|
||||
list,
|
||||
}
|
||||
if searchPlaceholder != nil {
|
||||
elems = append(elems, searchPlaceholder)
|
||||
}
|
||||
elems = append(elems, list)
|
||||
return elems
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,10 +3,15 @@ package ui
|
|||
import (
|
||||
"fmt"
|
||||
"image"
|
||||
"image/color"
|
||||
|
||||
"gioui.org/font"
|
||||
"gioui.org/layout"
|
||||
"gioui.org/op"
|
||||
"gioui.org/op/clip"
|
||||
"gioui.org/op/paint"
|
||||
"gioui.org/unit"
|
||||
"gioui.org/widget"
|
||||
)
|
||||
|
||||
// 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{}) {
|
||||
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)
|
||||
}
|
||||
func (l Label) ID() string { return l.id }
|
||||
|
|
@ -111,15 +118,8 @@ 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()
|
||||
}
|
||||
// Click registration is handled by registerInteraction within drawElement,
|
||||
// which correctly applies the container offset. No separate RegisterClick needed.
|
||||
// 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 {
|
||||
|
|
@ -204,6 +204,7 @@ type ListView struct {
|
|||
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) NeedsClip() bool { return true }
|
||||
func (lv ListView) Interactions() []Interaction {
|
||||
// ListView registers its scroll in Draw, not via registerInteraction.
|
||||
// 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.
|
||||
// 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) {
|
||||
// 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 {
|
||||
var scrollHandler func(any)
|
||||
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 ---
|
||||
|
||||
// OpenFile is called by the browser list when a file row is tapped.
|
||||
|
|
|
|||
|
|
@ -122,30 +122,25 @@ func (r *Renderer) Draw(gtx layout.Context, elems []Element) {
|
|||
|
||||
// registerInteraction registers a gesture for an element.
|
||||
// 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 {
|
||||
case Tap:
|
||||
reg, ok := r.clicks[id]
|
||||
if !ok {
|
||||
reg = clickReg{click: &gesture.Click{}}
|
||||
r.clicks[id] = reg
|
||||
}
|
||||
// click.Add() is called inside drawText, within the text clip
|
||||
reg.handler = interaction.Handler
|
||||
r.clicks[id] = reg
|
||||
// Register click within current clip context
|
||||
reg.click.Add(gtx.Ops)
|
||||
case Scroll:
|
||||
reg, ok := r.scrolls[id]
|
||||
if !ok {
|
||||
reg = scrollReg{scroll: &gesture.Scroll{}}
|
||||
r.scrolls[id] = reg
|
||||
}
|
||||
// Clip scroll gesture to element region
|
||||
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)
|
||||
// Register scroll within current clip context
|
||||
reg.scroll.Add(gtx.Ops)
|
||||
scrollClip.Pop()
|
||||
reg.handler = interaction.Handler
|
||||
r.scrolls[id] = reg
|
||||
}
|
||||
|
|
@ -161,7 +156,7 @@ func (r *Renderer) RegisterClick(gtx layout.Context, id string, region Region, h
|
|||
}
|
||||
reg.handler = handler
|
||||
r.clicks[id] = reg
|
||||
// Clip click to row region
|
||||
// Clip to the specified region for click area
|
||||
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))},
|
||||
|
|
@ -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.
|
||||
// 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.
|
||||
// Should be called while the element's clip is active (e.g., inside a container
|
||||
// or clippable element's Draw method).
|
||||
func (r *Renderer) RegisterScroll(gtx layout.Context, id string, region Region, handler func(any)) {
|
||||
reg, ok := r.scrolls[id]
|
||||
if !ok {
|
||||
|
|
@ -189,13 +183,15 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
|
|||
var events []InputEvent
|
||||
for _, reg := range r.clicks {
|
||||
evt, ok := reg.click.Update(q)
|
||||
if ok && evt.Kind == gesture.KindClick {
|
||||
if ok {
|
||||
if evt.Kind == gesture.KindClick {
|
||||
events = append(events, InputEvent{
|
||||
Handler: reg.handler,
|
||||
Data: evt,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
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).
|
||||
|
|
@ -224,14 +220,13 @@ func (r *Renderer) LastLineY() Dp {
|
|||
return r.lastLineY
|
||||
}
|
||||
|
||||
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
||||
// Register interactions before drawing
|
||||
if interactive, ok := e.(Interactive); ok {
|
||||
for _, interaction := range interactive.Interactions() {
|
||||
r.registerInteraction(interactive.ID(), interaction, gtx, e.Region())
|
||||
}
|
||||
// clippableElement is implemented by elements that need their own clip region
|
||||
// around all their content and interaction registrations.
|
||||
type clippableElement interface {
|
||||
NeedsClip() bool
|
||||
}
|
||||
|
||||
func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
||||
reg := e.Region()
|
||||
|
||||
if container, ok := e.(Container); ok {
|
||||
|
|
@ -247,8 +242,39 @@ func (r *Renderer) drawElement(gtx layout.Context, e Element) {
|
|||
}
|
||||
offset.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 {
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -293,14 +319,17 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re
|
|||
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{
|
||||
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))},
|
||||
}.Push(gtx.Ops)
|
||||
|
||||
if cr, ok := r.clicks[id]; ok && cr.click != nil {
|
||||
cr.click.Add(gtx.Ops)
|
||||
// Register click within the text clip if this is an interactive label
|
||||
if id != "" {
|
||||
if reg, ok := r.clicks[id]; ok && reg.click != nil {
|
||||
reg.click.Add(gtx.Ops)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
// Line spacing is fixed: LineHeight = fontSize × LineHeightScale, independent
|
||||
// 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)),
|
||||
MinWidth: 0,
|
||||
MaxWidth: int(r.toPx(wrapWidth)),
|
||||
MaxLines: 0, // unlimited — wrap at MaxWidth
|
||||
MaxLines: 0, // unlimited - wrap at MaxWidth
|
||||
LineHeight: fixed.I(gtx.Sp(lineHeightSp)),
|
||||
LineHeightScale: 1.0, // use LineHeight directly, don't scale
|
||||
WrapPolicy: text.WrapHeuristically,
|
||||
|
|
@ -387,7 +416,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w
|
|||
}.Push(gtx.Ops)
|
||||
|
||||
// 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,
|
||||
// so passing the same y for all lines gives correct baseline spacing.
|
||||
y := reg.Y - scrollOffset
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user