From e4932e163eb0b1a44051d2a1cd75a08fe0b56765 Mon Sep 17 00:00:00 2001 From: qiannian Date: Tue, 16 Jun 2026 16:28:58 +0800 Subject: [PATCH] widget,io,app: draw IME composition underline Track IME composition range updates as key.CompositionEvent and route them to the focused editor. Draw a thin underline under the current composition range using the editor text material. Keep the composition range in imeState so it is reset with the rest of the editor input method state, and normalize it in input.Router before forwarding CompositionEvent. Signed-off-by: qiannian --- app/window.go | 1 + io/input/router.go | 9 ++++++++- io/key/key.go | 14 +++++++++----- widget/editor.go | 32 ++++++++++++++++++++++++++++++-- 4 files changed, 48 insertions(+), 8 deletions(-) diff --git a/app/window.go b/app/window.go index 977ab6a1..9adba8df 100644 --- a/app/window.go +++ b/app/window.go @@ -439,6 +439,7 @@ func (c *callbacks) EditorState() editorState { func (c *callbacks) SetComposingRegion(r key.Range) { c.w.imeState.compose = r + c.w.driver.ProcessEvent(key.CompositionEvent(r)) } func (c *callbacks) EditorInsert(text string) { diff --git a/io/input/router.go b/io/input/router.go index b72d97cf..7fe13c80 100644 --- a/io/input/router.go +++ b/io/input/router.go @@ -419,7 +419,7 @@ func (f *filter) Merge(f2 filter) { func (f *filter) Matches(e event.Event) bool { switch e.(type) { - case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent: + case key.FocusEvent, key.SnippetEvent, key.EditEvent, key.SelectionEvent, key.CompositionEvent: return f.focusable default: return f.pointer.Matches(e) @@ -463,6 +463,13 @@ func (q *Router) processEvent(e event.Event, system bool) { evts = append(evts, taggedEvent{tag: f, event: e}) } q.changeState(e, state, evts) + case key.CompositionEvent: + e = key.CompositionEvent(rangeNorm(key.Range(e))) + var evts []taggedEvent + if f := state.focus; f != nil { + evts = append(evts, taggedEvent{tag: f, event: e}) + } + q.changeState(e, state, evts) case key.EditEvent, key.FocusEvent, key.SelectionEvent: var evts []taggedEvent if f := state.focus; f != nil { diff --git a/io/key/key.go b/io/key/key.go index d1819ba9..d16c5bb3 100644 --- a/io/key/key.go +++ b/io/key/key.go @@ -77,6 +77,9 @@ type Caret struct { // SelectionEvent is generated when an input method changes the selection. type SelectionEvent Range +// CompositionEvent is generated when an input method changes the composing range. +type CompositionEvent Range + // SnippetEvent is generated when the snippet range is updated by an // input method. type SnippetEvent Range @@ -243,11 +246,12 @@ func (h InputHintOp) Add(o *op.Ops) { data[1] = byte(h.Hint) } -func (EditEvent) ImplementsEvent() {} -func (Event) ImplementsEvent() {} -func (FocusEvent) ImplementsEvent() {} -func (SnippetEvent) ImplementsEvent() {} -func (SelectionEvent) ImplementsEvent() {} +func (EditEvent) ImplementsEvent() {} +func (Event) ImplementsEvent() {} +func (FocusEvent) ImplementsEvent() {} +func (CompositionEvent) ImplementsEvent() {} +func (SnippetEvent) ImplementsEvent() {} +func (SelectionEvent) ImplementsEvent() {} func (FocusCmd) ImplementsCommand() {} func (SoftKeyboardCmd) ImplementsCommand() {} diff --git a/widget/editor.go b/widget/editor.go index bf477121..6e5da092 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -25,6 +25,7 @@ import ( "gioui.org/layout" "gioui.org/op" "gioui.org/op/clip" + "gioui.org/op/paint" "gioui.org/text" "gioui.org/unit" ) @@ -107,8 +108,9 @@ type imeState struct { rng key.Range caret key.Caret } - snippet key.Snippet - start, end int + snippet key.Snippet + composition key.Range + start, end int } type maskReader struct { @@ -398,9 +400,12 @@ func (e *Editor) processKey(gtx layout.Context) (EditorEvent, bool) { case key.FocusEvent: // Reset IME state. e.ime.imeState = imeState{} + e.ime.composition = key.Range{Start: -1, End: -1} if ke.Focus && !e.ReadOnly { gtx.Execute(key.SoftKeyboardCmd{Show: true}) } + case key.CompositionEvent: + e.ime.composition = key.Range(ke) case key.Event: if !gtx.Focused(e) || ke.State != key.Press { break @@ -735,6 +740,7 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call if e.Len() > 0 { e.paintSelection(gtx, selectMaterial) e.paintText(gtx, textMaterial) + e.paintComposition(gtx, textMaterial) } if gtx.Enabled() { e.paintCaret(gtx, textMaterial) @@ -759,6 +765,28 @@ func (e *Editor) paintText(gtx layout.Context, material op.CallOp) { e.text.PaintText(gtx, material) } +func (e *Editor) paintComposition(gtx layout.Context, material op.CallOp) { + e.initBuffer() + r := e.ime.composition + if r.Start == -1 || r.Start == r.End { + return + } + e.text.regions = e.text.Regions(r.Start, r.End, e.text.regions) + thickness := max(gtx.Dp(unit.Dp(1)), 1) + for _, region := range e.text.regions { + y := region.Bounds.Max.Y - max(region.Baseline/3, thickness) + underline := image.Rect(region.Bounds.Min.X, y, region.Bounds.Max.X, y+thickness) + underline = underline.Intersect(image.Rectangle{Max: e.text.viewSize}) + if underline.Empty() { + continue + } + stack := clip.Rect(underline).Push(gtx.Ops) + material.Add(gtx.Ops) + paint.PaintOp{}.Add(gtx.Ops) + stack.Pop() + } +} + // paintCaret paints the text glyphs using the provided material to set the fill material // of the caret rectangle. func (e *Editor) paintCaret(gtx layout.Context, material op.CallOp) {