Selection: shift+arrow extends a selection (absolute byte offsets,
anchor/caret model); insert/backspace/delete replace the selection; the
IME unions its reported range with the active selection; the highlight
is drawn in the TextField and the selection is pushed to the IME.
Android key input (the blocker found during on-device validation):
Gio v0.10 on Android (a) drops modifier state in the JNI bridge and
(b) wraps plain arrow-key presses in input.SystemEvent for focus
navigation, so arrow keys never reached the editor. main.go now
registers explicit named key.Filters for the four arrows (delivers the
press and suppresses the focus jump) and tracks the shift key itself.
Verified on the emulator: plain arrows move the caret, shift+arrow
shows a highlight, typing replaces the selection.
Real-file e2e tests (real on-disk files via the real FileSystem,
multi-chunk 256KB files, chunk-boundary and multi-byte edits) found
and fixed two real bugs:
1. Line index: UpdateLineIndexAfterEdit only shifted offsets; edits
involving newlines left it permanently inconsistent. Replaced with
newline-aware UpdateLineIndexAfterInsert/UpdateLineIndexAfterDelete.
2. Rune granularity: HandleBackspace/HandleDelete deleted one byte,
corrupting multi-byte UTF-8 characters (e.g. a 2-byte char
straddling a chunk boundary). Now rune-granular.
Also: airtight e2e harness load-wait (StatFile/ReadFile/BuildLineIndex
interleaving could satisfy the old condition early).
Full suite green under -race; on-device verified.
892 lines
28 KiB
Go
892 lines
28 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
|
|
ScrollOffset Dp
|
|
VisibleLines []Line
|
|
WordWrap bool
|
|
WrapWidth Dp
|
|
}
|
|
|
|
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
|
|
gtx.Execute(key.FocusCmd{Tag: tf.id})
|
|
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 {
|
|
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.
|
|
r.lastIMEField = ""
|
|
r.lastWasFocused = false
|
|
r.lastSnippet = key.Snippet{}
|
|
r.lastSelStart = -1
|
|
r.lastSelCaret = -1
|
|
}
|
|
r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition, tf.SelectionStart, tf.SelectionEnd)
|
|
}
|
|
|
|
// 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, 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: true,
|
|
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
|
|
)
|
|
|
|
// 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
|
|
}
|
|
|
|
// 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
|
|
}
|