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 <qianniancn@gmail.com>
This commit is contained in:
qiannian
2026-06-16 16:28:58 +08:00
committed by Chris Waldon
parent a8fe27488f
commit e4932e163e
4 changed files with 48 additions and 8 deletions
+1
View File
@@ -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) {
+8 -1
View File
@@ -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 {
+9 -5
View File
@@ -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() {}
+30 -2
View File
@@ -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) {