git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commit22fb0862e8e55a0aed4a7b84f6b6e8d34244d741
parent17641bfe98
authorClaude <noreply@anthropic.com>
date2026-10-05 22:01
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,