From 153a9fc3bd07edb37172d700a56663210122dd18 Mon Sep 17 00:00:00 2001 From: qiannian Date: Wed, 8 Jul 2026 15:43:24 +0800 Subject: [PATCH 1/3] app: don't cancel IME on expanded snippets SnippetCmd can grow to include surrounding text while an IME composition is active. If the selection has not moved and the old snippet still matches the new one, don't cancel the platform composition. Signed-off-by: qiannian --- app/ime.go | 4 ++++ app/ime_test.go | 30 ++++++++++++++++++++++++++++++ app/os_macos.go | 2 +- app/os_windows.go | 2 +- 4 files changed, 36 insertions(+), 2 deletions(-) diff --git a/app/ime.go b/app/ime.go index b60c9a21f..242ae9c77 100644 --- a/app/ime.go +++ b/app/ime.go @@ -16,6 +16,10 @@ type editorState struct { compose key.Range } +func shouldCancelComposition(old, new editorState) bool { + return old.Selection.Range != new.Selection.Range || !areSnippetsConsistent(old.Snippet, new.Snippet) +} + func (e *editorState) Replace(r key.Range, text string) { if r.Start > r.End { r.Start, r.End = r.End, r.Start diff --git a/app/ime_test.go b/app/ime_test.go index cd4cdc37d..ed33b6741 100644 --- a/app/ime_test.go +++ b/app/ime_test.go @@ -161,3 +161,33 @@ func TestEditorIndices(t *testing.T) { } } } + +func TestShouldCancelComposition(t *testing.T) { + base := editorState{} + base.Selection.Range = key.Range{Start: 12, End: 12} + base.Snippet = key.Snippet{ + Range: key.Range{Start: 12, End: 17}, + Text: "hello", + } + + expanded := base + expanded.Snippet = key.Snippet{ + Range: key.Range{Start: 10, End: 17}, + Text: "拼音hello", + } + if shouldCancelComposition(base, expanded) { + t.Fatal("expanded but consistent snippet should not cancel composition") + } + + changedText := base + changedText.Snippet.Text = "hullo" + if !shouldCancelComposition(base, changedText) { + t.Fatal("changed snippet text should cancel composition") + } + + movedSelection := base + movedSelection.Selection.Range = key.Range{Start: 13, End: 13} + if !shouldCancelComposition(base, movedSelection) { + t.Fatal("changed selection should cancel composition") + } +} diff --git a/app/os_macos.go b/app/os_macos.go index c58f98f00..d4ea0412f 100644 --- a/app/os_macos.go +++ b/app/os_macos.go @@ -548,7 +548,7 @@ func (w *window) SetCursor(cursor pointer.Cursor) { } func (w *window) EditorStateChanged(old, new editorState) { - if old.Selection.Range != new.Selection.Range || !areSnippetsConsistent(old.Snippet, new.Snippet) { + if shouldCancelComposition(old, new) { C.discardMarkedText(w.view) w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) } diff --git a/app/os_windows.go b/app/os_windows.go index 8af2d6a8e..b66c67ab4 100644 --- a/app/os_windows.go +++ b/app/os_windows.go @@ -625,7 +625,7 @@ func (w *window) EditorStateChanged(old, new editorState) { return } defer windows.ImmReleaseContext(w.hwnd, imc) - if old.Selection.Range != new.Selection.Range || old.Snippet != new.Snippet { + if shouldCancelComposition(old, new) { windows.ImmNotifyIME(imc, windows.NI_COMPOSITIONSTR, windows.CPS_CANCEL, 0) } } From 7e4c00c8fc62cb4d7b89c51cafeaac870bcbf55c Mon Sep 17 00:00:00 2001 From: qiannian Date: Wed, 8 Jul 2026 15:45:05 +0800 Subject: [PATCH 2/3] app: [Windows] place IME candidates under composition Send the editor's visible composition bounds with SelectionCmd and use them as the CFS_EXCLUDE rectangle for ImmSetCandidateWindow. That puts the candidate list below the text being composed, instead of just under the caret. Handle RESULTSTR and COMPSTR as separate IME updates. Cursor-only updates move the IME cursor without replacing text in the editor. Fixes: https://todo.sr.ht/~eliasnaur/gio/697 Signed-off-by: qiannian --- app/ime.go | 19 +++- app/ime_test.go | 73 ++++++++++++++++ app/internal/windows/windows.go | 9 +- app/os_windows.go | 149 ++++++++++++++++++++++++-------- io/input/key.go | 2 + io/key/key.go | 4 + widget/editor.go | 48 ++++++++-- 7 files changed, 257 insertions(+), 47 deletions(-) diff --git a/app/ime.go b/app/ime.go index 242ae9c77..1c03b9afd 100644 --- a/app/ime.go +++ b/app/ime.go @@ -20,10 +20,27 @@ func shouldCancelComposition(old, new editorState) bool { return old.Selection.Range != new.Selection.Range || !areSnippetsConsistent(old.Snippet, new.Snippet) } -func (e *editorState) Replace(r key.Range, text string) { +// imeRange is the range currently owned by the IME. While composing, both +// preedit updates and commits replace that range; otherwise they replace the +// editor selection. +func imeRange(state editorState) key.Range { + rng := state.compose + if rng.Start == -1 { + rng = state.Selection.Range + } + return normRange(rng) +} + +// normRange makes text replacement independent of the selection direction. +func normRange(r key.Range) key.Range { if r.Start > r.End { r.Start, r.End = r.End, r.Start } + return r +} + +func (e *editorState) Replace(r key.Range, text string) { + r = normRange(r) runes := []rune(text) newEnd := r.Start + len(runes) adjust := func(pos int) int { diff --git a/app/ime_test.go b/app/ime_test.go index ed33b6741..6bae8abe3 100644 --- a/app/ime_test.go +++ b/app/ime_test.go @@ -4,6 +4,7 @@ package app import ( "gioui.org/f32" + "image" "testing" "unicode/utf8" @@ -162,6 +163,42 @@ func TestEditorIndices(t *testing.T) { } } +func TestIMERange(t *testing.T) { + editorStateWithSelection := func(compose, selection key.Range) editorState { + var state editorState + state.compose = compose + state.Selection.Range = selection + return state + } + for _, tc := range []struct { + name string + in editorState + want key.Range + }{ + { + name: "selection fallback", + in: editorStateWithSelection(key.Range{Start: -1, End: -1}, key.Range{Start: 2, End: 5}), + want: key.Range{Start: 2, End: 5}, + }, + { + name: "composition wins", + in: editorStateWithSelection(key.Range{Start: 4, End: 9}, key.Range{Start: 1, End: 1}), + want: key.Range{Start: 4, End: 9}, + }, + { + name: "normalize reversed", + in: editorState{ + compose: key.Range{Start: 8, End: 3}, + }, + want: key.Range{Start: 3, End: 8}, + }, + } { + if got := imeRange(tc.in); got != tc.want { + t.Errorf("%s: imeRange() = %v, want %v", tc.name, got, tc.want) + } + } +} + func TestShouldCancelComposition(t *testing.T) { base := editorState{} base.Selection.Range = key.Range{Start: 12, End: 12} @@ -191,3 +228,39 @@ func TestShouldCancelComposition(t *testing.T) { t.Fatal("changed selection should cancel composition") } } + +func TestEditorCompositionBounds(t *testing.T) { + cache := text.NewShaper(text.WithCollection(gofont.Collection())) + e := new(widget.Editor) + e.SetText("hello world") + + var r input.Router + var ops op.Ops + gtx := layout.Context{ + Ops: &ops, + Source: r.Source(), + Constraints: layout.Exact(image.Pt(300, 100)), + } + gtx.Execute(key.FocusCmd{Tag: e}) + + layoutEditor := func() { + ops.Reset() + gtx.Ops = &ops + gtx.Source = r.Source() + e.Layout(gtx, cache, font.Font{}, unit.Sp(12), op.CallOp{}, op.CallOp{}) + r.Frame(gtx.Ops) + } + + layoutEditor() + r.Queue(key.CompositionEvent{Start: 0, End: 5}) + layoutEditor() + if bounds := r.EditorState().Selection.CompositionBounds; bounds.Empty() { + t.Fatalf("expected non-empty composition bounds") + } + + r.Queue(key.CompositionEvent{Start: -1, End: -1}) + layoutEditor() + if bounds := r.EditorState().Selection.CompositionBounds; !bounds.Empty() { + t.Fatalf("expected empty composition bounds, got %v", bounds) + } +} diff --git a/app/internal/windows/windows.go b/app/internal/windows/windows.go index 30154fe31..21545a9ff 100644 --- a/app/internal/windows/windows.go +++ b/app/internal/windows/windows.go @@ -204,8 +204,8 @@ const ( GCS_RESULTREADSTR = 0x0200 GCS_RESULTSTR = 0x0800 - CFS_POINT = 0x0002 - CFS_CANDIDATEPOS = 0x0040 + CFS_POINT = 0x0002 + CFS_EXCLUDE = 0x0080 HWND_TOP = syscall.Handle(0) HWND_TOPMOST = ^(syscall.Handle(1) - 1) // -1 @@ -762,12 +762,13 @@ func ImmSetCompositionWindow(imc syscall.Handle, x, y int) { _ImmSetCompositionWindow.Call(uintptr(imc), uintptr(unsafe.Pointer(&f))) } -func ImmSetCandidateWindow(imc syscall.Handle, x, y int) { +func ImmSetCandidateWindow(imc syscall.Handle, x, y int, r Rect) { f := CandidateForm{ - dwStyle: CFS_CANDIDATEPOS, + dwStyle: CFS_EXCLUDE, ptCurrentPos: Point{ X: int32(x), Y: int32(y), }, + rcArea: r, } _ImmSetCandidateWindow.Call(uintptr(imc), uintptr(unsafe.Pointer(&f))) } diff --git a/app/os_windows.go b/app/os_windows.go index b66c67ab4..2c8ebe225 100644 --- a/app/os_windows.go +++ b/app/os_windows.go @@ -10,6 +10,7 @@ import ( "golang.org/x/sys/windows/registry" "image" "io" + "math" "os" "runtime" "sort" @@ -407,11 +408,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr return windows.TRUE } defer windows.ImmReleaseContext(w.hwnd, imc) - sel := w.w.EditorState().Selection - caret := sel.Transform.Transform(sel.Caret.Pos.Add(f32.Pt(0, sel.Caret.Descent))) - icaret := image.Pt(int(caret.X+.5), int(caret.Y+.5)) - windows.ImmSetCompositionWindow(imc, icaret.X, icaret.Y) - windows.ImmSetCandidateWindow(imc, icaret.X, icaret.Y) + w.updateIMEWindows(imc) return windows.TRUE case windows.WM_IME_COMPOSITION: imc := windows.ImmGetContext(w.hwnd) @@ -420,38 +417,56 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr } defer windows.ImmReleaseContext(w.hwnd, imc) state := w.w.EditorState() - rng := state.compose - if rng.Start == -1 { - rng = state.Selection.Range + if lParam&windows.GCS_RESULTSTR != 0 { + // RESULTSTR is committed text. Keep it separate from COMPSTR so a + // preedit update never looks like a commit. + rng := imeRange(state) + result := windows.ImmGetCompositionString(imc, windows.GCS_RESULTSTR) + start := rng.Start + w.w.EditorReplace(rng, result) + end := start + utf8.RuneCountInString(result) + w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) + w.w.SetEditorSelection(key.Range{Start: end, End: end}) + state = w.w.EditorState() } - if rng.Start > rng.End { - rng.Start, rng.End = rng.End, rng.Start - } - var replacement string - switch { - case lParam&windows.GCS_RESULTSTR != 0: - replacement = windows.ImmGetCompositionString(imc, windows.GCS_RESULTSTR) - case lParam&windows.GCS_COMPSTR != 0: - replacement = windows.ImmGetCompositionString(imc, windows.GCS_COMPSTR) - } - end := rng.Start + utf8.RuneCountInString(replacement) - w.w.EditorReplace(rng, replacement) - state = w.w.EditorState() - comp := key.Range{ - Start: rng.Start, - End: end, - } - if lParam&windows.GCS_DELTASTART != 0 { - start := windows.ImmGetCompositionValue(imc, windows.GCS_DELTASTART) - comp.Start = state.RunesIndex(state.UTF16Index(comp.Start) + start) - } - w.w.SetComposingRegion(comp) - pos := end - if lParam&windows.GCS_CURSORPOS != 0 { - rel := windows.ImmGetCompositionValue(imc, windows.GCS_CURSORPOS) - pos = state.RunesIndex(state.UTF16Index(rng.Start) + rel) + if lParam&windows.GCS_COMPSTR != 0 { + // COMPSTR is still preedit text, so keep the composing range alive. + rng := imeRange(state) + replacement := windows.ImmGetCompositionString(imc, windows.GCS_COMPSTR) + end := rng.Start + utf8.RuneCountInString(replacement) + w.w.EditorReplace(rng, replacement) + state = w.w.EditorState() + comp := key.Range{ + Start: rng.Start, + End: end, + } + if lParam&windows.GCS_DELTASTART != 0 { + start := windows.ImmGetCompositionValue(imc, windows.GCS_DELTASTART) + comp.Start = state.RunesIndex(state.UTF16Index(comp.Start) + start) + } + w.w.SetComposingRegion(comp) + pos := end + if lParam&windows.GCS_CURSORPOS != 0 { + rel := windows.ImmGetCompositionValue(imc, windows.GCS_CURSORPOS) + pos = state.RunesIndex(state.UTF16Index(rng.Start) + rel) + } + w.w.SetEditorSelection(key.Range{Start: pos, End: pos}) + } else if lParam&(windows.GCS_DELTASTART|windows.GCS_CURSORPOS) != 0 && state.compose.Start != -1 { + // Some composition messages only move the IME cursor or clause start. + rng := normRange(state.compose) + comp := rng + if lParam&windows.GCS_DELTASTART != 0 { + start := windows.ImmGetCompositionValue(imc, windows.GCS_DELTASTART) + comp.Start = state.RunesIndex(state.UTF16Index(comp.Start) + start) + w.w.SetComposingRegion(comp) + } + if lParam&windows.GCS_CURSORPOS != 0 { + rel := windows.ImmGetCompositionValue(imc, windows.GCS_CURSORPOS) + pos := state.RunesIndex(state.UTF16Index(rng.Start) + rel) + w.w.SetEditorSelection(key.Range{Start: pos, End: pos}) + } } - w.w.SetEditorSelection(key.Range{Start: pos, End: pos}) + w.updateIMEWindows(imc) return windows.TRUE case windows.WM_IME_ENDCOMPOSITION: w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) @@ -492,6 +507,65 @@ func getModifiers() key.Modifiers { return kmods } +// updateIMEWindows keeps the Windows IME popup near the text being edited. +func (w *window) updateIMEWindows(imc syscall.Handle) { + sel := w.w.EditorState().Selection + top := sel.Transform.Transform(sel.Caret.Pos.Add(f32.Pt(0, -sel.Caret.Ascent))) + base := sel.Transform.Transform(sel.Caret.Pos) + bottom := sel.Transform.Transform(sel.Caret.Pos.Add(f32.Pt(0, sel.Caret.Descent))) + + itop := image.Pt(int(top.X+.5), int(top.Y+.5)) + ibase := image.Pt(int(base.X+.5), int(base.Y+.5)) + ibottom := image.Pt(int(bottom.X+.5), int(bottom.Y+.5)) + if ibottom.Y <= itop.Y { + ibottom.Y = itop.Y + 1 + } + exclude := windows.Rect{ + Left: int32(ibase.X), + Top: int32(itop.Y), + Right: int32(ibase.X + 1), + Bottom: int32(ibottom.Y), + } + x, y := ibottom.X, ibottom.Y + if !sel.CompositionBounds.Empty() { + exclude = transformRect(sel.Transform, sel.CompositionBounds) + x = int(exclude.Left) + y = int(exclude.Bottom) + } + windows.ImmSetCompositionWindow(imc, x, y) + windows.ImmSetCandidateWindow(imc, x, y, exclude) +} + +// transformRect maps a local rectangle to window coordinates. Transform all +// corners because an affine transform may flip or rotate the rectangle. +func transformRect(t f32.Affine2D, r image.Rectangle) windows.Rect { + p0 := t.Transform(f32.Pt(float32(r.Min.X), float32(r.Min.Y))) + p1 := t.Transform(f32.Pt(float32(r.Max.X), float32(r.Min.Y))) + p2 := t.Transform(f32.Pt(float32(r.Max.X), float32(r.Max.Y))) + p3 := t.Transform(f32.Pt(float32(r.Min.X), float32(r.Max.Y))) + + minX := min(min(p0.X, p1.X), min(p2.X, p3.X)) + minY := min(min(p0.Y, p1.Y), min(p2.Y, p3.Y)) + maxX := max(max(p0.X, p1.X), max(p2.X, p3.X)) + maxY := max(max(p0.Y, p1.Y), max(p2.Y, p3.Y)) + left := int32(math.Floor(float64(minX))) + top := int32(math.Floor(float64(minY))) + right := int32(math.Ceil(float64(maxX))) + bottom := int32(math.Ceil(float64(maxY))) + if right <= left { + right = left + 1 + } + if bottom <= top { + bottom = top + 1 + } + return windows.Rect{ + Left: left, + Top: top, + Right: right, + Bottom: bottom, + } +} + // hitTest returns the non-client area hit by the point, needed to // process WM_NCHITTEST. func (w *window) hitTest(x, y int) uintptr { @@ -625,6 +699,11 @@ func (w *window) EditorStateChanged(old, new editorState) { return } defer windows.ImmReleaseContext(w.hwnd, imc) + if old.Selection.Caret != new.Selection.Caret || + old.Selection.Transform != new.Selection.Transform || + old.Selection.CompositionBounds != new.Selection.CompositionBounds { + w.updateIMEWindows(imc) + } if shouldCancelComposition(old, new) { windows.ImmNotifyIME(imc, windows.NI_COMPOSITIONSTR, windows.CPS_CANCEL, 0) } diff --git a/io/input/key.go b/io/input/key.go index 6606109cd..4432d95ab 100644 --- a/io/input/key.go +++ b/io/input/key.go @@ -18,6 +18,7 @@ type EditorState struct { Transform f32.Affine2D key.Range key.Caret + CompositionBounds image.Rectangle } Snippet key.Snippet } @@ -332,6 +333,7 @@ func (q *keyQueue) setSelection(state keyState, req key.SelectionCmd) keyState { } state.content.Selection.Range = req.Range state.content.Selection.Caret = req.Caret + state.content.Selection.CompositionBounds = req.CompositionBounds return state } diff --git a/io/key/key.go b/io/key/key.go index d16c5bb3b..98b2b0025 100644 --- a/io/key/key.go +++ b/io/key/key.go @@ -4,6 +4,7 @@ package key import ( + "image" "strings" "gioui.org/f32" @@ -42,6 +43,9 @@ type SelectionCmd struct { Tag event.Tag Range Caret + // CompositionBounds is the visible bounds of the composing text, relative to + // the input handler. It is empty when there is no visible composing text. + CompositionBounds image.Rectangle } // SnippetCmd updates the content snippet for an input handler. diff --git a/widget/editor.go b/widget/editor.go index 6e5da0929..1fa1f0805 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -105,8 +105,9 @@ type offEntry struct { type imeState struct { selection struct { - rng key.Range - caret key.Caret + rng key.Range + caret key.Caret + compositionBounds image.Rectangle } snippet key.Snippet composition key.Range @@ -625,7 +626,13 @@ func (e *Editor) initBuffer() { func (e *Editor) Update(gtx layout.Context) (EditorEvent, bool) { e.initBuffer() event, ok := e.processEvents(gtx) - // Notify IME of selection if it changed. + e.updateIMEState(gtx) + + e.updateSnippet(gtx, e.ime.start, e.ime.end) + return event, ok +} + +func (e *Editor) updateIMEState(gtx layout.Context) { newSel := e.ime.selection start, end := e.text.Selection() newSel.rng = key.Range{ @@ -638,13 +645,16 @@ func (e *Editor) Update(gtx layout.Context) (EditorEvent, bool) { Ascent: float32(carAsc), Descent: float32(carDesc), } + newSel.compositionBounds = e.compositionBounds() if newSel != e.ime.selection { e.ime.selection = newSel - gtx.Execute(key.SelectionCmd{Tag: e, Range: newSel.rng, Caret: newSel.caret}) + gtx.Execute(key.SelectionCmd{ + Tag: e, + Range: newSel.rng, + Caret: newSel.caret, + CompositionBounds: newSel.compositionBounds, + }) } - - e.updateSnippet(gtx, e.ime.start, e.ime.end) - return event, ok } // Layout lays out the editor using the provided textMaterial as the paint material @@ -713,6 +723,7 @@ func (e *Editor) layout(gtx layout.Context, textMaterial, selectMaterial op.Call e.scrollCaret = false e.text.ScrollToCaret() } + e.updateIMEState(gtx) visibleDims := e.text.Dimensions() defer clip.Rect(image.Rectangle{Max: visibleDims.Size}).Push(gtx.Ops).Pop() @@ -787,6 +798,29 @@ func (e *Editor) paintComposition(gtx layout.Context, material op.CallOp) { } } +// compositionBounds returns the part of the composing text visible in the editor. +func (e *Editor) compositionBounds() image.Rectangle { + r := e.ime.composition + if r.Start == -1 || r.Start == r.End { + return image.Rectangle{} + } + e.text.regions = e.text.Regions(r.Start, r.End, e.text.regions) + visible := image.Rectangle{Max: e.text.viewSize} + var bounds image.Rectangle + for _, region := range e.text.regions { + r := region.Bounds.Intersect(visible) + if r.Empty() { + continue + } + if bounds.Empty() { + bounds = r + } else { + bounds = bounds.Union(r) + } + } + return bounds +} + // 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) { From fbadf3093b74c4b833483227be3a8b5ccac1d7f8 Mon Sep 17 00:00:00 2001 From: qiannian <40210590@qq.com> Date: Mon, 20 Jul 2026 15:00:08 +0800 Subject: [PATCH 3/3] app,widget: avoid redundant IME state updates Signed-off-by: qiannian --- app/os_windows.go | 36 +++++++++++++++++++++--------------- widget/editor.go | 19 +++++++++++++++++-- widget/editor_test.go | 36 ++++++++++++++++++++++++++++++++++++ widget/text.go | 2 ++ widget/text_bench_test.go | 28 ++++++++++++++++++++++++++++ 5 files changed, 104 insertions(+), 17 deletions(-) diff --git a/app/os_windows.go b/app/os_windows.go index 2c8ebe225..834a2924f 100644 --- a/app/os_windows.go +++ b/app/os_windows.go @@ -416,6 +416,7 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr return windows.TRUE } defer windows.ImmReleaseContext(w.hwnd, imc) + defer w.updateIMEWindows(imc) state := w.w.EditorState() if lParam&windows.GCS_RESULTSTR != 0 { // RESULTSTR is committed text. Keep it separate from COMPSTR so a @@ -427,6 +428,9 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr end := start + utf8.RuneCountInString(result) w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) w.w.SetEditorSelection(key.Range{Start: end, End: end}) + if lParam&windows.GCS_COMPSTR == 0 { + return windows.TRUE + } state = w.w.EditorState() } if lParam&windows.GCS_COMPSTR != 0 { @@ -451,22 +455,24 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr pos = state.RunesIndex(state.UTF16Index(rng.Start) + rel) } w.w.SetEditorSelection(key.Range{Start: pos, End: pos}) - } else if lParam&(windows.GCS_DELTASTART|windows.GCS_CURSORPOS) != 0 && state.compose.Start != -1 { - // Some composition messages only move the IME cursor or clause start. - rng := normRange(state.compose) - comp := rng - if lParam&windows.GCS_DELTASTART != 0 { - start := windows.ImmGetCompositionValue(imc, windows.GCS_DELTASTART) - comp.Start = state.RunesIndex(state.UTF16Index(comp.Start) + start) - w.w.SetComposingRegion(comp) - } - if lParam&windows.GCS_CURSORPOS != 0 { - rel := windows.ImmGetCompositionValue(imc, windows.GCS_CURSORPOS) - pos := state.RunesIndex(state.UTF16Index(rng.Start) + rel) - w.w.SetEditorSelection(key.Range{Start: pos, End: pos}) - } + return windows.TRUE + } + if lParam&(windows.GCS_DELTASTART|windows.GCS_CURSORPOS) == 0 || state.compose.Start == -1 { + return windows.TRUE + } + // Some composition messages only move the IME cursor or clause start. + rng := normRange(state.compose) + comp := rng + if lParam&windows.GCS_DELTASTART != 0 { + start := windows.ImmGetCompositionValue(imc, windows.GCS_DELTASTART) + comp.Start = state.RunesIndex(state.UTF16Index(comp.Start) + start) + w.w.SetComposingRegion(comp) + } + if lParam&windows.GCS_CURSORPOS != 0 { + rel := windows.ImmGetCompositionValue(imc, windows.GCS_CURSORPOS) + pos := state.RunesIndex(state.UTF16Index(rng.Start) + rel) + w.w.SetEditorSelection(key.Range{Start: pos, End: pos}) } - w.updateIMEWindows(imc) return windows.TRUE case windows.WM_IME_ENDCOMPOSITION: w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) diff --git a/widget/editor.go b/widget/editor.go index 1fa1f0805..72a94fd82 100644 --- a/widget/editor.go +++ b/widget/editor.go @@ -111,6 +111,9 @@ type imeState struct { } snippet key.Snippet composition key.Range + lastCompose key.Range + textVersion uint64 + scrollOff image.Point start, end int } @@ -633,12 +636,24 @@ func (e *Editor) Update(gtx layout.Context) (EditorEvent, bool) { } func (e *Editor) updateIMEState(gtx layout.Context) { - newSel := e.ime.selection start, end := e.text.Selection() - newSel.rng = key.Range{ + rng := key.Range{ Start: start, End: end, } + scrollOff := e.text.ScrollOff() + if rng == e.ime.selection.rng && + e.ime.composition == e.ime.lastCompose && + e.text.version == e.ime.textVersion && + scrollOff == e.ime.scrollOff { + return + } + e.ime.lastCompose = e.ime.composition + e.ime.textVersion = e.text.version + e.ime.scrollOff = scrollOff + + newSel := e.ime.selection + newSel.rng = rng caretPos, carAsc, carDesc := e.text.CaretInfo() newSel.caret = key.Caret{ Pos: layout.FPt(caretPos), diff --git a/widget/editor_test.go b/widget/editor_test.go index 65deb613c..f5301555b 100644 --- a/widget/editor_test.go +++ b/widget/editor_test.go @@ -1142,6 +1142,42 @@ func TestSelectMove(t *testing.T) { } } +func TestEditorIMEStateInvalidation(t *testing.T) { + e := new(Editor) + e.SingleLine = true + e.SetText("hello world") + e.ime.composition = key.Range{Start: 0, End: 5} + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(50, 20)), + Locale: english, + } + shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection(gofont.Collection())) + e.Layout(gtx, shaper, font.Font{}, unit.Sp(10), op.CallOp{}, op.CallOp{}) + + e.text.regions = nil + e.updateIMEState(gtx) + if len(e.text.regions) != 0 { + t.Fatal("unchanged IME state traversed text regions") + } + + check := func(name string, change func()) { + e.text.regions = nil + change() + e.updateIMEState(gtx) + if len(e.text.regions) == 0 { + t.Fatalf("%s: changed IME state did not traverse text regions", name) + } + } + check("selection", func() { e.SetCaret(1, 1) }) + check("content", func() { e.text.Replace(0, 1, "H") }) + check("scroll", func() { e.text.ScrollRel(10, 0) }) + check("constraints", func() { + gtx.Constraints = layout.Exact(image.Pt(40, 20)) + e.text.Layout(gtx, shaper, font.Font{}, unit.Sp(10)) + }) +} + func TestEditor_Read(t *testing.T) { s := "hello world" buf := make([]byte, len(s)) diff --git a/widget/text.go b/widget/text.go index 6dd090da3..69c3078a5 100644 --- a/widget/text.go +++ b/widget/text.go @@ -81,6 +81,7 @@ type textView struct { lastMask rune viewSize image.Point valid bool + version uint64 regions []Region dims layout.Dimensions @@ -573,6 +574,7 @@ func (e *textView) runeOffset(r int) int { func (e *textView) invalidate() { e.offIndex = e.offIndex[:0] e.valid = false + e.version++ } // Replace the text between start and end with s. Indices are in runes. diff --git a/widget/text_bench_test.go b/widget/text_bench_test.go index 28ef5fa6c..92134139f 100644 --- a/widget/text_bench_test.go +++ b/widget/text_bench_test.go @@ -171,6 +171,34 @@ func BenchmarkEditorStatic(b *testing.B) { }) } +func BenchmarkEditorIMEState(b *testing.B) { + gtx := layout.Context{ + Ops: new(op.Ops), + Constraints: layout.Exact(image.Pt(200, 1000)), + Locale: english, + } + shaper := text.NewShaper(text.NoSystemFonts(), text.WithCollection(benchFonts)) + e := new(Editor) + e.SetText(string([]rune(latinDocument)[:1000])) + e.ime.composition.Start = 0 + e.ime.composition.End = e.Len() + e.Layout(gtx, shaper, font.Font{}, unit.Sp(10), op.CallOp{}, op.CallOp{}) + + b.Run("unchanged", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + e.updateIMEState(gtx) + } + }) + b.Run("invalidated", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + e.text.version++ + e.updateIMEState(gtx) + } + }) +} + func BenchmarkEditorDynamic(b *testing.B) { runBenchmarkPermutations(b, func(b *testing.B, runeCount int, locale system.Locale, txt string) { var win *headless.Window