Two feature bodies accumulated in the working tree:
1. Pinch to change the app font size, continuously (no snapping):
- internal/ui/pinch_tracker.go: logic-free touch state machine.
Two-mover formation (the resting palm can land first or last;
movement is the only signal valid for both), pair = the mover
pair whose distance changed most, baseline = press distance
(formDist), lazy pending releases, survivor-scroll forwarding
after a pair break. Robust to ~1 fps frames: a whole pinch can
land in one drain (formDist/brokeFactor/lazy releases).
- render.go: pinch probe (raw pointer events) + grab lifecycle so
the pair is exclusive (scroll sees nothing of the pair) and the
survivor's finger keeps working as a scroll after the pinch.
- state.go/logic.go/session.go/frame.go: app-local float font
scale, content-point pin (buffer byte + offset from baseline,
not a layout point, so rewrap keeps the same character under
the center), restore/font pins, session persistence.
- pinch_test.go, pinch_font_test.go, tag_identity_test.go,
real_draw_probe_test.go: unit + real-Renderer/real-Router tests.
2. Soft keyboard must not shift content:
- Root cause: gioui.org/app calls Router.RevealFocus on any frame
the viewport shrinks (IME open under adjustResize) and
synthesizes a pointer.Scroll nudge aimed at the focused field's
stale pre-resize bounds; gesture.Scroll consumed it -> a 32 dp
content jump.
- Fix: main.go flags the shrink frame; render.go drains that one
synthetic scroll for the gesture's tag before Update (scroll-
range clamping cannot work: the router UNIONs ranges across
frames). Finger scroll (pointer.Drag) and the flinger are
untouched. reveal_focus_drain_test.go reproduces RevealFocus at
the router level and verifies the drain + zero delta.
Also: tools/touchinject (platform-signed emulator multi-touch
injection harness + e2e script, adb has no two-finger input),
docs (spec 2.2 + development_plan 18-20), .gitignore, gofmt.
1041 lines
34 KiB
Go
1041 lines
34 KiB
Go
package ui
|
|
|
|
import (
|
|
"fmt"
|
|
"image"
|
|
"image/color"
|
|
"strings"
|
|
|
|
"gioui.org/font"
|
|
"gioui.org/io/key"
|
|
"gioui.org/layout"
|
|
"gioui.org/op"
|
|
"gioui.org/op/clip"
|
|
"gioui.org/op/paint"
|
|
"gioui.org/unit"
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// String returns a string representation of the Region.
|
|
func (r Region) String() string {
|
|
return fmt.Sprintf("Region{x=%g y=%g w=%g h=%g}", r.X, r.Y, r.W, r.H)
|
|
}
|
|
|
|
// 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)
|
|
String() string
|
|
Type() string
|
|
}
|
|
|
|
// 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 // Changed to Exported
|
|
}
|
|
|
|
func (c Container) Type() string { return "container" }
|
|
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)
|
|
}
|
|
}
|
|
|
|
// String returns a string representation of the Container and all children.
|
|
func (c Container) String() string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("Container[%s] region=%+v bg=%#v children=%d", c.id, c.region, c.background, len(c.Children)))
|
|
for i, child := range c.Children {
|
|
if str, ok := any(child).(fmt.Stringer); ok {
|
|
sb.WriteString(fmt.Sprintf("\n [%d] %s", i, str.String()))
|
|
} else {
|
|
sb.WriteString(fmt.Sprintf("\n [%d] %T region=%+v", i, child, child.Region()))
|
|
}
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// 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) Type() string { return "label" }
|
|
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 }
|
|
|
|
// String returns a string representation of the Label.
|
|
func (l Label) String() string {
|
|
return fmt.Sprintf("Label[%s] text=%q region=%+v align=%d fontSize=%g", l.id, l.Text, l.region, l.Align, l.FontSize)
|
|
}
|
|
|
|
// 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) Type() string { return "icon" }
|
|
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 }
|
|
|
|
// String returns a string representation of the Icon.
|
|
func (i Icon) String() string {
|
|
return fmt.Sprintf("Icon[%s] name=%q region=%+v size=%g", i.id, i.Name, i.region, i.Size)
|
|
}
|
|
|
|
// 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
|
|
CursorPosition int
|
|
// SelectionStart/SelectionEnd are byte offsets into Value (the visible
|
|
// window); -1 means no selection. Used for the IME selection push and the
|
|
// in-app highlight.
|
|
SelectionStart int
|
|
SelectionEnd int
|
|
// MatchRanges are byte ranges [start,end) into Value (the visible
|
|
// window) of in-file search matches; the renderer draws them as yellow
|
|
// highlights. CurrentMatch is the index into MatchRanges of the match the
|
|
// user has navigated to (it is also the active selection); it gets the
|
|
// stronger highlight so it stands out among the others. -1 = none.
|
|
MatchRanges [][2]int
|
|
CurrentMatch int
|
|
// CaretDrag is set after a long press on blank space: a single caret
|
|
// handle is shown and can be dragged to move the caret.
|
|
CaretDrag bool
|
|
ScrollOffset Dp
|
|
VisibleLines []Line
|
|
WordWrap bool
|
|
WrapWidth Dp
|
|
// ShowIMESeq is a monotonically increasing pulse from the logic layer
|
|
// (incremented on focus gain, file open, and editor taps). The renderer
|
|
// issues SoftKeyboardCmd{Show:true} only when it changes, never every
|
|
// frame: a per-frame show re-shows the keyboard while its hide animation
|
|
// is running (the IME insets dispatches redraw the app), so the keyboard
|
|
// could not be dismissed. See TextField.Draw.
|
|
ShowIMESeq uint64
|
|
}
|
|
|
|
func (tf TextField) Type() string { return "textfield" }
|
|
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 }
|
|
func (tf TextField) NeedsClip() bool { return true }
|
|
|
|
// String returns a string representation of the TextField.
|
|
func (tf TextField) String() string {
|
|
return fmt.Sprintf("TextField[%s] region=%+v len=%d multiline=%v", tf.id, tf.region, len(tf.Value), tf.Multiline)
|
|
}
|
|
|
|
// 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) {
|
|
if tf.Focused {
|
|
// (Re-)gained focus for this field (or a different field than the one the
|
|
// dedup state currently tracks): force a fresh snippet/selection push so
|
|
// the IME starts from a known state.
|
|
if r.lastIMEField != tf.id || !r.lastWasFocused {
|
|
r.lastSnippet = key.Snippet{}
|
|
r.lastSelStart = -1
|
|
r.lastSelCaret = -1
|
|
}
|
|
r.lastIMEField = tf.id
|
|
r.lastWasFocused = true
|
|
// Issue key.FocusCmd only on a focus transition (see
|
|
// Renderer.lastFocusCmdID): a per-frame no-op FocusCmd still triggers
|
|
// the router's immediate-command path, which re-queues the frame's
|
|
// pointer events and replays every touch event into every gesture.
|
|
r.focusSeenThisFrame = true
|
|
if r.lastFocusCmdID != tf.id {
|
|
r.lastFocusCmdID = tf.id
|
|
gtx.Execute(key.FocusCmd{Tag: tf.id})
|
|
}
|
|
// Raise the soft keyboard only when the logic layer pulses it (focus
|
|
// gain, file open, editor tap), not every frame. A per-frame show was
|
|
// harmless while the app only redrew on user events, but the IME
|
|
// insets now redraw the app during the keyboard's hide animation; a
|
|
// per-frame show re-shows the keyboard mid-animation, so BACK/chevron
|
|
// could never dismiss it (matches widget.Editor, which shows on focus
|
|
// gain and click only).
|
|
if tf.ShowIMESeq != r.lastIMEShowSeq {
|
|
r.lastIMEShowSeq = tf.ShowIMESeq
|
|
gtx.Execute(key.SoftKeyboardCmd{Show: true})
|
|
}
|
|
// IME wiring. The visible window (tf.Value) is pushed as the snippet
|
|
// with Range {0, len}, so the IME treats the window as the document and
|
|
// reports EditEvent.Range window-relative. This lets swipe/autocorrect
|
|
// operate on the visible text without shipping the whole file to the IME.
|
|
//
|
|
// Item 4: tell the IME this is a text field (enables the text keyboard,
|
|
// autocorrect, and suggestions).
|
|
key.InputHintOp{Tag: tf.id, Hint: key.HintText}.Add(gtx.Ops)
|
|
// Item 2: push the snippet (the visible window) for swipe/autocorrect,
|
|
// but only when it changed. Re-pushing an unchanged snippet every frame
|
|
// resets the IME's composition/cursor, which desyncs fast commits (see
|
|
// widget.Editor's updateSnippet dedup).
|
|
snippet := key.Snippet{
|
|
Range: key.Range{Start: 0, End: runeCount(tf.Value, len(tf.Value))},
|
|
Text: tf.Value,
|
|
}
|
|
if snippet != r.lastSnippet {
|
|
r.lastSnippet = snippet
|
|
gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: snippet})
|
|
}
|
|
// Item 1: sync the caret/selection so the IME's selection matches.
|
|
// Window-relative rune indices (tf.CursorPosition and the selection
|
|
// bounds are byte offsets into tf.Value). With a selection, push the
|
|
// full range so the IME highlights it and a commit replaces it (the
|
|
// logic side unions the commit range with the selection, so the
|
|
// replacement is deterministic regardless of what the IME reports).
|
|
// Push only when the (start, end) pair changes, so a static selection
|
|
// does not reset the IME every frame.
|
|
var selStart, selEnd int
|
|
if tf.SelectionStart >= 0 && tf.SelectionEnd > tf.SelectionStart {
|
|
// Clamp to the window (the element may be built from a window that
|
|
// does not fully contain the selection).
|
|
s := tf.SelectionStart
|
|
if s < 0 {
|
|
s = 0
|
|
}
|
|
e := tf.SelectionEnd
|
|
if e > len(tf.Value) {
|
|
e = len(tf.Value)
|
|
}
|
|
selStart = runeCount(tf.Value, s)
|
|
selEnd = runeCount(tf.Value, e)
|
|
} else {
|
|
selStart = -1
|
|
selEnd = runeCount(tf.Value, tf.CursorPosition)
|
|
}
|
|
if selStart != r.lastSelStart || selEnd != r.lastSelCaret {
|
|
// While a selection/caret handle drag is in progress, defer the
|
|
// IME selection sync (see Renderer.selDragActive): pushing a
|
|
// SelectionCmd every frame the drag moves the selection triggers the
|
|
// input router's immediate-command replay of the frame's pointer
|
|
// events, which re-injects the drag into every gesture and makes the
|
|
// selection jump. The final selection is pushed on the first frame
|
|
// after the drag ends (lastSelStart/lastSelCaret were not updated,
|
|
// so the mismatch persists until then).
|
|
if !r.selDragActive {
|
|
r.lastSelStart = selStart
|
|
r.lastSelCaret = selEnd
|
|
rng := key.Range{Start: selStart, End: selEnd}
|
|
if selStart < 0 {
|
|
rng = key.Range{Start: selEnd, End: selEnd}
|
|
}
|
|
gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: rng, Caret: key.Caret{}})
|
|
}
|
|
}
|
|
} else if r.lastIMEField == tf.id {
|
|
// This (previously-focused) field lost focus: forget it so the next focus
|
|
// pushes a fresh snippet/selection. Resetting lastIMEShowSeq makes the
|
|
// next focus re-raise the keyboard even without a fresh pulse.
|
|
r.lastIMEField = ""
|
|
r.lastWasFocused = false
|
|
r.lastSnippet = key.Snippet{}
|
|
r.lastSelStart = -1
|
|
r.lastSelCaret = -1
|
|
r.lastIMEShowSeq = 0
|
|
}
|
|
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WordWrap, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd, tf.CaretDrag, tf.MatchRanges, tf.CurrentMatch, tf.Focused)
|
|
}
|
|
|
|
// runeCount returns the number of UTF-8 runes in s[:bytePos] (bytePos is a
|
|
// byte offset, clamped to len(s)). A rune starts at an ASCII byte (<0x80) or a
|
|
// multi-byte lead byte (>=0xC0); 0x80-0xBF are continuation bytes.
|
|
func runeCount(s string, bytePos int) int {
|
|
if bytePos > len(s) {
|
|
bytePos = len(s)
|
|
}
|
|
n := 0
|
|
for i := 0; i < bytePos; i++ {
|
|
b := s[i]
|
|
if b < 0x80 || b >= 0xC0 {
|
|
n++
|
|
}
|
|
}
|
|
return n
|
|
}
|
|
|
|
// NewTextField creates a visible multiline TextField.
|
|
func NewTextField(id string, value string, region Region, wordWrap bool, wrapWidth Dp, scrollOffset Dp, cursorPos int, selStart, selEnd int, interactions []Interaction) TextField {
|
|
return TextField{
|
|
id: id,
|
|
region: region,
|
|
visible: true,
|
|
interactions: interactions,
|
|
Value: value,
|
|
Multiline: true,
|
|
WordWrap: wordWrap,
|
|
WrapWidth: wrapWidth,
|
|
ScrollOffset: scrollOffset,
|
|
CursorPosition: cursorPos,
|
|
SelectionStart: selStart,
|
|
SelectionEnd: selEnd,
|
|
}
|
|
}
|
|
|
|
// 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 Dp // pixel-level scroll offset
|
|
Selected int
|
|
RowTapHandler func(any) // handler for row taps, receives index as any
|
|
}
|
|
|
|
func (lv ListView) Type() string { return "listview" }
|
|
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
|
|
}
|
|
|
|
// String returns a string representation of the ListView.
|
|
func (lv ListView) String() string {
|
|
var sb strings.Builder
|
|
sb.WriteString(fmt.Sprintf("ListView[%s] region=%+v items=%d selected=%d", lv.id, lv.region, len(lv.Items), lv.Selected))
|
|
for i, item := range lv.Items {
|
|
sb.WriteString(fmt.Sprintf("\n [%d] %s", i, item.String()))
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// 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)
|
|
firstVisibleRow := int(lv.ScrollOffset / rowHeight)
|
|
for i, item := range lv.Items {
|
|
// Account for scroll offset: row Y is shifted up by scroll amount
|
|
rowGlobalIndex := firstVisibleRow + i
|
|
y := lv.region.Y + Dp(rowGlobalIndex)*rowHeight - lv.ScrollOffset
|
|
// Only draw visible rows (virtualized)
|
|
if y+rowHeight < lv.region.Y || y > lv.region.Y+lv.region.H {
|
|
continue
|
|
}
|
|
// Draw background for selected item (removed as per request)
|
|
/*
|
|
if item.Selected || (lv.Selected == i) {
|
|
r.drawBg(gtx, Region{
|
|
X: lv.region.X, Y: y,
|
|
W: lv.region.W, H: rowHeight,
|
|
}, Color{R: 200, G: 220, B: 255, A: 255})
|
|
}
|
|
*/
|
|
// Register click area for this row
|
|
rowID := fmt.Sprintf("list_row_%d", rowGlobalIndex)
|
|
r.RegisterClick(gtx, rowID, Region{
|
|
X: lv.region.X, Y: y,
|
|
W: lv.region.W, H: rowHeight,
|
|
}, func(data any) {
|
|
// ListView clicks are simple taps, not coordinate-based.
|
|
if lv.RowTapHandler != nil {
|
|
lv.RowTapHandler(rowGlobalIndex)
|
|
} else {
|
|
OpenFile(item.Text)
|
|
}
|
|
})
|
|
// Draw main text
|
|
textRegion := Region{
|
|
X: lv.region.X + Dp(8),
|
|
Y: y + 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: y + 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 Dp, selected int, interactions []Interaction, rowTapHandler func(any)) ListView {
|
|
return ListView{
|
|
id: id,
|
|
region: region,
|
|
visible: true,
|
|
interactions: interactions,
|
|
Items: items,
|
|
ScrollOffset: scrollOffset,
|
|
Selected: selected,
|
|
RowTapHandler: rowTapHandler,
|
|
}
|
|
}
|
|
|
|
// ListItem is a single entry in a ListView.
|
|
type ListItem struct {
|
|
Text string
|
|
Subtext string
|
|
Selected bool
|
|
}
|
|
|
|
// String returns a string representation of the ListItem.
|
|
func (li ListItem) String() string {
|
|
selected := ""
|
|
if li.Selected {
|
|
selected = " *"
|
|
}
|
|
return fmt.Sprintf("ListItem text=%q subtext=%q%s", li.Text, li.Subtext, selected)
|
|
}
|
|
|
|
// 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) Type() string { return "alphaindex" }
|
|
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 }
|
|
|
|
// String returns a string representation of the AlphaIndex.
|
|
func (ai AlphaIndex) String() string {
|
|
return fmt.Sprintf("AlphaIndex[%s] region=%+v letters=%v active=%q", ai.id, ai.region, ai.Letters, ai.ActiveLetter)
|
|
}
|
|
|
|
// 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) Type() string { return "button" }
|
|
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 }
|
|
|
|
// String returns a string representation of the Button.
|
|
func (b Button) String() string {
|
|
return fmt.Sprintf("Button[%s] text=%q region=%+v enabled=%v", b.id, b.Text, b.region, b.Enabled)
|
|
}
|
|
|
|
// 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) Type() string { return "searchbar" }
|
|
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 }
|
|
|
|
// String returns a string representation of the SearchBar.
|
|
func (sb SearchBar) String() string {
|
|
return fmt.Sprintf("SearchBar[%s] region=%+v query=%q match=%d/%d", sb.id, sb.region, sb.Query, sb.Match, sb.Total)
|
|
}
|
|
|
|
// 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
|
|
ClipRegion Region // Region to clip drawing
|
|
visible bool
|
|
interactions []Interaction
|
|
Line int
|
|
Column int
|
|
Blinking bool
|
|
Selection *Selection
|
|
}
|
|
|
|
func (c Cursor) NeedsClip() bool { return true }
|
|
func (c Cursor) Type() string { return "cursor" }
|
|
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 }
|
|
|
|
// String returns a string representation of the Cursor.
|
|
func (c Cursor) String() string {
|
|
return fmt.Sprintf("Cursor[%s] region=%+v line=%d col=%d", c.id, c.region, c.Line, c.Column)
|
|
}
|
|
func (c Cursor) Draw(gtx layout.Context, r *Renderer) {
|
|
// Clip to the cursor's allocated clip region (e.g., the editor text field)
|
|
// to prevent drawing over status bars.
|
|
var stack *clip.Stack
|
|
if c.ClipRegion.W > 0 && c.ClipRegion.H > 0 {
|
|
stack = new(clip.Stack)
|
|
*stack = clip.Rect{
|
|
Min: image.Point{X: int(r.toPx(c.ClipRegion.X)), Y: int(r.toPx(c.ClipRegion.Y))},
|
|
Max: image.Point{X: int(r.toPx(c.ClipRegion.X + c.ClipRegion.W)), Y: int(r.toPx(c.ClipRegion.Y + c.ClipRegion.H))},
|
|
}.Op().Push(gtx.Ops)
|
|
}
|
|
|
|
// Draw a thin vertical bar (e.g., width 2dp, height 18dp) at the cursor's top-left position
|
|
// instead of filling the entire region, which covers the text editor.
|
|
cursorRegion := Region{
|
|
X: c.region.X,
|
|
Y: c.region.Y,
|
|
W: Dp(2),
|
|
H: Dp(18),
|
|
}
|
|
r.drawBg(gtx, cursorRegion, Color{R: 0, G: 0, B: 0, A: 255})
|
|
|
|
if stack != nil {
|
|
stack.Pop()
|
|
}
|
|
}
|
|
|
|
func NewCursor(id string, region Region, line, col int, blinking bool) Cursor {
|
|
return Cursor{
|
|
id: id,
|
|
region: region,
|
|
visible: true,
|
|
Line: line,
|
|
Column: col,
|
|
Blinking: blinking,
|
|
}
|
|
}
|
|
|
|
// 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) Type() string { return "mergehunk" }
|
|
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 }
|
|
|
|
// String returns a string representation of the MergeHunk.
|
|
func (mh MergeHunk) String() string {
|
|
return fmt.Sprintf("MergeHunk[%s] region=%+v hunk=%d/%d lineRange=%s resolution=%d", mh.id, mh.region, mh.HunkNumber, mh.TotalHunks, mh.LineRange, mh.Resolution)
|
|
}
|
|
|
|
// 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) Type() string { return "toast" }
|
|
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 }
|
|
|
|
// String returns a string representation of the Toast.
|
|
func (t Toast) String() string {
|
|
return fmt.Sprintf("Toast[%s] text=%q region=%+v timeout=%d", t.id, t.Text, t.region, t.Timeout)
|
|
}
|
|
|
|
// 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) Type() string { return "spacer" }
|
|
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 }
|
|
|
|
// String returns a string representation of the Spacer.
|
|
func (s Spacer) String() string {
|
|
return fmt.Sprintf("Spacer[%s] region=%+v", s.id, s.region)
|
|
}
|
|
|
|
// NewSpacer creates a visible Spacer element.
|
|
func NewSpacer(height Dp) Spacer {
|
|
return Spacer{
|
|
region: Region{H: height},
|
|
visible: true,
|
|
}
|
|
}
|
|
|
|
// GioEditor is a reference to a main-owned widget.Editor (see
|
|
// Renderer.RegisterGioEditor). The element itself is pure data: the mutable
|
|
// widget state lives in the renderer, which is owned by the main goroutine.
|
|
type GioEditor struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
interactions []Interaction
|
|
}
|
|
|
|
func (ge GioEditor) Type() string { return "gioeditor" }
|
|
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 }
|
|
|
|
// String returns a string representation of the GioEditor.
|
|
func (ge GioEditor) String() string {
|
|
return fmt.Sprintf("GioEditor[%s] region=%+v", ge.id, ge.region)
|
|
}
|
|
|
|
// Draw renders the Gio Editor widget registered for this element's ID.
|
|
func (ge GioEditor) Draw(gtx layout.Context, r *Renderer) {
|
|
ed, ok := r.GioEditor(ge.id)
|
|
if !ok {
|
|
return
|
|
}
|
|
ed.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
|
|
// NOTE: We pass the tap handler to RegisterClick which uses coordinates.
|
|
var tapHandler func(any)
|
|
for _, interaction := range ge.interactions {
|
|
if interaction.Gesture == Tap {
|
|
tapHandler = interaction.Handler
|
|
break
|
|
}
|
|
}
|
|
r.RegisterClick(gtx, ge.id, ge.region, tapHandler)
|
|
|
|
ed.Layout(gtx, r.shp, font.Font{}, r.theme.FontSize, textColor, selectionColor)
|
|
}
|
|
|
|
// NewGioEditor creates a GioEditor element referencing the widget.Editor
|
|
// registered with the renderer under id.
|
|
func NewGioEditor(id string, region Region) GioEditor {
|
|
return GioEditor{
|
|
id: id,
|
|
region: region,
|
|
visible: true,
|
|
}
|
|
}
|
|
|
|
// --- 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(string)
|
|
|
|
// Color is an RGBA color.
|
|
type Color struct {
|
|
R, G, B, A uint8
|
|
}
|
|
|
|
// String returns a string representation of the Color.
|
|
func (c Color) String() string {
|
|
return fmt.Sprintf("Color{R:%d G:%d B:%d A:%d}", c.R, c.G, c.B, c.A)
|
|
}
|
|
|
|
// 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
|
|
// SelDrag is a selection-handle / selection-body / caret-handle drag.
|
|
// The renderer registers the underlying gesture.Drag ops itself (it owns
|
|
// the handle geometry); this interaction just delivers the logic handler.
|
|
SelDrag
|
|
// Pinch is a two-finger pinch inside the element's text region. The
|
|
// renderer owns the probe (it needs raw two-pointer geometry that no
|
|
// single gesture primitive in Gio v0.10 provides); this interaction just
|
|
// delivers the logic handler, which receives FontPinchEvent.
|
|
Pinch
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// Point defines a 2D coordinate in device-independent pixels (Dp).
|
|
// KeyEvent is a key press with its modifier state, delivered as the Data of
|
|
// an InputEvent to a ui.KeyDown handler. It replaces passing a bare key.Name
|
|
// (which discarded modifier state) so handlers can distinguish e.g.
|
|
// shift+arrow (extend selection) from plain arrow (move cursor).
|
|
type KeyEvent struct {
|
|
Name key.Name
|
|
Shift bool
|
|
}
|
|
|
|
type Point struct {
|
|
X, Y Dp
|
|
}
|
|
|
|
// LongPressPoint is a touch that was held still for the long-press duration.
|
|
// Coordinates are app-local window Dp (the same space as Point from a tap).
|
|
type LongPressPoint struct {
|
|
X, Y Dp
|
|
}
|
|
|
|
// DoubleTapPoint is the second tap of a double tap. App-local window Dp.
|
|
type DoubleTapPoint struct {
|
|
X, Y Dp
|
|
}
|
|
|
|
// SelectionDragEvent is emitted while a selection handle (Which 0 = start,
|
|
// 1 = end), the selection body (Which 2 = move whole selection) or the
|
|
// caret drag handle (Which 3) is being dragged. X/Y are app-local window Dp.
|
|
type SelectionDragEvent struct {
|
|
Which int
|
|
X, Y Dp
|
|
}
|
|
|
|
// SelectionDragEnd is emitted when a selection/caret drag is released.
|
|
type SelectionDragEnd struct{}
|
|
|
|
// FontPinchEvent carries one frame's relative two-finger pinch factor
|
|
// (current inter-finger distance / previous frame's distance, both in px).
|
|
// The logic applies it as a multiplier to the app-local font scale; the
|
|
// value is a plain float32 ratio with no rounding, so the font size is
|
|
// continuous, never snapped to whole points. Center is the pinch midpoint
|
|
// (the average of the two fingers) in app-local window Dp — the same space
|
|
// as Point; the logic anchors the content under this point so it stays put
|
|
// while the font scales.
|
|
type FontPinchEvent struct {
|
|
Scale float32
|
|
Center Point
|
|
}
|
|
|
|
// MenuItem is one button in the selection menu.
|
|
// X/Y/W/H are relative to the Menu region.
|
|
type MenuItem struct {
|
|
Icon string // "copy", "cut", "paste"
|
|
Label string
|
|
X, Y, W, H Dp
|
|
}
|
|
|
|
// Menu is the floating selection menu (Copy / Cut / Paste). The logic goroutine
|
|
// positions it and decides the items; the renderer draws it and routes a tap
|
|
// to the element's Tap interaction with the tapped Point, which the logic
|
|
// hit-tests against its own items (single source of truth for geometry).
|
|
type Menu struct {
|
|
id string
|
|
region Region
|
|
visible bool
|
|
items []MenuItem
|
|
tap []Interaction
|
|
}
|
|
|
|
func NewMenu(id string, region Region, items []MenuItem, tapHandler func(any)) Menu {
|
|
return Menu{
|
|
id: id,
|
|
region: region,
|
|
visible: true,
|
|
items: items,
|
|
tap: []Interaction{{Gesture: Tap, Handler: tapHandler}},
|
|
}
|
|
}
|
|
|
|
func (m Menu) Type() string { return "menu" }
|
|
func (m Menu) Region() Region { return m.region }
|
|
func (m Menu) Visible() bool { return m.visible }
|
|
func (m Menu) ID() string { return m.id }
|
|
func (m Menu) Interactions() []Interaction { return m.tap }
|
|
func (m Menu) Items() []MenuItem { return m.items }
|
|
|
|
func (m Menu) String() string {
|
|
return fmt.Sprintf("Menu[%s] region=%+v items=%d", m.id, m.region, len(m.items))
|
|
}
|
|
|
|
// Draw renders the menu panel and its items. Click registration happens in
|
|
// the renderer's leaf-element path (Tap over the whole panel).
|
|
func (m Menu) Draw(gtx layout.Context, r *Renderer) {
|
|
// Panel background (light, like an Android floating menu).
|
|
r.drawBg(gtx, m.region, Color{R: 245, G: 245, B: 245, A: 255})
|
|
for _, it := range m.items {
|
|
// Item X/Y are relative to the menu region; offset to app coordinates.
|
|
ofx, ofy := m.region.X, m.region.Y
|
|
img := r.icon(it.Icon)
|
|
iconSize := it.H / 2
|
|
iconX := ofx + it.X + (it.W-iconSize)/2
|
|
iconY := ofy + it.Y + 2
|
|
r.drawPng(gtx, img, Region{X: iconX, Y: iconY, W: iconSize, H: iconSize}, iconSize, iconSize)
|
|
r.drawText(gtx, it.Label, unit.Sp(10), Region{X: ofx + it.X, Y: ofy + it.Y + iconSize + 3, W: it.W, H: 12}, AlignCenter, Color{R: 30, G: 30, B: 30, A: 255}, "")
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|