diff --git a/internal/ui/element.go b/internal/ui/element.go index 45872c8..148e590 100644 --- a/internal/ui/element.go +++ b/internal/ui/element.go @@ -7,12 +7,12 @@ import ( "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" - "gioui.org/io/key" ) // Region defines a screen area in device-independent pixels (Dp). @@ -47,9 +47,9 @@ type Container struct { 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) 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{}) { @@ -96,9 +96,9 @@ type Label struct { 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) 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 @@ -109,7 +109,7 @@ func (l Label) Draw(gtx layout.Context, r *Renderer) { // 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 } +func (l Label) ID() string { return l.id } // String returns a string representation of the Label. func (l Label) String() string { @@ -139,9 +139,9 @@ type Icon struct { 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) 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) @@ -157,7 +157,7 @@ func (i Icon) Draw(gtx layout.Context, r *Renderer) { } r.drawPng(gtx, img, i.region, w, h) } -func (i Icon) ID() string { return i.id } +func (i Icon) ID() string { return i.id } // String returns a string representation of the Icon. func (i Icon) String() string { @@ -178,27 +178,27 @@ func NewIcon(name string, region Region, size Dp, interactions []Interaction) Ic // 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 + id string + region Region + visible bool + interactions []Interaction + Value string + Placeholder string + Focused bool + Multiline bool CursorPosition int - ScrollOffset Dp - VisibleLines []Line - WordWrap bool - WrapWidth Dp + 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) 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 } +func (tf TextField) NeedsClip() bool { return true } // String returns a string representation of the TextField. func (tf TextField) String() string { @@ -209,7 +209,15 @@ func (tf TextField) String() string { // and draws display lines inline โ€” one LayoutString call, no double-shaping. func (tf TextField) Draw(gtx layout.Context, r *Renderer) { if tf.Focused { - fmt.Printf("Focused: tag = %s\n", tf.id) + // (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.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 @@ -220,15 +228,34 @@ func (tf TextField) Draw(gtx layout.Context, r *Renderer) { // 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. - gtx.Execute(key.SnippetCmd{Tag: tf.id, Snippet: key.Snippet{ + // 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 so the IME's selection matches. Window-relative - // rune index of the caret (tf.CursorPosition is a byte offset in tf.Value). + // rune index of the caret (tf.CursorPosition is a byte offset in + // tf.Value). Push only when it moves, so a static caret does not reset + // the IME every frame. caret := runeCount(tf.Value, tf.CursorPosition) - gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: key.Range{Start: caret, End: caret}, Caret: key.Caret{}}) + if caret != r.lastSelCaret { + r.lastSelCaret = caret + gtx.Execute(key.SelectionCmd{Tag: tf.id, Range: key.Range{Start: caret, End: caret}, 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.lastSelCaret = -1 } r.drawWrappedText(gtx, tf.Value, tf.region, tf.WrapWidth, tf.ScrollOffset, tf.CursorPosition) } @@ -253,15 +280,15 @@ func runeCount(s string, bytePos int) int { // NewTextField creates a visible multiline TextField. func NewTextField(id string, value string, region Region, wrapWidth Dp, scrollOffset Dp, cursorPos int, interactions []Interaction) TextField { return TextField{ - id: id, - region: region, - visible: true, - interactions: interactions, - Value: value, - Multiline: true, - WordWrap: true, - WrapWidth: wrapWidth, - ScrollOffset: scrollOffset, + id: id, + region: region, + visible: true, + interactions: interactions, + Value: value, + Multiline: true, + WordWrap: true, + WrapWidth: wrapWidth, + ScrollOffset: scrollOffset, CursorPosition: cursorPos, } } @@ -274,21 +301,21 @@ type Line struct { // 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 + 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) 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. @@ -340,12 +367,12 @@ func (lv ListView) Draw(gtx layout.Context, r *Renderer) { } // 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}) - } + 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) @@ -421,10 +448,10 @@ type AlphaIndex struct { 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) 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. @@ -452,15 +479,15 @@ type Button struct { 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) 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 } +func (b Button) ID() string { return b.id } // String returns a string representation of the Button. func (b Button) String() string { @@ -490,10 +517,10 @@ type SearchBar struct { 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) 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. @@ -526,11 +553,11 @@ type Cursor struct { 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) 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. @@ -596,10 +623,10 @@ type MergeHunk struct { 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) 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. @@ -627,10 +654,10 @@ type Toast struct { 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) 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. @@ -656,10 +683,10 @@ type Spacer struct { 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) 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. @@ -685,10 +712,10 @@ type GioEditor struct { 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) 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. diff --git a/internal/ui/render.go b/internal/ui/render.go index 48271c6..b4dd089 100644 --- a/internal/ui/render.go +++ b/internal/ui/render.go @@ -15,6 +15,7 @@ import ( "gioui.org/gesture" "gioui.org/io/event" // Import event package "gioui.org/io/input" + "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/layout" "gioui.org/op" @@ -55,17 +56,27 @@ type scrollReg struct { // that Gio mutates during draw (e.g. the search bar's widget.Editor): such // state must not live in the logic goroutine's State (architecture.md ยง1). type Renderer struct { - theme Theme - shp *text.Shaper - scale float32 // px-per-Dp for the current draw pass; set in Draw - icons map[string]image.Image - clicks map[string]clickReg - Keys map[string]keyReg // Exported Keys map - scrolls map[string]scrollReg - gioEditors map[string]*widget.Editor // main-owned widget editors by element ID - displayLineCount int // number of display lines from last drawWrappedText - lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout) - glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText + theme Theme + shp *text.Shaper + scale float32 // px-per-Dp for the current draw pass; set in Draw + icons map[string]image.Image + clicks map[string]clickReg + Keys map[string]keyReg // Exported Keys map + scrolls map[string]scrollReg + gioEditors map[string]*widget.Editor // main-owned widget editors by element ID + displayLineCount int // number of display lines from last drawWrappedText + lastLineY Dp // last line baseline offset from text origin, in Dp (derived from GlyphLayout) + glyphLayout GlyphLayout // captured per-glyph layout from last drawWrappedText + + // IME dedup (main-owned, persistent across frames). Re-pushing an unchanged + // snippet or selection every frame resets the IME's composition and caret, + // which desyncs fast commits; push only on change, as widget.Editor does in + // updateSnippet and its selection gating. Keyed to the focused field's ID so + // it stays correct if a second TextField is ever added. + lastIMEField string + lastWasFocused bool + lastSnippet key.Snippet + lastSelCaret int // window-relative rune index of last-pushed caret; -1 = none } // New creates a new Renderer. @@ -228,7 +239,7 @@ func (r *Renderer) CheckGestures(q input.Source, m unit.Metric) []InputEvent { // ScrollY range: Min = -scrollOffset (remaining above), Max = large (content height unknown yet). // With Min==Max==0, clampSplit consumes zero scroll. delta := reg.scroll.Update(m, q, time.Now(), gesture.Vertical, - pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1<<30), Max: 1<<30}) + pointer.ScrollRange{}, pointer.ScrollRange{Min: -(1 << 30), Max: 1 << 30}) if delta != 0 { events = append(events, InputEvent{ Handler: reg.handler, @@ -400,7 +411,7 @@ func (r *Renderer) drawText(gtx layout.Context, str string, size unit.Sp, reg Re case AlignStart: drawX = reg.X case AlignCenter: - drawX = reg.X + (reg.W - textW) / 2 + drawX = reg.X + (reg.W-textW)/2 case AlignEnd: drawX = reg.X + reg.W - textW } @@ -486,9 +497,9 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w PxPerEm: fixed.I(gtx.Sp(r.theme.FontSize)), MinWidth: 0, MaxWidth: int(r.toPx(wrapWidth)), - MaxLines: 0, // unlimited - wrap at MaxWidth + MaxLines: 0, // unlimited - wrap at MaxWidth LineHeight: fixed.I(gtx.Sp(lineHeightSp)), - LineHeightScale: 1.0, // use LineHeight directly, don't scale + LineHeightScale: 1.0, // use LineHeight directly, don't scale WrapPolicy: text.WrapHeuristically, } r.shp.LayoutString(params, str) @@ -515,7 +526,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w var layout GlyphLayout layout.LineHeight = Dp(float32(lineHeightSp)) byteOffset := 0 - layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset) + layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset) for g, ok := r.shp.NextGlyph(); ok; g, ok = r.shp.NextGlyph() { // Record layout data for this glyph. // g.X is in fixed.Int26_6 โ€” shift >> 6 for device pixels, divide by scale for Dp. @@ -537,7 +548,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w line = line[:0] if g.Flags&text.FlagLineBreak != 0 { lineCount++ - layout.VisualLineStarts = append(layout.VisualLineStarts,byteOffset) + layout.VisualLineStarts = append(layout.VisualLineStarts, byteOffset) } } } @@ -565,7 +576,7 @@ func (r *Renderer) drawWrappedText(gtx layout.Context, str string, reg Region, w cursorY = reg.Y - scrollOffset + layout.Y[len(layout.Y)-1] - Dp(r.theme.FontSize) } } - + // Draw the cursor (thin vertical bar) cursorRegion := Region{ X: cursorX,