Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 22 additions & 1 deletion app/ime.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
103 changes: 103 additions & 0 deletions app/ime_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ package app

import (
"gioui.org/f32"
"image"
"testing"
"unicode/utf8"

Expand Down Expand Up @@ -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)
}
}
9 changes: 5 additions & 4 deletions app/internal/windows/windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)))
}
Expand Down
2 changes: 1 addition & 1 deletion app/os_macos.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
}
Expand Down
151 changes: 115 additions & 36 deletions app/os_windows.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"golang.org/x/sys/windows/registry"
"image"
"io"
"math"
"os"
"runtime"
"sort"
Expand Down Expand Up @@ -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)
Expand All @@ -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})
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
}
Expand Down
2 changes: 2 additions & 0 deletions io/input/key.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ type EditorState struct {
Transform f32.Affine2D
key.Range
key.Caret
CompositionBounds image.Rectangle
}
Snippet key.Snippet
}
Expand Down Expand Up @@ -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
}

Expand Down
Loading