Pad/internal/ui/element.go
Greg Pomerantz f54ca2f81a IME: map commits against the whole buffer; drop the renderer-side model
The renderer kept a mirror of the pushed IME snippet (the 'IME model')
to translate commit positions, but it transiently desynced from the
buffer on fling/tap sequences (observed as a few-byte mapping drift on
both the x86_64 emulator and the ARM phone), corrupting text. The model
string also sat on the main goroutine next to the JNI render path,
where the app observed states that were impossible for Go memory
(string contents changing between reads microseconds apart), pointing
at corruption in the native bridge layer.

Restructure along the lines of the Android InputConnection contract
and Gio's own reference editor (widget/editor.go):

- Commits carry absolute file runes (the pushed snippet's coordinate
  space) straight to the logic goroutine, which maps them to bytes
  against the WHOLE buffer (runeToByteWhole, an 8 KiB-step scan).
  Scrolling moves the window, not the buffer, so the mapping is exact
  mid-fling by construction — no mirror to desync.
- Drift guard in HandleIMECommit: a small commit (range <= 2 runes)
  is always anchored at the caret the IME was last told about; if the
  IME reports it ending elsewhere, its snippet text is stale (a
  dropped restartInput, as Gboard does during flings) and its
  position is in the stale text's coordinates — snap the commit to
  the cursor, the only position it cannot drift from.
- FlushIME simplifies to: push the snippet when the frame's
  (context+window) text differs from the last push (gioui dedupes
  against its own cache), force the selection re-push in the same
  frame. After a commit the frame text equals what the IME already
  holds locally, so the restart is naturally suppressed; a fling
  re-anchors the IME once per text change.
- Remove the renderer model (adoptFrame/ModelTranslate/
  ApplyIMEEdit/ApplyIMEKey/IMECaret), the IME freeze/settle
  machinery (IMEFrozen, markIMEScrollActive, imeSettleChan), and the
  window-relative imeRuneToByte.

Also fixed along the way (both found while chasing the corruption):

- real.ReadFileAt: loop over short reads. A single ReadAt on Android
  FUSE can return a short read, silently truncating a chunk and
  shifting every byte offset after it.
- logic: a late lazy-chunk result no longer clobbers a buffer that
  SetContent has already fully loaded.
- e2e: large-file IME test (1.6 MB file, fling + commit).
- app icon (scripts/make_icon.py + cmd/pad/appicon.png) so gogio
  builds the mipmap/adaptive icon set.

Verified: go vet + staticcheck, go test -race (all packages), and the
emulator scenario loop (open moby excerpt, fling to mid-file, tap,
type 'a', byte-compare the saved file) 75/75 clean.
2026-09-13 11:57:38 -04:00

994 lines
32 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
// IMEContext is the leading context (up to 10 runes immediately before
// the window start) that the renderer prepends to Value when pushing the
// IME snippet; IMEOffset is the absolute file rune offset where
// IMEContext begins (the pushed snippet's Range.Start). Computed by the
// logic layer at frame-build time; see Renderer.FlushIME and the IME
// fields on EditorState (IMEContext/IMEOffsetRune).
IMEContext string
IMEOffset int
// WindowStartByte is the absolute file byte offset where Value (the
// visible window) begins; -1/0 for whole-buffer fields. Used by the
// renderer's IME model to translate snippet positions back to file bytes
// (see Renderer.ModelTranslate).
WindowStartByte int
}
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 {
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})
}
// 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)
// The IME snippet and selection are NOT pushed here. They are pushed
// by Renderer.FlushIME, which the main loop calls after this frame's
// key events were drained (and mirrored into the renderer's IME model)
// and before the ops are flushed to the OS. Pushing the snippet from
// Draw runs before the logic has applied the frame's IME edit, so the
// commit frame would push the pre-edit text; gioui then sees the
// mismatch with the IME's own post-commit snippet and calls
// imm.restartInput() — resetting the keyboard's auto-cap state (the
// random mid-word capitals) on every keystroke. See FlushIME.
} 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; resetting lastSnippet makes the next focus re-push the
// snippet even if the text is unchanged.
r.lastIMEField = ""
r.lastWasFocused = false
r.lastIMEShowSeq = 0
r.lastSnippet = key.Snippet{}
}
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
}