diff --git a/app/ime.go b/app/ime.go index b60c9a21f..1c03b9afd 100644 --- a/app/ime.go +++ b/app/ime.go @@ -16,10 +16,31 @@ type editorState struct { compose key.Range } -func (e *editorState) Replace(r key.Range, text string) { +func shouldCancelComposition(old, new editorState) bool { + return old.Selection.Range != new.Selection.Range || !areSnippetsConsistent(old.Snippet, new.Snippet) +} + +// 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 cd4cdc37d..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" @@ -161,3 +162,105 @@ 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} + 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") + } +} + +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_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..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,7 +699,12 @@ 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 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) {