- 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)
620 lines
17 KiB
Go
620 lines
17 KiB
Go
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).
|
|
// All element positions and sizes use Dp for device independence.
|
|
type Region struct {
|
|
X, Y Dp
|
|
W, H Dp
|
|
}
|
|
|
|
// Element is the base interface for all UI elements.
|
|
// Elements know how to draw themselves when given a Renderer.
|
|
type Element interface {
|
|
Region() Region
|
|
Visible() bool
|
|
Draw(gtx layout.Context, r *Renderer)
|
|
}
|
|
|
|
// Container holds child elements and draws them within its bounds.
|
|
// Children's regions are screen-space; clipping handles containment.
|
|
type Container struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
background Color
|
|
children []Element
|
|
}
|
|
|
|
func (c Container) Region() Region { return c.region }
|
|
func (c Container) Visible() bool { return c.visible }
|
|
func (c Container) Draw(gtx layout.Context, r *Renderer) {
|
|
// Draw background — children are drawn by drawElement, not here
|
|
if c.background != (Color{}) {
|
|
r.drawBg(gtx, c.region, c.background)
|
|
}
|
|
}
|
|
|
|
// NewContainer creates a Container with the given region, background, and children.
|
|
func NewContainer(region Region, bg Color, children []Element) Container {
|
|
return Container{
|
|
region: region,
|
|
visible: true,
|
|
background: bg,
|
|
children: children,
|
|
}
|
|
}
|
|
|
|
// --- Leaf element types (each implements Element and knows how to Draw itself) ---
|
|
|
|
// Label displays static text.
|
|
type Label struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Text string
|
|
Align TextAlign
|
|
FontSize unit.Sp // 0 = theme default
|
|
Color Color // 0 = theme default
|
|
Bold bool
|
|
}
|
|
|
|
func (l Label) Region() Region { return l.region }
|
|
func (l Label) Visible() bool { return l.visible }
|
|
func (l Label) Interactions() []Interaction { return l.interactions }
|
|
func (l Label) Draw(gtx layout.Context, r *Renderer) {
|
|
col := l.Color
|
|
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 }
|
|
|
|
// NewLabel creates a visible Label element.
|
|
func NewLabel(text string, fontSize unit.Sp, region Region, align TextAlign, id string, interactions []Interaction) Label {
|
|
return Label{
|
|
id: id,
|
|
region: region,
|
|
visible: true,
|
|
interactions: interactions,
|
|
Text: text,
|
|
FontSize: fontSize,
|
|
Align: align,
|
|
}
|
|
}
|
|
|
|
// Icon displays a named icon image.
|
|
type Icon struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Name string // icon name, e.g. "cut"
|
|
Size Dp // 0 = default icon size
|
|
}
|
|
|
|
func (i Icon) Region() Region { return i.region }
|
|
func (i Icon) Visible() bool { return i.visible }
|
|
func (i Icon) Interactions() []Interaction { return i.interactions }
|
|
func (i Icon) Draw(gtx layout.Context, r *Renderer) {
|
|
img := r.icon(i.Name)
|
|
if img == nil {
|
|
return
|
|
}
|
|
// 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 {
|
|
w, h = i.region.W, i.region.H
|
|
}
|
|
r.drawPng(gtx, img, i.region, w, h)
|
|
}
|
|
func (i Icon) ID() string { return i.id }
|
|
|
|
// NewIcon creates a visible Icon element.
|
|
func NewIcon(name string, region Region, size Dp, interactions []Interaction) Icon {
|
|
return Icon{
|
|
id: name,
|
|
region: region,
|
|
visible: true,
|
|
interactions: interactions,
|
|
Name: name,
|
|
Size: size,
|
|
}
|
|
}
|
|
|
|
// TextField accepts text input or displays multiline text.
|
|
type TextField struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Value string
|
|
Placeholder string
|
|
Focused bool
|
|
Multiline bool
|
|
ScrollOffset Dp
|
|
VisibleLines []Line
|
|
WordWrap bool
|
|
WrapWidth Dp
|
|
}
|
|
|
|
func (tf TextField) Region() Region { return tf.region }
|
|
func (tf TextField) Visible() bool { return tf.visible }
|
|
func (tf TextField) ID() string { return tf.id }
|
|
func (tf TextField) Interactions() []Interaction { return tf.interactions }
|
|
|
|
// Draw renders the TextField. For multiline text, it shapes with word wrap
|
|
// and draws display lines inline — one LayoutString call, no double-shaping.
|
|
func (tf TextField) Draw(gtx layout.Context, r *Renderer) {
|
|
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset)
|
|
}
|
|
|
|
// NewTextField creates a visible multiline 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,
|
|
Value: value,
|
|
Multiline: true,
|
|
WordWrap: true,
|
|
WrapWidth: wrapWidth,
|
|
ScrollOffset: scrollOffset,
|
|
}
|
|
}
|
|
|
|
// Line represents a single line of text in a multiline TextField.
|
|
type Line struct {
|
|
Text string
|
|
LineNumber int // 1-indexed, for display
|
|
}
|
|
|
|
// ListView displays a scrollable list of items.
|
|
type ListView struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
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) 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.
|
|
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.
|
|
// 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 - clip is already active from drawElement
|
|
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 {
|
|
Text string
|
|
Subtext string
|
|
Selected bool
|
|
}
|
|
|
|
// AlphaIndex displays an alphabetical index for quick navigation.
|
|
type AlphaIndex struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Letters []string
|
|
ActiveLetter string
|
|
}
|
|
|
|
func (ai AlphaIndex) Region() Region { return ai.region }
|
|
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{
|
|
region: region,
|
|
visible: true,
|
|
Letters: letters,
|
|
}
|
|
}
|
|
|
|
// Button is an interactive button element.
|
|
type Button struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Text string
|
|
Enabled bool
|
|
Primary bool
|
|
}
|
|
|
|
func (b Button) Region() Region { return b.region }
|
|
func (b Button) Visible() bool { return b.visible }
|
|
func (b Button) Interactions() []Interaction { return b.interactions }
|
|
func (b Button) Draw(gtx layout.Context, r *Renderer) {
|
|
col := Color{R: 0, G: 0, B: 0, A: 255}
|
|
r.drawText(gtx, b.Text, r.theme.FontSize, b.region, AlignStart, col, b.id)
|
|
}
|
|
func (b Button) ID() string { return b.id }
|
|
|
|
// NewButton creates a visible Button element.
|
|
func NewButton(text string, enabled bool, primary bool, region Region) Button {
|
|
return Button{
|
|
region: region,
|
|
visible: true,
|
|
Text: text,
|
|
Enabled: enabled,
|
|
Primary: primary,
|
|
}
|
|
}
|
|
|
|
// SearchBar displays an in-editor search interface.
|
|
type SearchBar struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Query string
|
|
Match int
|
|
Total int
|
|
Forward bool
|
|
}
|
|
|
|
func (sb SearchBar) Region() Region { return sb.region }
|
|
func (sb SearchBar) Visible() bool { return sb.visible }
|
|
func (sb SearchBar) ID() string { return sb.id }
|
|
func (sb SearchBar) Interactions() []Interaction { return sb.interactions }
|
|
|
|
// NewSearchBar creates a visible SearchBar element.
|
|
func NewSearchBar(region Region, query string, match, total int, forward bool) SearchBar {
|
|
return SearchBar{
|
|
region: region,
|
|
visible: true,
|
|
Query: query,
|
|
Match: match,
|
|
Total: total,
|
|
Forward: forward,
|
|
}
|
|
}
|
|
|
|
// Cursor displays the text cursor and optional selection highlight.
|
|
type Cursor struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Line int
|
|
Column int
|
|
Blinking bool
|
|
Selection *Selection
|
|
}
|
|
|
|
func (c Cursor) Region() Region { return c.region }
|
|
func (c Cursor) Visible() bool { return c.visible }
|
|
func (c Cursor) ID() string { return c.id }
|
|
func (c Cursor) Interactions() []Interaction { return c.interactions }
|
|
|
|
// Selection represents a text selection range.
|
|
type Selection struct {
|
|
StartLine, StartCol int
|
|
EndLine, EndCol int
|
|
}
|
|
|
|
// MergeHunk displays a conflict resolution hunk.
|
|
type MergeHunk struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
HunkNumber int
|
|
TotalHunks int
|
|
LineRange string
|
|
ContextLines []string
|
|
OurLines []string
|
|
TheirLines []string
|
|
Resolution HunkResolution
|
|
}
|
|
|
|
func (mh MergeHunk) Region() Region { return mh.region }
|
|
func (mh MergeHunk) Visible() bool { return mh.visible }
|
|
func (mh MergeHunk) ID() string { return mh.id }
|
|
func (mh MergeHunk) Interactions() []Interaction { return mh.interactions }
|
|
|
|
// HunkResolution represents the resolution state of a merge hunk.
|
|
type HunkResolution int
|
|
|
|
const (
|
|
Unresolved HunkResolution = iota
|
|
KeepOurs
|
|
KeepTheirs
|
|
MergeBoth
|
|
)
|
|
|
|
// Toast displays a temporary notification.
|
|
type Toast struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
Text string
|
|
Timeout int // milliseconds
|
|
}
|
|
|
|
func (t Toast) Region() Region { return t.region }
|
|
func (t Toast) Visible() bool { return t.visible }
|
|
func (t Toast) ID() string { return t.id }
|
|
func (t Toast) Interactions() []Interaction { return t.interactions }
|
|
|
|
// NewToast creates a visible Toast element.
|
|
func NewToast(region Region, text string, timeout int) Toast {
|
|
return Toast{
|
|
region: region,
|
|
visible: true,
|
|
Text: text,
|
|
Timeout: timeout,
|
|
}
|
|
}
|
|
|
|
// Spacer adds vertical or horizontal space.
|
|
type Spacer struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
}
|
|
|
|
func (s Spacer) Region() Region { return s.region }
|
|
func (s Spacer) Visible() bool { return s.visible }
|
|
func (s Spacer) ID() string { return s.id }
|
|
func (s Spacer) Interactions() []Interaction { return s.interactions }
|
|
|
|
// NewSpacer creates a visible Spacer element.
|
|
func NewSpacer(height Dp) Spacer {
|
|
return Spacer{
|
|
region: Region{H: height},
|
|
visible: true,
|
|
}
|
|
}
|
|
|
|
// 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.
|
|
// Set by the editor package after initialization.
|
|
var OpenFile func(any)
|
|
|
|
// Color is an RGBA color.
|
|
type Color struct {
|
|
R, G, B, A uint8
|
|
}
|
|
|
|
// Theme holds styling defaults for the UI.
|
|
type Theme struct {
|
|
FontSize unit.Sp // Gio's shaper requires unit.Sp for font sizes
|
|
}
|
|
|
|
// TextAlign specifies horizontal text alignment.
|
|
type TextAlign int
|
|
|
|
const (
|
|
AlignStart TextAlign = iota
|
|
AlignCenter
|
|
AlignEnd
|
|
)
|
|
|
|
// InputType specifies the type of user input event.
|
|
type InputType int
|
|
|
|
const (
|
|
Tap InputType = iota
|
|
DoubleTap
|
|
LongPress
|
|
Scroll
|
|
KeyDown
|
|
KeyUp
|
|
)
|
|
|
|
// Interaction pairs a gesture type with a handler function.
|
|
type Interaction struct {
|
|
Gesture InputType
|
|
Handler func(any)
|
|
}
|
|
|
|
// Interactive is implemented by elements that respond to input events.
|
|
type Interactive interface {
|
|
ID() string
|
|
Interactions() []Interaction
|
|
}
|
|
|
|
// InputEvent represents a user input event with its handler.
|
|
type InputEvent struct {
|
|
Handler func(any)
|
|
Data any
|
|
}
|
|
|
|
// ConfigEvent represents a window configuration change (resize, orientation).
|
|
// Width and Height are in device-independent pixels (Dp).
|
|
type ConfigEvent struct {
|
|
Width Dp
|
|
Height Dp
|
|
}
|