Browser page: scroll + click coexist via register order fix

- Added Page state (BrowserPage/EditorPage) and SortMode
- BrowserLayout with 30 static entries, sort toggle, search placeholder
- ListView rows clickable via per-row RegisterClick
- Scroll gesture registered at START of ListView.Draw (before clicks)
  so click gestures registered later are checked first during hit test
- Unique IDs for interactive elements: editor_text, browser_list, search_bar
- ListView.Interactions() filters out Scroll (registered in Draw, not registerInteraction)
- RegisterScroll added to Renderer, called from element Draw methods
- lastLineYChan frame loop fixed: only re-layout if value changed
- Back icon added, editor status bar shows active filename
This commit is contained in:
Greg Pomerantz 2026-05-27 12:14:25 -04:00
parent 0246316d8f
commit ad3049dd22
8 changed files with 437 additions and 46 deletions

View File

@ -105,8 +105,10 @@ func (l *Logic) Run() {
update.apply(l.state) update.apply(l.state)
l.frameChan <- l.state.layout() l.frameChan <- l.state.layout()
case y := <-l.lastLineYChan: case y := <-l.lastLineYChan:
l.state.LastLineY = ui.Dp(y) if ui.Dp(y) != l.state.LastLineY {
l.frameChan <- l.state.layout() l.state.LastLineY = ui.Dp(y)
l.frameChan <- l.state.layout()
}
case events := <-l.inputChan: case events := <-l.inputChan:
for _, evt := range events { for _, evt := range events {
evt.Handler(evt.Data) evt.Handler(evt.Data)

View File

@ -4,6 +4,10 @@ import (
"pad/internal/ui" "pad/internal/ui"
) )
func init() {
ui.OpenFile = OpenFile
}
// SampleText is a static lorem-ipsum text used for display-only testing. // SampleText is a static lorem-ipsum text used for display-only testing.
// Roughly 3 KB, filling a few pages of the editor. // Roughly 3 KB, filling a few pages of the editor.
const SampleText = ` const SampleText = `
@ -36,20 +40,48 @@ func EditorLineHeight() ui.Dp {
return ui.Dp(float32(EditorFontSize) * EditorLineHeightScale) 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. // State holds all application state owned by the logic goroutine.
type State struct { type State struct {
PixelWidth int // raw pixel width from Gio ConfigEvent PixelWidth int // raw pixel width from Gio ConfigEvent
PixelHeight int // raw pixel height from Gio ConfigEvent PixelHeight int // raw pixel height from Gio ConfigEvent
scale float32 scale float32
page Page // current page (Browser or Editor)
WordWrap bool WordWrap bool
ScrollOffset ui.Dp // vertical scroll position in Dp ScrollOffset ui.Dp // vertical scroll position in Dp
LastLineY ui.Dp // last line baseline offset from text origin, from renderer LastLineY ui.Dp // last line baseline offset from text origin, from renderer
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
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 { 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) { func (s *State) SetScale(scale float32) {
@ -65,7 +97,12 @@ 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)
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 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. // EditorLayout computes the element tree for the editor page.
func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element { func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
margin := ui.Dp(10) margin := ui.Dp(10)
@ -99,16 +204,22 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
H: ui.Dp(52), H: ui.Dp(52),
} }
statusBarW := statusBarRegion.W statusBarW := statusBarRegion.W
filename := TheState.ActiveFilename
if filename == "" {
filename = "untitled.txt"
}
statusBar := ui.NewContainer( statusBar := ui.NewContainer(
statusBarRegion, statusBarRegion,
ui.Color{R: 230, G: 230, B: 230, A: 255}, ui.Color{R: 230, G: 230, B: 230, A: 255},
[]ui.Element{ []ui.Element{
// Row 1: filename // 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), ui.NewLabel(filename, 14, ui.Region{X: 0, Y: ui.Dp(2), W: statusBarW, H: ui.Dp(20)}, ui.AlignStart, "", nil),
// Row 2: cut, copy, paste icons // Row 2: back, 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("back", 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.Interaction{{Gesture: ui.Tap, Handler: GoToBrowser}}),
ui.NewIcon("paste", ui.Region{X: ui.Dp(96), Y: ui.Dp(28), W: ui.IconSize, H: ui.IconSize}, 0), 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 TheState.MaxScroll = maxScroll
editor := ui.NewTextField( editor := ui.NewTextField(
"editor_text",
SampleText, SampleText,
editorRegion, editorRegion,
editorRegion.W, editorRegion.W,
@ -164,3 +276,117 @@ func EditorLayout(screenWidth, screenHeight ui.Dp, wordWrap bool) []ui.Element {
return []ui.Element{statusBar, editor, bottomBar} 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,
}
}

View File

@ -1,7 +1,11 @@
package ui package ui
import ( import (
"fmt"
"image"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op/clip"
"gioui.org/unit" "gioui.org/unit"
) )
@ -107,6 +111,15 @@ 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
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 // 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 {
@ -117,12 +130,14 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) {
func (i Icon) ID() string { return i.id } func (i Icon) ID() string { return i.id }
// NewIcon creates a visible Icon element. // 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{ return Icon{
region: region, id: name,
visible: true, region: region,
Name: name, visible: true,
Size: size, 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. // 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{ return TextField{
id: id,
region: region, region: region,
visible: true, visible: true,
interactions: interactions, interactions: interactions,
@ -182,12 +198,99 @@ type ListView struct {
Items []ListItem Items []ListItem
ScrollOffset int ScrollOffset int
Selected int Selected int
RowFilenames []string // filenames for click navigation
} }
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) 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. // ListItem is a single entry in a ListView.
type ListItem struct { 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) ID() string { return ai.id }
func (ai AlphaIndex) Interactions() []Interaction { return ai.interactions } 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. // NewAlphaIndex creates a visible AlphaIndex element.
func NewAlphaIndex(region Region, letters []string) AlphaIndex { func NewAlphaIndex(region Region, letters []string) AlphaIndex {
return AlphaIndex{ return AlphaIndex{
@ -381,6 +502,10 @@ func NewSpacer(height Dp) Spacer {
// --- Utility types --- // --- 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. // Color is an RGBA color.
type Color struct { type Color struct {
R, G, B, A uint8 R, G, B, A uint8

BIN
internal/ui/icons/back.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 166 B

View File

@ -0,0 +1,3 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>

After

Width:  |  Height:  |  Size: 228 B

View File

@ -28,7 +28,7 @@ var iconTemplate = template.Must(template.New("").Parse(`// Code generated by ge
package icons package icons
import ( import (
_ "embed" "embed"
) )
//go:embed {{.Embeds}} //go:embed {{.Embeds}}

View File

@ -6,9 +6,15 @@ import (
"embed" "embed"
) )
//go:embed copy.svg cut.svg paste.svg //go:embed back.svg copy.svg cut.svg paste.svg
var svgFS embed.FS var svgFS embed.FS
// backSVG contains the back icon SVG source.
var backSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6"/>
</svg>
`
// copySVG contains the copy icon SVG source. // copySVG contains the copy icon SVG source.
var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24"> var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24">
<rect x="7" y="7" width="12" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/> <rect x="7" y="7" width="12" height="14" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/>
@ -17,16 +23,12 @@ var copySVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width
` `
// cutSVG contains the cut icon SVG source. // cutSVG contains the cut icon SVG source.
var cutSVG = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> var cutSVG = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<!-- Left blade --> <circle cx="6" cy="6" r="3"/>
<circle cx="7" cy="7" r="4" /> <circle cx="6" cy="18" r="3"/>
<line x1="9.5" y1="9.5" x2="14" y2="14" /> <path d="M20 4 8.12 15.12"/>
<!-- Right blade --> <path d="M14.8 14.8 20 20"/>
<circle cx="17" cy="7" r="4" /> <path d="M8.12 8.12 12 12"/>
<line x1="14.5" y1="9.5" x2="10" y2="14" />
<!-- Handles -->
<line x1="7" y1="3" x2="7" y2="11" />
<line x1="17" y1="3" x2="17" y2="11" />
</svg> </svg>
` `

View File

@ -72,7 +72,7 @@ func New(th Theme, shp *text.Shaper, scale ScaleProvider) *Renderer {
// loadIcons loads PNG icons from the embedded filesystem. // loadIcons loads PNG icons from the embedded filesystem.
func (r *Renderer) loadIcons() { 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") data, err := iconFS.ReadFile("icons/" + name + ".png")
if err != nil { if err != nil {
continue 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. // CheckGestures checks all registered gestures and returns any events.
func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
var events []InputEvent var events []InputEvent
@ -163,19 +196,19 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent {
}) })
} }
} }
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).
// With Min==Max==0, clampSplit consumes zero scroll. // With Min==Max==0, clampSplit consumes zero scroll.
delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical, delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical,
pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30}) pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30})
if delta != 0 { if delta != 0 {
events = append(events, InputEvent{ events = append(events, InputEvent{
Handler: reg.handler, Handler: reg.handler,
Data: delta, Data: delta,
}) })
}
} }
}
return events return events
} }