GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
IME: input-method composition in the browser and on macOS
One model for every shell (crate::ime): the commit is delivered as typed
text (Driver::commit_text: a press of a key whose text it is, then its
release; never a shortcut, never repeated), so every widget that inserts
a key's text takes it unchanged; the composition (ime::Preedit) is shared
per thread and set through Driver::preedit; and the editing widget reports
its caret as it paints, bracketed by build_frame, which tells the shell
where the candidates go and whether text is wanted at all.
TextBox shows a composition as a provisional run in its buffer, so wrap,
scroll, caret and glyph advances draw it as typed text, and underlines it.
It is never held (committed_buffer, take_change), never in the history,
and a press or the end of editing drops it and asks the input method to
cancel (ime::request_reset).
Browser: the keyboard is a hidden textarea's (a page composes only into
an editable element). Its input events while composing are the
composition, compositionend the commit, text with no composition (an emoji
panel) a commit; keys the input method takes (isComposing, keyCode 229)
are left to it; it moves to the caret after each frame. A paste that
reaches it by default (insertFromPaste) is the paste.
macOS (type-checked, not run): the view is an NSTextInputClient; while a
widget edits, a key press without Cmd goes through interpretKeyEvents:,
marked text is the composition and inserted text the commit, except a
plain key typing its own characters, left to the key path.
Checks: scripts/web-probe/ime drives Chromium's own IME path (CDP
Input.imeSetComposition / insertText) through the demo's text box; all six
checks pass. The clipboard probe gains a fifth case; all five pass. The
24-step demo replay is identical to the pixel to the previous browser run
through step 18. Linux: lib suite 544 passed (2 known failures), plate
golden identical, native sway run 0 px; check-wasm and check-mac ok.
Wayland text-input-v3, and showing the composition in LineEdit and the
DocEditor, are not done.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ
CLAUDE.md | 63 +++++++++++-
Cargo.toml | 9 +-
scripts/web-probe/clipboard.mjs | 21 +++-
scripts/web-probe/ime | 22 +++++
scripts/web-probe/ime.mjs | 77 +++++++++++++++
src/backend/dom.rs | 27 +++++
src/backend/driver.rs | 56 +++++++++++
src/backend/frame.rs | 5 +
src/ime.rs | 188 +++++++++++++++++++++++++++++++++++
src/lib.rs | 1 +
src/mac/mod.rs | 209 ++++++++++++++++++++++++++++++++++++---
src/scene/paint.rs | 6 ++
src/web/shell.rs | 178 ++++++++++++++++++++++++++++++---
src/widget/input/text_box.rs | 213 ++++++++++++++++++++++++++++++++++++++--
14 files changed, 1033 insertions(+), 42 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index 23222a7..0749387 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -131,9 +131,27 @@ in its own config.
secure context. Until then a copy in a page panicked (`std::thread::spawn`).
`scripts/web-probe/clipboard` is the check: copy, paste, copy with `writeText` refused,
and paste with the `paste` event swallowed, each read back from the system clipboard —
- all four pass in headless Chromium (2026-10-05).
-
-Not there yet: IME composition, drag and drop, file dialogs, and an app whose
+ all four pass in headless Chromium (2026-10-05). (Five since the IME: a `paste`
+ swallowed with its default kept pastes into the keyboard sink, an `input` of type
+ `insertFromPaste`, which is the paste too.)
+- **The keyboard is a hidden `<textarea>`'s** (the keyboard sink, since 2026-10-05), not
+ the canvas's: a page composes input-method text only into an editable element. It takes
+ the focus a press on the canvas gave the canvas (a canvas focused another way hands it
+ over, and a blur over to the canvas is not a focus loss); its keys are the app's as the
+ canvas's were, except one the input method takes (`isComposing`, keyCode 229); its
+ `input` events while composing are the composition (`Driver::preedit`, the cursor from
+ its selection via `dom::utf16_range_to_bytes`), `compositionend` the commit, and text
+ with no composition (an emoji panel, dictation) a commit as it comes. After each frame
+ it is moved to the editing widget's caret (`ime::caret`), where the candidates open; a
+ composition a widget dropped (`ime::take_reset`) is cancelled by blurring and refocusing
+ it INSIDE the turn, where the events that raises reach no handler.
+ `scripts/web-probe/ime` is the check, through Chromium's own IME path (CDP
+ `Input.imeSetComposition` / `insertText`): a composition shown with the sink at its
+ caret, the commit replacing it, a cancelled one leaving the box, a no-composition
+ insert, and plain keys — all six pass (2026-10-05), and the 24-step demo replay is
+ identical to the pixel to the run before the sink through step 18.
+
+Not there yet: drag and drop, file dialogs, and an app whose
text is not the display list's (`display_list_text` false — it stages its own through the
native-only `stage_renderer`, so draws no text here).
@@ -278,8 +296,18 @@ change. What the shell does, in AppKit's terms (module doc in `src/mac/mod.rs`):
- **Clipboard**: the general `NSPasteboard`'s plain-text type, behind the same
`widget::clipboard` pair every widget uses; ⌘C / ⌘X / ⌘V reach the widgets as Ctrl+C /
X / V do on Linux, since Command reads as `ctrl`.
-
-Not there yet: IME (`NSTextInputClient`), drag and drop, the context menu
+- **Input methods**: the view is an `NSTextInputClient`. While a widget is editing text
+ (`ime::caret` is set) a key press without ⌘ goes through `interpretKeyEvents:` first:
+ `setMarkedText:` is the composition, `insertText:` the commit — unless it is a plain
+ key typing its own characters with nothing marked, which is left to the key path so it
+ keeps its named key and the driver's repeat — and `doCommandBySelector:` leaves the key
+ to the key path. `firstRectForCharacterRange:` is the caret in screen coordinates. With
+ nothing editing, keys skip the input method, so one left on does not eat an app's
+ single-key commands. After each frame a dropped composition is discarded through the
+ input context (the marked text cleared first, so the `unmarkText` that may call commits
+ nothing) and a moved caret invalidates the character coordinates.
+
+Not there yet: drag and drop, the context menu
in a popup window (it is drawn in the window, as on a layer surface), blur behind the window,
a menu bar beyond Quit. **None of it has run**: this is Linux, where an Apple target can be
type-checked but not linked. `scripts/check-mac` type-checks the library, the demo, every
@@ -289,6 +317,28 @@ the four examples that still used the legacy `new(qh, …)` moved to `create`, a
than through `window_runner`. On a Mac, MoltenVK and the Vulkan loader must be installed
(the LunarG SDK, or Homebrew's `molten-vk` and `vulkan-loader`); `cargo run` is the test.
+**Input-method composition is one model for every shell** (`crate::ime`, since
+2026-10-05). Three things cross between the text widget and the shell's input method:
+the COMMIT is delivered as typed text (`Driver::commit_text`: a press of a key whose text
+it is, then its release — never a shortcut, never repeated, past the chords), so every
+widget that inserts a key's text takes it unchanged (`TextBox`, `LineEdit`, the
+`DocEditor`, an app's own field); the COMPOSITION (`ime::Preedit`: text and the input
+method's cursor as a byte range) is shared per thread, set through `Driver::preedit`; and
+the CARET goes back — a widget editing text reports it as it paints
+(`ime::report_caret`, in window px with the `PaintCtx` offset), `build_frame` brackets
+the frame (`begin_frame` / `end_frame`), and `ime::caret()` is where the candidates go and
+whether text is wanted at all. **`TextBox` shows a composition as a PROVISIONAL run** in
+`edit_buffer` (`composing`: its char start and length), so wrap, scroll, caret and the
+glyph advances draw it as typed text, and `selection_quads` underlines it; it is never
+held (`committed_buffer`, which `take_change` publishes under `update_on_type`), never in
+the history, and the box takes no key while it composes. It is applied in `prepare_text`
+and at the top of `handle_key`, against `ime::generation`; a press, or editing ending,
+drops it and asks the input method to cancel (`ime::request_reset`). A composition begun
+over a selection replaces it, as typing would. `a_composition_is_shown_in_place_and_the_
+commit_is_typed` is the test. Not there yet: the Wayland shell does not speak
+`text-input-v3`, so on Linux nothing composes; and `LineEdit` and the `DocEditor` take
+the commit but do not show the composition.
+
Wayland protocol bindings are generated **inline at compile time** by `wayland-scanner` macros in
`src/protocol.rs` from `protocol/*.xml` (`cce-inspector-v1`, `cce-window-management-v1`) — there is
no `build.rs` and no codegen step to run.
@@ -1317,6 +1367,9 @@ cce-system-interface) to confirm behavior, not just the test suite.
- `compute.rs` — what a compute job is, apart from the device that runs it: `Kernel`,
`Binding`, the job rules and naga's parse (see "Compute jobs run in the browser too").
`vk::ComputeDevice` and `web::ComputeDevice` run them.
+- `ime.rs` — input-method composition shared between the editing widget and the shell:
+ `Preedit`, the composition and its generation, the reported caret, the reset request
+ (see "Input-method composition is one model for every shell").
- `history.rs` — `History<T>`: the undo/redo snapshot stack (cap, gestures, grouped runs).
The toolkit defines the stack and the routing, never the step — see the trait section.
- `widget/` — `container/` (vbox/hbox/scroll/menu/treelist/…), `input/` (button/slider/text_box/
diff --git a/Cargo.toml b/Cargo.toml
index ec7c42c..6aacdfb 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -76,8 +76,8 @@ xkeysym = "0.2"
# The AppKit shell (`src/mac`): the window, its CAMetalLayer, the run loop.
[target.'cfg(target_os = "macos")'.dependencies]
objc2 = "0.6"
-objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSNotification", "NSObject", "NSDate", "NSDictionary"] }
-objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSPasteboard", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
+objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSString", "NSGeometry", "NSNotification", "NSObject", "NSDate", "NSDictionary", "NSArray", "NSAttributedString", "NSRange", "NSObjCRuntime"] }
+objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSApplication", "NSWindow", "NSView", "NSResponder", "NSEvent", "NSScreen", "NSCursor", "NSMenu", "NSMenuItem", "NSPasteboard", "NSTextInputClient", "NSTextInputContext", "NSColor", "NSTrackingArea", "NSGraphics", "NSGraphicsContext", "NSRunningApplication", "objc2-quartz-core", "objc2-core-foundation"] }
objc2-quartz-core = { version = "0.3", default-features = false, features = ["std", "CALayer", "CAMetalLayer", "objc2-core-foundation"] }
dispatch2 = "0.3"
@@ -98,6 +98,11 @@ web-sys = { version = "0.3", features = [
"Element",
"HtmlCanvasElement",
"HtmlElement",
+ "HtmlTextAreaElement",
+ "CompositionEvent",
+ "InputEvent",
+ "DomRect",
+ "Node",
"CssStyleDeclaration",
"EventTarget",
"Event",
diff --git a/scripts/web-probe/clipboard.mjs b/scripts/web-probe/clipboard.mjs
index 3605e48..83cbd4c 100644
--- a/scripts/web-probe/clipboard.mjs
+++ b/scripts/web-probe/clipboard.mjs
@@ -5,8 +5,11 @@
// 2. paste — Ctrl+V with other text there — takes it (copied back out);
// 3. copy with the page's writeText refused: the `copy` event alone
// carries it (what an insecure context has);
-// 4. paste with the `paste` event swallowed: the held key goes through on
-// its timer and pastes the page's own last copy.
+// 4. paste with the `paste` event swallowed but its default kept: the text
+// the browser pastes into the keyboard sink is the clipboard's;
+// 5. paste with the `paste` event swallowed and cancelled: the held key
+// goes through on its timer and pastes the page's own last copy or
+// paste (4's read-back copy of the box).
import { open } from './browser.mjs';
const [root] = process.argv.slice(2);
@@ -62,11 +65,21 @@ await session(async t => {
await t.focusBox(); await t.type('via the event');
await t.chord('a'); await t.chord('c');
check('copy, the copy event alone', await t.read(), 'via the event');
- await t.page.evaluate(() => window.addEventListener('paste', e => e.stopImmediatePropagation(), true));
+ // Ahead of the shell's listener: swallow the event, and with
+ // `cancelPaste` set cancel its default too.
+ await t.page.evaluate(() => window.addEventListener('paste', e => {
+ e.stopImmediatePropagation();
+ if (window.cancelPaste) e.preventDefault();
+ }, true));
+ await t.write(' and the system');
+ await t.focusBox(); await t.key('End'); await t.chord('v');
+ await t.chord('a'); await t.chord('c');
+ check('paste, the event swallowed', await t.read(), 'via the event and the system');
+ await t.page.evaluate(() => { window.cancelPaste = true; });
await t.write('system text the page never sees');
await t.focusBox(); await t.key('End'); await t.chord('v');
await t.chord('a'); await t.chord('c');
- check('paste, no paste event', await t.read(), 'via the eventvia the event');
+ check('paste, no paste event at all', await t.read(), 'via the event and the systemvia the event and the system');
});
process.exit(ok ? 0 : 1);
diff --git a/scripts/web-probe/ime b/scripts/web-probe/ime
new file mode 100755
index 0000000..5844592
--- /dev/null
+++ b/scripts/web-probe/ime
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# web-probe/ime [<out-dir>] — input-method composition through the browser shell: the
+# reference app (src/main.rs) built for the page as `demo` builds it, and
+# ime.mjs's compositions in its text box, driven through Chromium's own IME path, in headless
+# Chromium with the clipboard permissions granted. Prints one line a check
+# and fails if any does. Needs what `run` needs.
+set -euo pipefail
+here=$(cd "$(dirname "$0")" && pwd)
+out=${1:+$(realpath -m "$1")}
+fonts=${DEMO_FONTS_DIR:-/usr/share/fonts}
+cd "$here/../.."
+
+cargo build --release --target wasm32-unknown-unknown --example demo_web
+site=$(mktemp -d)
+trap 'rm -rf "$site"' EXIT
+wasm-bindgen --target web --no-typescript --out-dir "$site" \
+ target/wasm32-unknown-unknown/release/examples/demo_web.wasm
+cp "$here/demo.html" "$site/demo.html"
+ln -s "$fonts" "$site/fonts"
+(cd "$fonts" && find . \( -type f -o -type l \) \( -iname '*.ttf' -o -iname '*.otf' -o -iname '*.ttc' -o -iname '*.otc' \) \
+ | sed 's|^\./||' | DEMO_FAMILIES="${DEMO_FAMILIES:-}" python3 -c 'import json,os,sys; print(json.dumps({"files": sys.stdin.read().split(), "families": os.environ["DEMO_FAMILIES"]}))') > "$site/fonts.json"
+node "$here/ime.mjs" "$site" ${out:+"$out"}
diff --git a/scripts/web-probe/ime.mjs b/scripts/web-probe/ime.mjs
new file mode 100644
index 0000000..b1da851
--- /dev/null
+++ b/scripts/web-probe/ime.mjs
@@ -0,0 +1,77 @@
+// ime.mjs <site-dir> [<out-dir>]: input-method composition through the
+// browser shell, in the demo's text box, driven by Chromium's own IME path
+// (CDP Input.imeSetComposition / Input.insertText — the events a real IME
+// raises in the focused textarea). Each result is read back by copying the
+// box's text to the system clipboard. With <out-dir>, the frame mid-
+// composition is saved there (ime-composing.rgba).
+// 1. a composition shows in the box, and the keyboard sink sits at its caret;
+// 2. the commit replaces it with the committed text;
+// 3. a composition cancelled leaves the box as it was;
+// 4. text with no composition (an emoji panel) is typed;
+// 5. plain keys still type.
+import fs from 'fs';
+import { open } from './browser.mjs';
+
+const [root, out] = process.argv.slice(2);
+if (!root) { console.error('usage: ime.mjs <site-dir> [<out-dir>]'); process.exit(2); }
+let ok = true;
+const check = (what, pass, detail) => {
+ ok &&= pass;
+ console.log(`${pass ? 'ok ' : 'FAIL'} ${what}${detail ? ': ' + detail : ''}`);
+};
+const { page, logs, close } = await open(root, 'demo.html', 1280, 800);
+await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
+const cdp = await page.context().newCDPSession(page);
+const m = page.mouse, k = page.keyboard, wait = ms => page.waitForTimeout(ms);
+const chord = async key => { await k.down('Control'); await k.press(key); await k.up('Control'); await wait(300); };
+const boxText = async () => {
+ await chord('a'); await chord('c');
+ const t = await page.evaluate(() => navigator.clipboard.readText());
+ await k.press('End'); await wait(200);
+ return t;
+};
+const compose = async (text, at) => {
+ await cdp.send('Input.imeSetComposition', { text, selectionStart: at, selectionEnd: at });
+ await wait(400);
+};
+try {
+ await m.move(1279, 799);
+ await page.waitForFunction(() => window.demoReady === true, null, { timeout: 120000 });
+ await wait(3000);
+ await m.move(640, 168); await m.down(); await m.up(); await wait(300);
+ await k.type('ab'); await k.press('ArrowLeft'); await wait(300);
+
+ await compose('にほ', 1);
+ if (out) {
+ fs.mkdirSync(out, { recursive: true });
+ const b64 = await page.evaluate(() => window.capture());
+ fs.writeFileSync(`${out}/ime-composing.rgba`, Buffer.from(b64, 'base64'));
+ }
+ const at = await page.evaluate(() => {
+ const t = document.activeElement;
+ return { tag: t.tagName, left: parseFloat(t.style.left), top: parseFloat(t.style.top), value: t.value };
+ });
+ check('the keyboard sink has the focus and holds the composition', at.tag === 'TEXTAREA' && at.value === 'にほ', JSON.stringify(at));
+ // The box (the demo's, at y 155-181) and its caret after "aに".
+ check('the sink sits at the caret', at.top > 150 && at.top < 185 && at.left > 40 && at.left < 120, `left ${at.left}, top ${at.top}`);
+
+ await cdp.send('Input.insertText', { text: '日本' }); await wait(400);
+ let t = await boxText();
+ check('the commit replaces the composition', t === 'a日本b', JSON.stringify(t));
+
+ await compose('か', 1);
+ await compose('', 0);
+ t = await boxText();
+ check('a cancelled composition leaves the box as it was', t === 'a日本b', JSON.stringify(t));
+
+ await cdp.send('Input.insertText', { text: '😀' }); await wait(400);
+ t = await boxText();
+ check('text with no composition is typed', t === 'a日本b😀', JSON.stringify(t));
+
+ await k.type('xy'); await wait(300);
+ t = await boxText();
+ check('plain keys still type', t === 'a日本b😀xy', JSON.stringify(t));
+} catch (e) { ok = false; logs.push('[ime] ' + String(e)); }
+for (const l of logs) if (!l.includes('WebGPU is experimental')) console.log(l);
+await close();
+process.exit(ok ? 0 : 1);
diff --git a/src/backend/dom.rs b/src/backend/dom.rs
index 9c50048..4438645 100644
--- a/src/backend/dom.rs
+++ b/src/backend/dom.rs
@@ -87,6 +87,24 @@ pub fn clipboard_key(key: &str, accel: bool, alt: bool) -> Option<ClipKey> {
}
}
+/// A text control's selection — UTF-16 offsets, as the DOM counts — as a
+/// byte range in its text: where an input method has its cursor in a
+/// composition. An offset past the end is the end; inside a surrogate pair,
+/// the char it splits.
+pub fn utf16_range_to_bytes(text: &str, start: Option<u32>, end: Option<u32>) -> Option<(usize, usize)> {
+ let byte_at = |u: u32| {
+ let mut units = 0u32;
+ for (b, c) in text.char_indices() {
+ if units >= u {
+ return b;
+ }
+ units += c.len_utf16() as u32;
+ }
+ text.len()
+ };
+ Some((byte_at(start?), byte_at(end?)))
+}
+
/// A `WheelEvent`'s deltas as a scroll frame in the units the driver takes
/// (a `wl_pointer` axis frame's: a finger in px, a wheel notch as a discrete
/// step plus ten units, positive down and right — the DOM's signs too).
@@ -168,6 +186,15 @@ mod tests {
assert_eq!(clipboard_key("z", true, false), None);
}
+ #[test]
+ fn a_dom_selection_is_counted_in_utf16_and_given_in_bytes() {
+ // "に" is one UTF-16 unit and three bytes; "😀" two units, four bytes.
+ assert_eq!(utf16_range_to_bytes("にほ", Some(1), Some(1)), Some((3, 3)));
+ assert_eq!(utf16_range_to_bytes("😀a", Some(2), Some(3)), Some((4, 5)));
+ assert_eq!(utf16_range_to_bytes("ab", Some(0), Some(9)), Some((0, 2)));
+ assert_eq!(utf16_range_to_bytes("ab", None, Some(1)), None);
+ }
+
#[test]
fn notches_are_discrete_and_a_trackpad_is_a_finger() {
// Chromium's mouse wheel: 100 px a notch, three notches up.
diff --git a/src/backend/driver.rs b/src/backend/driver.rs
index 5b305e8..320d0ff 100644
--- a/src/backend/driver.rs
+++ b/src/backend/driver.rs
@@ -537,6 +537,47 @@ impl Driver {
t.deliver(msg, rebuild);
}
+ /// Text an input method committed. It is delivered as TYPED — a press
+ /// of a key whose text it is, then that key's release — so a widget
+ /// that inserts what a key types takes it as it is (see `crate::ime`).
+ /// Never a shortcut (no Ctrl, no Alt, whatever is held: an input method
+ /// commits on its own keys), never repeated, and past the chords: a
+ /// commit of "z" is a "z", not half of an undo.
+ pub fn commit_text<A: Application>(&mut self, t: Turn<'_, A>, text: String) {
+ if text.is_empty() {
+ return;
+ }
+ let mut rebuild = false;
+ for state in [ElementState::Pressed, ElementState::Released] {
+ let event = KeyEvent {
+ state,
+ logical_key: Key::Character(text.clone()),
+ text: (state == ElementState::Pressed).then(|| text.clone()),
+ repeat: false,
+ ctrl: false,
+ shift: self.mods.shift,
+ alt: false,
+ };
+ let msg = t.app.handle_key_input(&event, &mut rebuild);
+ if let Some(msg) = msg {
+ let mut update_rebuild = false;
+ t.app.update(msg, &mut update_rebuild, t.exit);
+ rebuild |= update_rebuild;
+ }
+ }
+ *t.redraw |= rebuild;
+ // The editing widget shows the text it now holds.
+ *t.redraw = true;
+ }
+
+ /// The input method's composition changed (`None`: it ended without a
+ /// commit, or the commit follows). The editing widget shows it from the
+ /// next frame.
+ pub fn preedit<A: Application>(&mut self, t: Turn<'_, A>, preedit: Option<crate::ime::Preedit>) {
+ crate::ime::set_preedit(preedit);
+ *t.redraw = true;
+ }
+
/// The runner's key repeat: once a held key has been down
/// [`KEY_REPEAT_DELAY`], deliver it again every [`KEY_REPEAT_INTERVAL`].
/// Called once per loop turn.
@@ -895,6 +936,21 @@ mod tests {
assert!(f.redraw);
}
+ #[test]
+ fn a_commit_is_typed_text_and_never_a_shortcut_or_a_held_key() {
+ let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
+ app.takes_undo = true;
+ // Ctrl held as the input method commits "z": typed, not an undo.
+ d.set_modifiers(&mut app, Modifiers { ctrl: true, ..Modifiers::default() });
+ d.commit_text(turn(&mut app, &mut f), "z".into());
+ d.commit_text(turn(&mut app, &mut f), "日本".into());
+ d.commit_text(turn(&mut app, &mut f), String::new());
+ let typed = |t: &str| Seen::Key(Key::Character(t.into()), false);
+ assert_eq!(app.seen, vec![typed("z"), typed("z"), typed("日本"), typed("日本")]);
+ assert!(d.pressed_key.is_none(), "nothing left held to repeat");
+ assert!(f.redraw);
+ }
+
#[test]
fn the_undo_chord_goes_to_the_app_hook_before_key_dispatch() {
let (mut d, mut app, mut f) = (driver(), mock(), Flags { redraw: false, exit: false });
diff --git a/src/backend/frame.rs b/src/backend/frame.rs
index d0dd673..8076adf 100644
--- a/src/backend/frame.rs
+++ b/src/backend/frame.rs
@@ -88,6 +88,10 @@ pub fn build_frame<A: Application>(
) -> BuiltFrame {
let (logical_w, logical_h) = (size.width, size.height);
+ // The editing widget reports its caret as it paints (`ime::report_caret`):
+ // where an input method's candidates go, and whether text is wanted.
+ crate::ime::begin_frame();
+
// 0. Shape every registered widget against the SAME FontSystem the glyph pass draws
// with, before the app builds its frame. A widget's caret/selection/click→index math
// reads per-glyph advances its `prepare_text` records; nothing else calls it on the
@@ -118,6 +122,7 @@ pub fn build_frame<A: Application>(
let dl = app
.display_list(size, scale)
.unwrap_or_else(|| crate::scene::paint::PaintCtx::new().finish());
+ crate::ime::end_frame();
// Taken with the display list it describes. A frame that took the
// app's damage and then was not presented owes those pixels, so the
// next one that is presented repaints everything.
diff --git a/src/ime.rs b/src/ime.rs
new file mode 100644
index 0000000..dcfe8ac
--- /dev/null
+++ b/src/ime.rs
@@ -0,0 +1,188 @@
+//! Input-method composition, between the text widget the user types into and
+//! the shell whose window system runs the input method (an IME: Japanese,
+//! Chinese, Korean, an accent picker, an emoji panel).
+//!
+//! Three things cross, and only one of them is new to the toolkit:
+//!
+//! - **The commit** — text the input method has finished composing — is
+//! delivered as TYPED text (`Driver::commit_text`: a key press carrying it,
+//! then its release), so every widget that inserts what a key types takes
+//! it with no change: `TextBox`, `LineEdit`, the `DocEditor`, an app's own
+//! field.
+//! - **The composition** (the preedit: text still being composed, with the
+//! input method's cursor in it) is shared here: a shell sets it
+//! ([`set_preedit`], through `Driver::preedit`), and the widget that is
+//! editing shows it at its caret — `TextBox` splices it into its buffer as
+//! a provisional run, underlined, which is never committed, recorded in
+//! its history or reported as a change.
+//! - **The caret** goes the other way: a widget that is editing reports its
+//! caret's rect each frame it paints ([`report_caret`]), so the shell can
+//! place the input method's candidate window under it, and knows text
+//! input is wanted at all ([`caret`] is `None` when nothing is editing;
+//! the macOS shell then keeps keys away from the input method, so an IME
+//! left on does not swallow an app's single-key shortcuts).
+//!
+//! A widget that drops a composition it was showing — it stopped editing
+//! mid-composition — asks the shell to cancel it in the input method too
+//! ([`request_reset`], drained by the shell with [`take_reset`]).
+//!
+//! Per thread, as the context menu is: a window's widgets, its shell and its
+//! frame are one thread's. The Wayland shell does not speak
+//! `text-input-v3` yet, so on Linux nothing sets a composition and this is
+//! inert.
+
+use std::cell::RefCell;
+
+/// Text the input method is still composing.
+#[derive(Debug, Clone, PartialEq, Eq, Default)]
+pub struct Preedit {
+ pub text: String,
+ /// The input method's cursor (or selected clause) in `text`, as a byte
+ /// range; `None` to show no caret in it.
+ pub cursor: Option<(usize, usize)>,
+}
+
+impl Preedit {
+ pub fn new(text: impl Into<String>, cursor: Option<(usize, usize)>) -> Self {
+ Self { text: text.into(), cursor }
+ }
+
+ /// The caret's position in `text`, in chars: the end of the input
+ /// method's cursor range, or the end of the text when it has none. A
+ /// byte offset off a char boundary or past the end is clamped back.
+ pub fn caret_chars(&self) -> usize {
+ let end = self.cursor.map_or(self.text.len(), |(_, e)| e).min(self.text.len());
+ let mut end = end;
+ while !self.text.is_char_boundary(end) {
+ end -= 1;
+ }
+ self.text[..end].chars().count()
+ }
+}
+
+#[derive(Default)]
+struct State {
+ preedit: Option<Preedit>,
+ /// Moves on every change of `preedit`, so a widget can tell it has
+ /// applied the current one.
+ generation: u64,
+ /// The caret the last built frame reported, and the one this frame has
+ /// so far.
+ caret: Option<[f32; 4]>,
+ reported: Option<[f32; 4]>,
+ reset: bool,
+}
+
+thread_local! {
+ static STATE: RefCell<State> = RefCell::new(State::default());
+}
+
+/// The composition changed: `None` (or empty text) when there is none. A
+/// shell's, through `Driver::preedit`.
+pub fn set_preedit(preedit: Option<Preedit>) {
+ let preedit = preedit.filter(|p| !p.text.is_empty());
+ STATE.with(|s| {
+ let mut s = s.borrow_mut();
+ if s.preedit != preedit {
+ s.preedit = preedit;
+ s.generation += 1;
+ }
+ });
+}
+
+/// The composition, if the input method is composing.
+pub fn preedit() -> Option<Preedit> {
+ STATE.with(|s| s.borrow().preedit.clone())
+}
+
+/// Moves whenever the composition does.
+pub fn generation() -> u64 {
+ STATE.with(|s| s.borrow().generation)
+}
+
+/// A frame is being built: carets are reported afresh.
+pub fn begin_frame() {
+ STATE.with(|s| s.borrow_mut().reported = None);
+}
+
+/// The frame is built: what was reported is the caret.
+pub fn end_frame() {
+ STATE.with(|s| {
+ let mut s = s.borrow_mut();
+ s.caret = s.reported;
+ });
+}
+
+/// A widget editing text has its caret at `x, y` (`w` x `h`), in the
+/// window's logical px. Called as it paints.
+pub fn report_caret(x: f32, y: f32, w: f32, h: f32) {
+ STATE.with(|s| s.borrow_mut().reported = Some([x, y, w, h]));
+}
+
+/// Where the editing widget's caret was in the last built frame, or `None`
+/// when no widget is editing text.
+pub fn caret() -> Option<[f32; 4]> {
+ STATE.with(|s| s.borrow().caret)
+}
+
+/// A widget dropped a composition it was showing: the input method should
+/// cancel it too. Clears the composition.
+pub fn request_reset() {
+ set_preedit(None);
+ STATE.with(|s| s.borrow_mut().reset = true);
+}
+
+/// Whether a reset was asked for since the last call. A shell's.
+pub fn take_reset() -> bool {
+ STATE.with(|s| std::mem::replace(&mut s.borrow_mut().reset, false))
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn a_composition_moves_the_generation_only_when_it_changes() {
+ let g0 = generation();
+ set_preedit(Some(Preedit::new("に", None)));
+ let g1 = generation();
+ assert!(g1 > g0);
+ set_preedit(Some(Preedit::new("に", None)));
+ assert_eq!(generation(), g1);
+ // Empty text is no composition.
+ set_preedit(Some(Preedit::new("", None)));
+ assert_eq!(preedit(), None);
+ assert!(generation() > g1);
+ }
+
+ #[test]
+ fn the_caret_is_the_end_of_the_cursor_range_in_chars() {
+ assert_eq!(Preedit::new("にほん", None).caret_chars(), 3);
+ // "に" is three bytes.
+ assert_eq!(Preedit::new("にほん", Some((0, 3))).caret_chars(), 1);
+ // Off a boundary, clamped back.
+ assert_eq!(Preedit::new("にほん", Some((0, 4))).caret_chars(), 1);
+ assert_eq!(Preedit::new("ab", Some((0, 9))).caret_chars(), 2);
+ }
+
+ #[test]
+ fn the_caret_is_what_the_last_built_frame_reported() {
+ begin_frame();
+ report_caret(10.0, 20.0, 1.5, 16.0);
+ assert_eq!(caret(), None, "not until the frame is built");
+ end_frame();
+ assert_eq!(caret(), Some([10.0, 20.0, 1.5, 16.0]));
+ begin_frame();
+ end_frame();
+ assert_eq!(caret(), None, "a frame with nothing editing");
+ }
+
+ #[test]
+ fn a_reset_clears_the_composition_and_is_taken_once() {
+ set_preedit(Some(Preedit::new("ka", None)));
+ request_reset();
+ assert_eq!(preedit(), None);
+ assert!(take_reset());
+ assert!(!take_reset());
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 6234a45..6859d1a 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -10,6 +10,7 @@ pub mod widget;
pub mod config;
pub mod input;
pub mod history;
+pub mod ime;
pub mod layout;
pub mod relief_spec;
#[cfg(not(any(target_arch = "wasm32", target_os = "macos")))]
diff --git a/src/mac/mod.rs b/src/mac/mod.rs
index 5667946..19ca7a5 100644
--- a/src/mac/mod.rs
+++ b/src/mac/mod.rs
@@ -32,10 +32,16 @@
//! The menu is a minimal one (Quit, ⌘Q), whose quit — like the close
//! button — asks the app to exit the way a compositor's close does.
//!
-//! Not there yet: the clipboard, IME composition (`NSTextInputClient`),
-//! drag and drop, the context menu in a popup window (it is drawn in the
-//! window and kept inside it, as on a layer surface), and blur behind the
-//! window (`NSVisualEffectView`).
+//! - **Input methods.** The view is an `NSTextInputClient`: while a widget
+//! is editing text, a key press goes through `interpretKeyEvents:`, whose
+//! marked text is the composition and whose inserted text the commit
+//! (`crate::ime`); the candidate window is put under the caret the
+//! widget reported. The clipboard is the general `NSPasteboard`
+//! (`widget::clipboard`).
+//!
+//! Not there yet: drag and drop, the context menu in a popup window (it is
+//! drawn in the window and kept inside it, as on a layer surface), and blur
+//! behind the window (`NSVisualEffectView`).
//!
//! **This module has never been run.** It is written against objc2's
//! AppKit bindings and type-checked for `aarch64-apple-darwin`
@@ -43,23 +49,24 @@
//! needs a Mac, with MoltenVK installed (the Vulkan SDK, or Homebrew's
//! `molten-vk` and `vulkan-loader`).
-use std::cell::RefCell;
+use std::cell::{Cell, RefCell};
use std::sync::mpsc::Receiver;
use std::time::Duration;
use cursor_icon::CursorIcon;
use dispatch2::{DispatchQueue, DispatchTime};
use objc2::rc::Retained;
-use objc2::runtime::{NSObject, ProtocolObject};
-use objc2::{define_class, msg_send, sel, AnyThread, MainThreadOnly};
+use objc2::runtime::{AnyObject, NSObject, ProtocolObject, Sel};
+use objc2::{define_class, msg_send, sel, AnyThread, DefinedClass, MainThreadOnly};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSApplicationDelegate, NSApplicationTerminateReply,
NSBackingStoreType, NSColor, NSCursor, NSEvent, NSEventModifierFlags, NSEventType, NSMenu, NSMenuItem,
- NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow, NSWindowDelegate, NSWindowStyleMask,
+ NSTextInputClient, NSTrackingArea, NSTrackingAreaOptions, NSView, NSWindow, NSWindowDelegate, NSWindowStyleMask,
NSWindowTitleVisibility,
};
use objc2_foundation::{
- ns_string, MainThreadMarker, NSNotification, NSObjectProtocol, NSPoint, NSRect, NSSize, NSString,
+ ns_string, MainThreadMarker, NSArray, NSAttributedString, NSAttributedStringKey, NSNotFound, NSNotification,
+ NSObjectProtocol, NSPoint, NSRange, NSRangePointer, NSRect, NSSize, NSString, NSUInteger,
};
use objc2_quartz_core::CAMetalLayer;
use web_time::Instant;
@@ -171,6 +178,7 @@ pub fn run<A: Application>() {
due: None,
last_turn: Instant::now(),
stopped: false,
+ ime_caret: None,
mtm,
layer,
view: view.clone(),
@@ -211,6 +219,10 @@ enum Ev {
Scrolled { dx: f64, dy: f64, precise: bool, inverted: bool, phase: u64, momentum: u64, flags: u64, x: f32, y: f32 },
Magnified { phase: u64, magnification: f64 },
Keyed { code: u16, chars: String, unmodified: String, flags: u64, down: bool },
+ /// The input method's composition changed (marked text).
+ Preedit(Option<crate::ime::Preedit>),
+ /// The input method committed text.
+ Commit(String),
FlagsChanged { code: u16, flags: u64 },
Focused(bool),
/// The window's size or backing scale changed.
@@ -264,6 +276,8 @@ struct MacShell<A: Application> {
due: Option<Instant>,
last_turn: Instant,
stopped: bool,
+ /// The caret the input method was last told of.
+ ime_caret: Option<[f32; 4]>,
mtm: MainThreadMarker,
layer: Retained<CAMetalLayer>,
view: Retained<CceView>,
@@ -351,6 +365,16 @@ impl<A: Application> MacShell<A> {
let (driver, t) = self.turn();
driver.key(t, key, text, state);
}
+ Ev::Preedit(preedit) => {
+ let (driver, t) = self.turn();
+ driver.preedit(t, preedit);
+ }
+ Ev::Commit(text) => {
+ let (driver, t) = self.turn();
+ driver.preedit(t, None);
+ let (driver, t) = self.turn();
+ driver.commit_text(t, text);
+ }
Ev::FlagsChanged { code, flags } => {
// A modifier key went down or up: the new state, and the key
// itself as the other shells report it.
@@ -469,6 +493,30 @@ impl<A: Application> MacShell<A> {
true
}
+ /// Keep the input method in step with the frame just drawn: a
+ /// composition a widget dropped, or one with no widget editing any more,
+ /// is discarded (the marked text cleared first, so the `unmarkText` this
+ /// may call commits nothing); a caret that moved has the candidate
+ /// window follow it.
+ fn sync_input_method(&mut self) {
+ let caret = crate::ime::caret();
+ let drop = crate::ime::take_reset() || (caret.is_none() && !self.view.ivars().marked.borrow().is_empty());
+ let context = self.view.inputContext();
+ if drop {
+ self.view.ivars().marked.borrow_mut().clear();
+ crate::ime::set_preedit(None);
+ if let Some(c) = &context {
+ c.discardMarkedText();
+ }
+ }
+ if caret != self.ime_caret {
+ self.ime_caret = caret;
+ if let Some(c) = &context {
+ c.invalidateCharacterCoordinates();
+ }
+ }
+ }
+
fn size(&self) -> LogicalSize {
LogicalSize::new(self.logical.0, self.logical.1)
}
@@ -569,6 +617,7 @@ impl<A: Application> Shell for MacShell<A> {
} else {
self.redraw = true;
}
+ self.sync_input_method();
}
}
@@ -631,15 +680,133 @@ fn location(view: &NSView, event: &NSEvent) -> (f32, f32) {
(p.x as f32, p.y as f32)
}
+/// The view's input-method state, for the `NSTextInputClient` methods.
+#[derive(Default)]
+struct ViewIme {
+ /// The marked text (the composition) as the input method last set it.
+ marked: RefCell<String>,
+ /// Inside `interpretKeyEvents:` for a key press, whose characters are
+ /// these; and whether the input method took the press.
+ in_key: Cell<bool>,
+ consumed: Cell<bool>,
+ key_chars: RefCell<String>,
+}
+
+/// The text of what an input method hands over: an `NSString`, or an
+/// `NSAttributedString` around one.
+fn ns_text(obj: &AnyObject) -> String {
+ if let Some(a) = obj.downcast_ref::<NSAttributedString>() {
+ a.string().to_string()
+ } else if let Some(s) = obj.downcast_ref::<NSString>() {
+ s.to_string()
+ } else {
+ String::new()
+ }
+}
+
define_class!(
// SAFETY: NSView has no subclassing requirements, and CceView no Drop.
#[unsafe(super(NSView, objc2_app_kit::NSResponder, NSObject))]
#[thread_kind = MainThreadOnly]
#[name = "CceUiView"]
+ #[ivars = ViewIme]
struct CceView;
unsafe impl NSObjectProtocol for CceView {}
+ // The input method's side of the view: AppKit calls these from inside
+ // `interpretKeyEvents:` (see `key`), and the candidate window asks where
+ // the caret is.
+ unsafe impl NSTextInputClient for CceView {
+ #[unsafe(method(insertText:replacementRange:))]
+ unsafe fn insert_text(&self, string: &AnyObject, _replacement: NSRange) {
+ let text = ns_text(string);
+ let ime = self.ivars();
+ let was_marked = !std::mem::take(&mut *ime.marked.borrow_mut()).is_empty();
+ // A plain key typing its own character is left to the key path,
+ // which keeps its named keys and the driver's repeat.
+ if ime.in_key.get() && !was_marked && text == *ime.key_chars.borrow() {
+ return;
+ }
+ ime.consumed.set(true);
+ send(Ev::Commit(text));
+ }
+
+ #[unsafe(method(doCommandBySelector:))]
+ unsafe fn do_command(&self, _selector: Sel) {
+ // A key the input method does not take (Return, Backspace, an
+ // arrow): left to the key path.
+ }
+
+ #[unsafe(method(setMarkedText:selectedRange:replacementRange:))]
+ unsafe fn set_marked_text(&self, string: &AnyObject, selected: NSRange, _replacement: NSRange) {
+ let text = ns_text(string);
+ let ime = self.ivars();
+ ime.consumed.set(true);
+ *ime.marked.borrow_mut() = text.clone();
+ let cursor = crate::backend::dom::utf16_range_to_bytes(
+ &text,
+ Some(selected.location as u32),
+ Some((selected.location + selected.length) as u32),
+ );
+ send(Ev::Preedit((!text.is_empty()).then(|| crate::ime::Preedit { text, cursor })));
+ }
+
+ #[unsafe(method(unmarkText))]
+ fn unmark_text(&self) {
+ // Accept the composition as it stands.
+ let text = std::mem::take(&mut *self.ivars().marked.borrow_mut());
+ if !text.is_empty() {
+ send(Ev::Commit(text));
+ }
+ }
+
+ #[unsafe(method(selectedRange))]
+ fn selected_range(&self) -> NSRange {
+ NSRange::new(NSNotFound as usize, 0)
+ }
+
+ #[unsafe(method(markedRange))]
+ fn marked_range(&self) -> NSRange {
+ let marked = self.ivars().marked.borrow();
+ if marked.is_empty() {
+ NSRange::new(NSNotFound as usize, 0)
+ } else {
+ NSRange::new(0, marked.encode_utf16().count())
+ }
+ }
+
+ #[unsafe(method(hasMarkedText))]
+ fn has_marked_text(&self) -> bool {
+ !self.ivars().marked.borrow().is_empty()
+ }
+
+ #[unsafe(method_id(attributedSubstringForProposedRange:actualRange:))]
+ unsafe fn attributed_substring(&self, _range: NSRange, _actual: NSRangePointer) -> Option<Retained<NSAttributedString>> {
+ None
+ }
+
+ #[unsafe(method_id(validAttributesForMarkedText))]
+ fn valid_attributes(&self) -> Retained<NSArray<NSAttributedStringKey>> {
+ NSArray::new()
+ }
+
+ /// Where the candidate window goes: under the editing widget's caret,
+ /// in screen coordinates.
+ #[unsafe(method(firstRectForCharacterRange:actualRange:))]
+ unsafe fn first_rect(&self, _range: NSRange, _actual: NSRangePointer) -> NSRect {
+ let [x, y, w, h] = crate::ime::caret().unwrap_or([0.0, 0.0, 1.0, 16.0]);
+ let caret = NSRect::new(NSPoint::new(x as f64, y as f64), NSSize::new(w.max(1.0) as f64, h as f64));
+ let in_window = self.convertRect_toView(caret, None);
+ self.window().map_or(in_window, |w| w.convertRectToScreen(in_window))
+ }
+
+ #[unsafe(method(characterIndexForPoint:))]
+ fn character_index(&self, _point: NSPoint) -> NSUInteger {
+ NSNotFound as NSUInteger
+ }
+ }
+
impl CceView {
#[unsafe(method(isFlipped))]
fn is_flipped(&self) -> bool {
@@ -761,7 +928,7 @@ define_class!(
impl CceView {
fn new(mtm: MainThreadMarker, frame: NSRect) -> Retained<Self> {
- let this = Self::alloc(mtm).set_ivars(());
+ let this = Self::alloc(mtm).set_ivars(ViewIme::default());
// SAFETY: NSView's designated initializer.
let view: Retained<Self> = unsafe { msg_send![super(this), initWithFrame: frame] };
// Enter, exit and motion wherever the view is, window key or not.
@@ -799,12 +966,30 @@ impl CceView {
}
fn key(&self, event: &NSEvent, down: bool) {
+ let text = |s: Option<Retained<NSString>>| s.map(|s| s.to_string()).unwrap_or_default();
+ let flags = event.modifierFlags().0 as u64;
+ // While a widget is editing text, a key press goes to the input
+ // method first (`interpretKeyEvents:`, which calls back into the
+ // `NSTextInputClient` methods above); what it takes is composition
+ // or a commit, what it leaves takes the key path below. A ⌘
+ // shortcut never goes, and nothing does while nothing is editing,
+ // so an input method left on does not eat an app's single-key
+ // commands.
+ let ime = self.ivars();
+ if down && flags & appkit::flags::COMMAND == 0 && crate::ime::caret().is_some() {
+ ime.in_key.set(true);
+ ime.consumed.set(false);
+ *ime.key_chars.borrow_mut() = text(event.characters());
+ self.interpretKeyEvents(&NSArray::from_slice(&[event]));
+ ime.in_key.set(false);
+ if ime.consumed.get() {
+ return;
+ }
+ }
// The driver repeats a held key itself, as it does on Wayland.
if event.isARepeat() {
return;
}
- let text = |s: Option<Retained<NSString>>| s.map(|s| s.to_string()).unwrap_or_default();
- let flags = event.modifierFlags().0 as u64;
let keyed = |down| Ev::Keyed {
code: event.keyCode(),
chars: text(event.characters()),
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index c5ce66a..5309ec2 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -1140,6 +1140,12 @@ impl PaintCtx {
out
}
+ /// The translation applied to what is emitted now: a widget's own rect
+ /// plus this is where it lands in the window.
+ pub fn offset(&self) -> (f32, f32) {
+ self.offset
+ }
+
fn apply_offset(&self, r: Rect) -> Rect {
Rect { x: r.x + self.offset.0, y: r.y + self.offset.1, width: r.width, height: r.height }
}
diff --git a/src/web/shell.rs b/src/web/shell.rs
index 3c358a9..df53633 100644
--- a/src/web/shell.rs
+++ b/src/web/shell.rs
@@ -17,6 +17,22 @@
//! - **Frames out.** [`build_frame`] at the canvas's CSS size and the page's
//! `devicePixelRatio`, drawn by the [`WebRenderer`].
//!
+//! **The keyboard is a hidden `<textarea>`'s**, not the canvas's: a page can
+//! compose input-method text (Japanese, Chinese, Korean, a dead key, an
+//! emoji panel) only into an editable element. It takes the focus a press
+//! on the canvas would have given the canvas (and the canvas, focused some
+//! other way, hands it over), its key events are the app's as the canvas's
+//! were, and its composition is the input method's: a key the input method
+//! takes (`Process`, keyCode 229, or one sent mid-composition) is left to
+//! it, `input` events while composing are the composition
+//! (`Driver::preedit`), `compositionend` its commit (`Driver::commit_text`),
+//! and text that arrives with no composition (an emoji panel, dictation) is
+//! committed as it comes. After each frame it is moved to the editing
+//! widget's caret (`ime::caret`), where the input method puts its
+//! candidates. A widget that drops a composition (`ime::take_reset`) has it
+//! cancelled by taking the focus off the textarea and back, inside the turn,
+//! where the events that raises are not the app's.
+//!
//! **The clipboard** comes through the page's clipboard events, since a page
//! may read the clipboard only inside a `paste` event: a ⌘/Ctrl+V is held
//! back from the app until its `paste` event has handed over the text (or,
@@ -39,11 +55,14 @@ use std::time::Duration;
use cursor_icon::CursorIcon;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
-use web_sys::{AddEventListenerOptions, ClipboardEvent, FocusEvent, HtmlCanvasElement, KeyboardEvent, PointerEvent, WheelEvent};
+use web_sys::{
+ AddEventListenerOptions, ClipboardEvent, CompositionEvent, FocusEvent, HtmlCanvasElement, HtmlTextAreaElement, InputEvent,
+ KeyboardEvent, PointerEvent, WheelEvent,
+};
use super::renderer::{Capture, WebRenderer};
use crate::backend::app::{set_wake, AppSender, Application, LogicalPosition, LogicalSize};
-use crate::backend::dom::{clipboard_key, map_key, wheel_frame, ClipKey};
+use crate::backend::dom::{clipboard_key, map_key, utf16_range_to_bytes, wheel_frame, ClipKey};
use crate::backend::driver::{Driver, Modifiers, PressSite, ScrollFrame, ScrollSource, Turn};
use crate::backend::frame::build_frame;
use crate::backend::shell::{Pacer, Shell, Step, ACTIVE_DISPATCH};
@@ -114,12 +133,13 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
if sizing == Sizing::App {
set_css_size(&canvas, settings.width, settings.height);
}
- // The canvas takes the keyboard (it is focused on a press), draws no
- // focus outline of its own, and keeps touches for the app rather than
- // panning the page.
+ // The canvas can be focused (it hands the focus to the keyboard sink),
+ // draws no focus outline of its own, and keeps touches for the app
+ // rather than panning the page.
canvas.set_tab_index(0);
let _ = style.set_property("outline", "none");
let _ = style.set_property("touch-action", "none");
+ let sink = keyboard_sink()?;
let shell = WebShell {
app,
@@ -139,6 +159,8 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
just_configured: false,
cursor: CursorIcon::Default,
mac: is_mac(),
+ sink: sink.clone(),
+ sink_at: None,
};
let lp = Rc::new(Loop {
shell: RefCell::new(shell),
@@ -153,7 +175,7 @@ pub async fn run<A: Application>(canvas: HtmlCanvasElement, fonts: Fonts, sizing
});
lp.shell.borrow_mut().measure();
lp.shell.borrow_mut().just_configured = true;
- Loop::install(&lp, &canvas)?;
+ Loop::install(&lp, &canvas, &sink)?;
lp.wake();
Ok(())
}
@@ -202,6 +224,10 @@ struct WebShell<A: Application> {
cursor: CursorIcon,
/// Command is the shortcut key here: ⌘Z is undo, as every Mac app has it.
mac: bool,
+ /// The hidden textarea that holds the keyboard (see the module doc), and
+ /// where it was last put (page px: left, top, height).
+ sink: HtmlTextAreaElement,
+ sink_at: Option<(f64, f64, f64)>,
}
impl<A: Application> WebShell<A> {
@@ -266,6 +292,39 @@ impl<A: Application> WebShell<A> {
LogicalPosition::new(x, y)
}
+ /// Put the keyboard sink at the editing widget's caret (the canvas's
+ /// corner when nothing is editing), so the input method's candidates
+ /// open there; and cancel a composition a widget dropped. Inside the
+ /// turn: the shell is borrowed, so the blur and focus this raises (and
+ /// the composition's end) reach no handler.
+ fn place_sink(&mut self) {
+ let r = self.canvas.get_bounding_client_rect();
+ let (left, top, h) = match crate::ime::caret() {
+ Some([x, y, _, h]) => (r.left() + x as f64, r.top() + y as f64, (h as f64).max(8.0)),
+ None => (r.left(), r.top(), 16.0),
+ };
+ if self.sink_at != Some((left, top, h)) {
+ self.sink_at = Some((left, top, h));
+ let st = self.sink.style();
+ let _ = st.set_property("left", &format!("{left}px"));
+ let _ = st.set_property("top", &format!("{top}px"));
+ let _ = st.set_property("height", &format!("{h}px"));
+ let _ = st.set_property("font-size", &format!("{}px", (h * 0.8).round()));
+ let _ = st.set_property("line-height", &format!("{h}px"));
+ }
+ if crate::ime::take_reset() {
+ let focused = web_sys::window()
+ .and_then(|w| w.document())
+ .and_then(|d| d.active_element())
+ .is_some_and(|a| a == *self.sink.unchecked_ref::<web_sys::Element>());
+ self.sink.set_value("");
+ if focused {
+ let _ = self.sink.blur();
+ let _ = self.sink.focus();
+ }
+ }
+ }
+
fn update_cursor(&mut self) {
let (x, y) = self.driver.cursor_pos;
let icon = self.driver.cursor_icon_at(&self.app, x, y, self.size());
@@ -363,6 +422,7 @@ impl<A: Application> Shell for WebShell<A> {
self.redraw = true;
}
}
+ self.place_sink();
if waiting.is_empty() {
return;
}
@@ -487,7 +547,7 @@ impl<A: Application> Loop<A> {
}
/// The callbacks: the loop's own two, the wake hook, and the canvas's events.
- fn install(lp: &Rc<Self>, canvas: &HtmlCanvasElement) -> Result<(), JsValue> {
+ fn install(lp: &Rc<Self>, canvas: &HtmlCanvasElement, sink: &HtmlTextAreaElement) -> Result<(), JsValue> {
let l = lp.clone();
*lp.frame_cb.borrow_mut() = Some(Closure::new(move |_t: f64| l.on_frame()));
let l = lp.clone();
@@ -534,13 +594,14 @@ impl<A: Application> Loop<A> {
})?;
let l = lp.clone();
let c = canvas.clone();
+ let k = sink.clone();
listen(target, "pointerdown", true, move |e: PointerEvent| {
let Some(btn) = dom_button(e.button()) else { return };
// Take the keyboard first: the focus event this fires is
// dispatched now, before the shell is borrowed below. Capture
// keeps a drag's moves and its release coming to the canvas when
// the pointer leaves it, as a Wayland implicit grab does.
- let _ = c.focus();
+ let _ = k.focus();
let _ = c.set_pointer_capture(e.pointer_id());
e.prevent_default();
l.event(|s| {
@@ -580,19 +641,74 @@ impl<A: Application> Loop<A> {
l.arm_finger_lift();
}
})?;
+ // The canvas focused some other way (Tab, the page's script) hands
+ // the keyboard to the sink.
+ let k = sink.clone();
+ listen(target, "focus", false, move |_e: FocusEvent| {
+ let _ = k.focus();
+ })?;
+ let keys: &web_sys::EventTarget = sink.as_ref();
let l = lp.clone();
- listen(target, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
+ listen(keys, "keydown", true, move |e: KeyboardEvent| l.key(&e, ElementState::Pressed))?;
let l = lp.clone();
- listen(target, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
+ listen(keys, "keyup", true, move |e: KeyboardEvent| l.key(&e, ElementState::Released))?;
for (name, focused) in [("focus", true), ("blur", false)] {
let l = lp.clone();
- listen(target, name, false, move |_e: FocusEvent| {
+ let c = canvas.clone();
+ listen(keys, name, false, move |e: FocusEvent| {
+ // Over to the canvas is on its way back.
+ let to_canvas = e.related_target().is_some_and(|r| AsRef::<JsValue>::as_ref(&r) == AsRef::<JsValue>::as_ref(&c));
+ if !focused && to_canvas {
+ return;
+ }
l.event(|s| {
let (driver, t) = s.turn();
driver.keyboard_focus(t, focused);
})
})?;
}
+ // The input method's half.
+ let l = lp.clone();
+ let k = sink.clone();
+ listen(keys, "input", false, move |e: InputEvent| {
+ if e.is_composing() {
+ let text = k.value();
+ let cursor = utf16_range_to_bytes(&text, k.selection_start().ok().flatten(), k.selection_end().ok().flatten());
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.preedit(t, Some(crate::ime::Preedit { text, cursor }));
+ });
+ } else if e.input_type() == "insertFromPaste" {
+ // A paste that reached the sink by the browser's default (a
+ // handler of the page's swallowed its `paste` event): the
+ // clipboard's text all the same, for the held ⌘/Ctrl+V.
+ clipboard::pasted(k.value());
+ k.set_value("");
+ l.release_paste();
+ } else {
+ // Text with no composition: an emoji panel, dictation.
+ let text = k.value();
+ k.set_value("");
+ if !text.is_empty() {
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.commit_text(t, text);
+ });
+ }
+ }
+ })?;
+ let l = lp.clone();
+ let k = sink.clone();
+ listen(keys, "compositionend", false, move |e: CompositionEvent| {
+ let text = e.data().unwrap_or_default();
+ k.set_value("");
+ l.event(|s| {
+ let (driver, t) = s.turn();
+ driver.preedit(t, None);
+ let (driver, t) = s.turn();
+ driver.commit_text(t, text);
+ });
+ })?;
// The clipboard's events, raised by the three keys `key` lets
// through. They go to the focused element or the body, so they are
// heard on the document.
@@ -628,6 +744,12 @@ impl<A: Application> Loop<A> {
}
fn key(&self, e: &KeyboardEvent, state: ElementState) {
+ // The input method's key: it composes with it (and a browser that
+ // sends the key confirming a composition after its end still marks
+ // it 229).
+ if e.is_composing() || e.key_code() == 229 {
+ return;
+ }
let accel = e.ctrl_key() || e.meta_key();
let Some((key, text)) = map_key(&e.key(), accel) else { return };
let clip = clipboard_key(&e.key(), accel, e.alt_key());
@@ -716,6 +838,40 @@ impl<A: Application> Loop<A> {
}
}
+/// The hidden textarea the keyboard goes to (see the module doc): fixed in
+/// the page, invisible, never hit, and no help offered on what is typed.
+fn keyboard_sink() -> Result<HtmlTextAreaElement, JsValue> {
+ let doc = window().document().ok_or_else(|| JsValue::from_str("cce-ui: no document"))?;
+ let sink: HtmlTextAreaElement = doc.create_element("textarea")?.dyn_into()?;
+ for (k, v) in [("autocomplete", "off"), ("autocorrect", "off"), ("autocapitalize", "off"), ("spellcheck", "false"), ("aria-hidden", "true")] {
+ sink.set_attribute(k, v)?;
+ }
+ let st = sink.style();
+ for (k, v) in [
+ ("position", "fixed"),
+ ("left", "0px"),
+ ("top", "0px"),
+ ("width", "1px"),
+ ("height", "16px"),
+ ("padding", "0"),
+ ("border", "0"),
+ ("margin", "0"),
+ ("outline", "none"),
+ ("resize", "none"),
+ ("overflow", "hidden"),
+ ("white-space", "pre"),
+ ("opacity", "0"),
+ ("pointer-events", "none"),
+ ("caret-color", "transparent"),
+ ("color", "transparent"),
+ ("background", "transparent"),
+ ] {
+ st.set_property(k, v)?;
+ }
+ doc.body().ok_or_else(|| JsValue::from_str("cce-ui: no body"))?.append_child(&sink)?;
+ Ok(sink)
+}
+
fn window() -> web_sys::Window {
web_sys::window().expect("cce-ui's browser shell runs in a window")
}
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 0b52757..3b4c556 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -113,6 +113,15 @@ pub struct TextBox {
/// The laid-out base rect, cached from [`Layout::rect_assigned`] — the cursor/scroll math
/// reads geometry between events, which the narrow traits don't otherwise carry.
rect: Rect,
+ /// An input method's composition, shown in `edit_buffer` as a PROVISIONAL run:
+ /// its start and length in chars. Everything that draws the buffer — wrap,
+ /// scroll, caret, the glyph advances — draws it as it draws typed text, and
+ /// the selection quads underline it. It is never committed, recorded in the
+ /// history or reported as a change: `committed_buffer` is the buffer without
+ /// it. See `crate::ime`.
+ composing: Option<(usize, usize)>,
+ /// The `ime::generation` the run shows.
+ ime_seen: u64,
/// Recessed style: a `Recess` overlay is carved over the box's own fill —
/// an inset well, the input-direction counterpart of the raised controls.
recessed: Option<bool>,
@@ -170,6 +179,8 @@ impl TextBox {
hovered: false,
rect: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
recessed: None,
+ composing: None,
+ ime_seen: 0,
})
}
@@ -379,8 +390,9 @@ impl TextBox {
pub fn take_change(&mut self) -> bool {
if self.update_on_type {
- if self.text != self.edit_buffer {
- self.text = self.edit_buffer.clone();
+ let held = self.committed_buffer();
+ if self.text != held {
+ self.text = held;
self.just_changed = true;
}
}
@@ -675,10 +687,80 @@ impl TextBox {
self.clamp_scroll();
}
+ /// `edit_buffer` without an input method's provisional run: what the box
+ /// holds, as opposed to what it shows.
+ pub fn committed_buffer(&self) -> String {
+ match self.composing {
+ Some((start, len)) => self.edit_buffer.chars().enumerate().filter(|(i, _)| *i < start || *i >= start + len).map(|(_, c)| c).collect(),
+ None => self.edit_buffer.clone(),
+ }
+ }
+
+ /// Take the provisional run out of the buffer, the caret back where it
+ /// began. Whether there was one.
+ fn strip_composition(&mut self) -> bool {
+ let Some((start, _)) = self.composing else { return false };
+ self.edit_buffer = self.committed_buffer();
+ self.composing = None;
+ self.cursor_idx = start.min(self.edit_buffer.chars().count());
+ self.select_anchor = None;
+ self.all_selected = false;
+ true
+ }
+
+ /// Drop a composition this box is showing, and have the input method
+ /// cancel it: the box is no longer where it is going.
+ fn abandon_composition(&mut self) {
+ if self.strip_composition() {
+ crate::ime::request_reset();
+ self.sync_editor_state();
+ }
+ self.ime_seen = crate::ime::generation();
+ }
+
+ /// Show the input method's current composition, if it has moved since
+ /// this box last showed one: the old run out, the new one in at the
+ /// caret, the caret where the input method has its cursor. A composition
+ /// begun over a selection replaces it, as typing would.
+ fn sync_preedit(&mut self) {
+ if !self.editing {
+ return;
+ }
+ let generation = crate::ime::generation();
+ if generation == self.ime_seen {
+ return;
+ }
+ self.ime_seen = generation;
+ self.strip_composition();
+ if let Some(p) = crate::ime::preedit() {
+ let before = self.snapshot();
+ if before.all_selected || before.selected_range().is_some() {
+ let mut state = before.clone();
+ state.insert_text("");
+ self.history.record(before);
+ self.edit_buffer = state.buffer;
+ self.cursor_idx = state.cursor_idx;
+ }
+ let start = self.cursor_idx.min(self.edit_buffer.chars().count());
+ let at = self.edit_buffer.char_indices().nth(start).map_or(self.edit_buffer.len(), |(b, _)| b);
+ self.edit_buffer.insert_str(at, &p.text);
+ self.composing = Some((start, p.text.chars().count()));
+ self.cursor_idx = start + p.caret_chars();
+ self.select_anchor = None;
+ self.all_selected = false;
+ }
+ self.sync_editor_state();
+ self.scroll_to_cursor();
+ }
+
/// The legacy `focus()` body minus the global-focus claim (the caller's, via
/// `EventCtx::request_focus`).
fn begin_editing(&mut self) {
if self.disabled { return; }
+ // A composition still going is the input method's for wherever the
+ // caret was; this box starts with none.
+ self.composing = None;
+ self.ime_seen = crate::ime::generation();
self.editing = true;
self.edit_buffer = self.text.clone();
let len = self.edit_buffer.chars().count();
@@ -693,6 +775,7 @@ impl TextBox {
/// The legacy `unfocus()` body: leave edit mode and commit the buffer.
fn commit_editing(&mut self) {
if self.editing {
+ self.abandon_composition();
self.editing = false;
if self.text != self.edit_buffer {
self.text = self.edit_buffer.clone();
@@ -747,6 +830,12 @@ impl TextBox {
fn handle_key(&mut self, event: &KeyEvent) -> bool {
if !self.editing || self.disabled { return false; }
if event.state != ElementState::Pressed { return false; }
+ // While an input method composes, its keys are its own: a shell does
+ // not deliver them, and one that does is not editing this text.
+ self.sync_preedit();
+ if self.composing.is_some() {
+ return true;
+ }
let control = event.ctrl;
@@ -1008,10 +1097,14 @@ impl TextBox {
/// Selection highlight + caret quads, shared by both render branches. `x`/`w` are the
/// (possibly label-inset) horizontal span the branch draws in — the legacy paths differed
/// (non-rounded and rounded alike use the full base span).
- fn selection_quads(&self, x: f32, w: f32, out: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) {
+ /// The selection highlight, an input method's composition underlined, and
+ /// the caret, as quads. Returns the caret's rect while editing (unclipped,
+ /// in the box's coordinates), which the painter reports to the input method.
+ fn selection_quads(&self, x: f32, w: f32, out: &mut Vec<(f32, f32, f32, f32, [f32; 4])>) -> Option<[f32; 4]> {
if !(self.editing || self.select_anchor.is_some()) {
- return;
+ return None;
}
+ let mut caret = None;
let top = self.label_top();
let char_width = self.char_width();
let line_height = self.line_height();
@@ -1072,11 +1165,34 @@ impl TextBox {
}
}
+ if let Some((cs, cl)) = self.composing {
+ // The composition's underline, a line at a time, under the
+ // glyphs it covers.
+ let last_line = index_map.get((cs + cl).saturating_sub(1).min(index_map.len() - 1)).map_or(0, |p| p.0);
+ let first_line = index_map.get(cs.min(index_map.len() - 1)).map_or(0, |p| p.0);
+ for line_idx in first_line..=last_line {
+ let cols: Vec<usize> = (cs..cs + cl)
+ .filter_map(|i| index_map.get(i).filter(|p| p.0 == line_idx).map(|p| p.1))
+ .collect();
+ if let (Some(&a), Some(&b)) = (cols.iter().min(), cols.iter().max()) {
+ let ux = x + 8.0 + self.line_col_x(line_idx, a) - self.scroll_x;
+ let uw = self.line_col_x(line_idx, b + 1) - self.line_col_x(line_idx, a);
+ let uy = self.rect.y + top + 8.0 + ((line_idx + 1) as f32 * line_height) - 2.0 - self.scroll_y;
+ let left = ux.max(x + 8.0);
+ let right = (ux + uw).min(x + w - 8.0);
+ if left < right && uy >= view_top && uy + 1.5 <= view_bottom {
+ out.push((left, uy, right - left, 1.5, cursor_color));
+ }
+ }
+ }
+ }
+
if self.editing {
let caret_h = self.font_size * 1.15;
let (cursor_l, cursor_c) = index_map[self.cursor_idx.min(index_map.len() - 1)];
let cursor_x = x + 8.0 + self.line_col_x(cursor_l, cursor_c) - self.scroll_x;
let cursor_y = self.rect.y + top + 8.0 + (cursor_l as f32 * line_height) + (line_height - caret_h) / 2.0 - self.scroll_y;
+ caret = Some([cursor_x, cursor_y, 1.5, caret_h]);
let clipped_y = cursor_y.max(view_top);
let clipped_bottom = (cursor_y + caret_h).min(view_bottom);
if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
@@ -1104,6 +1220,16 @@ impl TextBox {
}
}
+ if let Some((cs, cl)) = self.composing {
+ let at = |i: usize| self.glyph_positions.get(i).copied().unwrap_or(i as f32 * char_width);
+ let left = (x + 8.0 + at(cs) - self.scroll_x).max(x + 8.0);
+ let right = (x + 8.0 + at(cs + cl) - self.scroll_x).min(x + w - 8.0);
+ if left < right {
+ let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
+ out.push((left, text_y + self.font_size + 1.0, right - left, 1.5, cursor_color));
+ }
+ }
+
if self.editing {
let offset = if self.glyph_positions.is_empty() {
self.cursor_idx as f32 * char_width
@@ -1111,13 +1237,15 @@ impl TextBox {
self.cursor_x_offset
};
let cursor_x = x + 8.0 + offset - self.scroll_x;
+ let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
+ let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
+ caret = Some([cursor_x, cursor_y, 1.5, caret_h]);
if cursor_x >= x + 8.0 && cursor_x <= x + w - 8.0 {
- let text_y = crate::layout::align_text_y(self.rect.y, self.rect.height, self.font_size, top);
- let cursor_y = text_y + (self.font_size - caret_h) / 2.0;
out.push((cursor_x, cursor_y, 1.5, caret_h, cursor_color));
}
}
}
+ caret
}
/// The value/placeholder text lines — the legacy `text_labels` body minus the control
@@ -1400,6 +1528,8 @@ impl Paint for TextBox {
/// The legacy `prepare_text`: sync font family/size with the live config defaults, then
/// shape the display text and record per-glyph advances (`map_x_to_idx` reads them).
fn prepare_text(&mut self, fs: &mut cosmic_text::FontSystem, _rect: Rect) {
+ // The input method's composition, shown before the buffer is shaped.
+ self.sync_preedit();
let (style_family, style_size) = crate::layout::control_label_font_detached_parsed();
if self.font_size == self.default_font_size {
self.font_size = style_size;
@@ -1544,7 +1674,10 @@ impl Paint for TextBox {
quads.push((self.rect.x, self.rect.y + top, self.rect.width, visual_h, border_color));
quads.push((self.rect.x + border_w, self.rect.y + top + border_w, self.rect.width - 2.0 * border_w, visual_h - 2.0 * border_w, bg_color));
}
- self.selection_quads(self.rect.x, self.rect.width, &mut quads);
+ if let Some([cx, cy, cw, ch]) = self.selection_quads(self.rect.x, self.rect.width, &mut quads) {
+ let (ox, oy) = ctx.offset();
+ crate::ime::report_caret(cx + ox, cy + oy, cw, ch);
+ }
}
for (qx, qy, qw, qh, qc) in quads {
ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
@@ -1582,7 +1715,10 @@ impl Paint for TextBox {
}
let mut quads: Vec<(f32, f32, f32, f32, [f32; 4])> = Vec::new();
- self.selection_quads(x, w, &mut quads);
+ if let Some([cx, cy, cw, ch]) = self.selection_quads(x, w, &mut quads) {
+ let (ox, oy) = ctx.offset();
+ crate::ime::report_caret(cx + ox, cy + oy, cw, ch);
+ }
for (qx, qy, qw, qh, qc) in quads {
ctx.quad(Rect { x: qx, y: qy, width: qw, height: qh }, qc);
}
@@ -1653,6 +1789,11 @@ impl Input for TextBox {
}
fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
+ // A press moves the caret: a composition in progress is left where
+ // it was, cancelled.
+ if let Event::MouseButton { state: ElementState::Pressed, .. } = event {
+ self.abandon_composition();
+ }
match event {
Event::MouseButton { button: MouseButton::Right, state: ElementState::Pressed, x: px, y: py, .. } => {
if self.disabled { return false; }
@@ -1988,6 +2129,62 @@ mod tests {
assert!(tb.edit_buffer.starts_with("imap"), "the existing value survives the first keystroke");
}
+ /// An input method's composition is a provisional run in the buffer: shown
+ /// at the caret with the caret where the input method has it, never held
+ /// (`committed_buffer`, `take_change`), replaced by the commit, which is
+ /// typed — and dropped, the input method told, when editing ends under it.
+ #[test]
+ fn a_composition_is_shown_in_place_and_the_commit_is_typed() {
+ use crate::ime::{self, Preedit};
+ let mut dummy = crate::context::UiContext::new();
+ let mut tb = TextBox::new("ab".to_string()).with_update_on_type(true);
+ tb.set_rect(10.0, 10.0, 300.0, 30.0);
+ tb.focus();
+ tb.cursor_idx = 1;
+ tb.select_anchor = None;
+ tb.all_selected = false;
+ let _ = ime::take_reset();
+
+ // "にほ", the input method's cursor after "に".
+ ime::set_preedit(Some(Preedit::new("にほ", Some((0, 3)))));
+ tb.sync_preedit();
+ assert_eq!(tb.edit_buffer, "aにほb");
+ assert_eq!(tb.composing, Some((1, 2)));
+ assert_eq!(tb.cursor_idx, 2);
+ assert_eq!(tb.committed_buffer(), "ab");
+ assert!(!tb.take_change(), "a composition is not a change");
+ // A key the input method lets through mid-composition is not typed.
+ let typed = |t: &str| KeyEvent {
+ state: ElementState::Pressed,
+ logical_key: Key::Character(t.to_string()),
+ text: Some(t.to_string()),
+ repeat: false,
+ ctrl: false,
+ shift: false,
+ alt: false,
+ };
+ assert!(tb.keyboard_input(&typed("x"), &mut dummy));
+ assert_eq!(tb.edit_buffer, "aにほb");
+
+ // The commit: the composition ends, then its text is typed.
+ ime::set_preedit(None);
+ assert!(tb.keyboard_input(&typed("日本"), &mut dummy));
+ assert_eq!(tb.edit_buffer, "a日本b");
+ assert_eq!((tb.composing, tb.cursor_idx), (None, 3));
+ assert!(tb.take_change());
+ assert_eq!(tb.text, "a日本b");
+
+ // Editing ends mid-composition: the run goes, the input method is
+ // told, and the text is what was held.
+ ime::set_preedit(Some(Preedit::new("か", None)));
+ tb.sync_preedit();
+ assert_eq!(tb.edit_buffer, "a日本かb");
+ tb.commit_editing();
+ assert_eq!(tb.text, "a日本b");
+ assert!(ime::take_reset());
+ assert_eq!(ime::preedit(), None);
+ }
+
/// Undo/redo over an editing session: a typed word is one step, a
/// space starts the next, a cursor move splits a run, Backspace runs
/// coalesce, redo walks forward, a fresh keystroke after an undo forks,