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

commit49bee9238b85b7322e071d0dee9f46cd450a2dd9
parent87c8725268
authorClaude <noreply@anthropic.com>
date2026-10-05 23:09
IME: the composition in LineEdit and the DocEditor

Both show an input method's composition at the caret without ever holding
it: a host reads LineEdit::text directly and saves the DocEditor's buffer.

- LineEdit splices it into display() at the caret; display_index and
  text_index map across it (the caret where the input method has its
  cursor, a point inside the composition is the caret); composition_range
  is the span to underline, a masked field shows bullets. The app calls
  sync_ime each frame the field has the keyboard, reports the caret it
  draws (ime::report_caret), and drop_composition when it loses focus.
- The DocEditor lays out the caret's line with the composition in it and
  maps every column read off that layout across it (laid_col /
  source_col: the caret, caret_rect, pos_at); it underlines it and reports
  its own caret while painted focused.
- Both take no key while a composition is up, take the commit as typed,
  and treat a press as dropping the composition (cancelled in the input
  method) and placing the caret.

Verified: a test each; cce-notes built against this tree and driven under
the headless sway with a stand-in input-method-v2 client — the composition
underlined at the caret, the commit typed, a second composition left up
through the autosave then dropped by a click, and the note on disk held
the commit and never the composition. Linux: lib suite 548 passed (587
with the markdown and doc_editor features; the 2 known failures), plate
golden identical, the 24-step sway run 0 px; check-mac and check-wasm ok.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WjL3pejMNY95NHv9BcmXaZ

 CLAUDE.md                    |  28 ++++++-
 src/widget/doc_editor/mod.rs | 192 ++++++++++++++++++++++++++++++++++++++++++-
 src/widget/line_edit.rs      | 171 +++++++++++++++++++++++++++++++++++++-
 3 files changed, 383 insertions(+), 8 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 12ef531..b2579f6 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -335,8 +335,32 @@ the history, and the box takes no key while it composes. It is applied in `prepa
 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: `LineEdit` and the `DocEditor` take the
-commit but do not show the composition; no shell sends surrounding text, so an input
+commit_is_typed` is the test.
+
+**`LineEdit` and the `DocEditor` show it too** (since 2026-10-05), each without letting it
+into what it holds — a host reads `LineEdit::text` directly and saves the `DocEditor`'s
+buffer, so neither ever contains it. `LineEdit` splices it into `display()` at the caret,
+and `display_index` / `text_index` map across it (the caret lands where the input method
+has its cursor, a point inside the composition is the caret, one after it is the text it is
+drawn after); `composition_range` is the span to underline, a masked field shows bullets.
+The app, which draws the field, calls `sync_ime` each frame the field has the keyboard,
+reports the caret it draws (`ime::report_caret` — also what tells the shell text is
+wanted), and `drop_composition` when the field loses it. The `DocEditor` lays out the
+caret's line WITH the composition (an active line, raw anyway) and maps every column read
+off that layout across it (`laid_col` / `source_col`: the caret, `caret_rect`, `pos_at`);
+it underlines it, and reports its caret itself while painted focused; a host calls
+`drop_composition` when the editor loses the keyboard. Both take no key while a
+composition is up, take the commit as typed, and treat a press as dropping the
+composition (cancelled in the input method) and placing the caret — the `DocEditor`'s read
+through the line as drawn. `a_composition_is_shown_at_the_caret_and_never_held` and
+`a_composition_is_laid_out_in_place_and_never_held` are the tests; cce-notes, built against
+this tree, was driven under the headless sway with the stand-in input method (2026-10-05):
+the composition underlined at the caret, the commit typed, a second composition left up
+through the editor's autosave and then dropped by a click — and the note on disk held the
+commit and never the composition. No `LineEdit` host calls the three methods yet
+(cce-browser's URL bar and dialog fields; it cannot be built here, for WPE).
+
+Not there yet: no shell sends surrounding text, so an input
 method's `delete_surrounding_text` (text-input-v3) is not applied; and a password box is
 announced with the normal content purpose.
 
diff --git a/src/widget/doc_editor/mod.rs b/src/widget/doc_editor/mod.rs
index 794fe97..6d0bec0 100644
--- a/src/widget/doc_editor/mod.rs
+++ b/src/widget/doc_editor/mod.rs
@@ -87,6 +87,13 @@ pub struct DocEditor {
     follow_caret: bool,
     /// The host's embedded images, by link text (see [`DocEditor::set_images`]).
     images: Option<Box<dyn Fn(&str) -> Option<EmbedImage>>>,
+    /// An input method's composition (see `crate::ime`), at the source
+    /// position it began, and the `ime::generation` it was taken at. It is
+    /// in the caret line's LAYOUT, never in the buffer — the buffer is what
+    /// the host saves — so every column read off that line's layout is
+    /// mapped across it (`laid_col`, `source_col`).
+    composition: Option<(Pos, crate::ime::Preedit)>,
+    ime_seen: u64,
 }
 
 impl DocEditor {
@@ -117,6 +124,8 @@ impl DocEditor {
             viewport: Rect { x: 0.0, y: 0.0, width: 0.0, height: 0.0 },
             follow_caret: false,
             images: None,
+            composition: None,
+            ime_seen: 0,
         };
         e.sync();
         e.caret_to_body();
@@ -301,7 +310,13 @@ impl DocEditor {
         }
         let act = self.shown_active;
         let active = self.is_active(i, act);
-        let text = self.buf.line(i);
+        // The caret line with a composition in it, laid out as typed.
+        let composed = self.composition.as_ref().filter(|(at, _)| at.line == i).map(|(at, p)| {
+            let mut t = self.buf.line(i).to_string();
+            t.insert_str(at.col.min(t.len()), &p.text);
+            t
+        });
+        let text = composed.as_deref().unwrap_or(self.buf.line(i));
         let line = match self.props.get(i) {
             Some(Some(p)) if !active => {
                 let key = match p {
@@ -357,14 +372,94 @@ impl DocEditor {
         (self.content_height() - self.viewport.height).max(0.0)
     }
 
+    // ---- the input method ---------------------------------------------------
+
+    /// Take up the input method's composition, if it has moved since the
+    /// editor last did: the caret line relaid out with it in. A composition
+    /// begun over a selection replaces it, as typing would.
+    fn sync_ime(&mut self) {
+        let generation = crate::ime::generation();
+        if generation == self.ime_seen {
+            return;
+        }
+        self.ime_seen = generation;
+        let next = crate::ime::preedit();
+        if next.is_some() && self.composition.is_none() && self.buf.delete_selection() {
+            self.sync();
+        }
+        if let Some((at, _)) = self.composition.take() {
+            self.relayout(at.line);
+        }
+        if let Some(p) = next {
+            let at = self.buf.caret;
+            self.buf.anchor = None;
+            self.composition = Some((at, p));
+            self.relayout(at.line);
+            self.follow_caret = true;
+        }
+    }
+
+    /// The editor lost the keyboard, or a press moved its caret: a
+    /// composition it was showing is dropped, and the input method asked to
+    /// cancel it.
+    pub fn drop_composition(&mut self) {
+        if let Some((at, _)) = self.composition.take() {
+            self.relayout(at.line);
+            crate::ime::request_reset();
+        }
+        self.ime_seen = crate::ime::generation();
+    }
+
+    /// Whether an input method's composition is showing.
+    pub fn composing(&self) -> bool {
+        self.composition.is_some()
+    }
+
+    fn relayout(&mut self, line: usize) {
+        if let Some(l) = self.layouts.get_mut(line) {
+            *l = None;
+        }
+        self.tops_dirty = true;
+    }
+
+    /// The composition on `line`: its source column, its length in bytes,
+    /// and the bytes into it the input method has its cursor.
+    fn composition_on(&self, line: usize) -> Option<(usize, usize, usize)> {
+        let (at, p) = self.composition.as_ref().filter(|(at, _)| at.line == line)?;
+        let caret = p.text.char_indices().nth(p.caret_chars()).map_or(p.text.len(), |(b, _)| b);
+        Some((at.col, p.text.len(), caret))
+    }
+
+    /// A source column of `line` as a column of its layout: past a
+    /// composition there, moved over it; the caret itself, where the input
+    /// method has its cursor in it.
+    fn laid_col(&self, line: usize, col: usize, is_caret: bool) -> usize {
+        match self.composition_on(line) {
+            Some((c, _, caret)) if is_caret && col == c => c + caret,
+            Some((c, len, _)) if col > c => col + len,
+            _ => col,
+        }
+    }
+
+    /// A column of `line`'s layout as a source column: inside a composition
+    /// is where it began.
+    fn source_col(&self, line: usize, col: usize) -> usize {
+        match self.composition_on(line) {
+            Some((c, len, _)) if col > c && col < c + len => c,
+            Some((c, len, _)) if col >= c + len => col - len,
+            _ => col,
+        }
+    }
+
     // ---- caret geometry -----------------------------------------------------
 
     /// The caret's (x, y) in content space and its row height.
     fn caret_point(&mut self, p: Pos) -> (f32, f32, f32) {
         self.ensure(p.line);
         self.retop();
+        let col = self.laid_col(p.line, p.col, p == self.buf.caret);
         let l = self.layouts[p.line].as_ref().expect("laid out");
-        let (x, row) = l.caret_xy(p.col);
+        let (x, row) = l.caret_xy(col);
         (x, self.tops[p.line] + row as f32 * l.row_h, l.row_h)
     }
 
@@ -387,7 +482,8 @@ impl DocEditor {
         self.retop();
         let l = self.layouts[i].as_ref().unwrap();
         let row = (((y - self.tops[i]) / l.row_h).floor().max(0.0) as usize).min(l.rows - 1);
-        Pos::new(i, l.col_at(x, row))
+        let col = l.col_at(x, row);
+        Pos::new(i, self.source_col(i, col))
     }
 
     fn scroll_to_caret(&mut self) {
@@ -448,6 +544,12 @@ impl DocEditor {
     /// sends them to `Application::undo`); call [`DocEditor::undo`] there.
     pub fn key(&mut self, ev: &KeyEvent) -> Response {
         self.sync();
+        // 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_ime();
+        if self.composition.is_some() {
+            return Response::None;
+        }
         let rev = self.buf.revision;
         let caret = (self.buf.caret, self.buf.anchor);
         let (ctrl, shift) = (ev.ctrl, ev.shift);
@@ -700,6 +802,16 @@ impl DocEditor {
         self.sync();
         let rev = self.buf.revision;
         let before = (self.buf.caret, self.buf.anchor);
+        // A press mid-composition places the caret, read through the line
+        // as it is drawn, and the composition is left there, cancelled.
+        if self.composition.is_some() {
+            let p = self.pos_at(sx, sy);
+            self.drop_composition();
+            self.buf.set_caret(p, false);
+            self.dragging = true;
+            self.want_x = None;
+            return self.after_edit(rev, before);
+        }
         let (x, y) = (sx - self.origin.0, sy - self.origin.1 + self.scroll);
         if y >= 0.0 && !shift {
             let i = self.line_at_y(y);
@@ -857,6 +969,7 @@ impl DocEditor {
         self.viewport = rect;
         self.origin = (rect.x + (rect.width - width) / 2.0, rect.y + self.pad);
         self.sync();
+        self.sync_ime();
         if std::mem::take(&mut self.follow_caret) {
             self.scroll_to_caret();
         }
@@ -887,6 +1000,8 @@ impl DocEditor {
         let (ox, oy) = (self.origin.0, self.origin.1 - self.scroll);
         let sel = self.buf.selection();
         let caret = self.buf.caret;
+        let caret_col = self.laid_col(caret.line, caret.col, true);
+        let composition = self.composition_on(caret.line);
         let th = self.theme.clone();
         let first = self.line_at_y(self.scroll - self.pad);
         pc.clip(rect, |pc| {
@@ -945,11 +1060,29 @@ impl DocEditor {
                         pc.vector(ox + r.x, sy, ox + r.x + r.w, sy, 1.0, r.color, Cap::Flat);
                     }
                 }
+                if let Some((c, len, _)) = composition {
+                    if i == caret.line {
+                        // The composition's underline, a row at a time.
+                        let (from, to) = (l.caret_xy(c), l.caret_xy(c + len));
+                        for row in from.1..=to.1 {
+                            let x0 = if row == from.1 { from.0 } else { l.content_x };
+                            let x1 = if row == to.1 { to.0 } else { l.runs.iter().filter(|r| r.row == row).map(|r| r.x + r.w).fold(x0, f32::max) };
+                            if x1 > x0 {
+                                let uy = top + (row + 1) as f32 * l.row_h - 3.0;
+                                pc.quad(Rect { x: ox + x0, y: uy, width: x1 - x0, height: 1.5 }, th.caret);
+                            }
+                        }
+                    }
+                }
                 if focused && i == caret.line {
-                    let (x, row) = l.caret_xy(caret.col);
+                    let (x, row) = l.caret_xy(caret_col);
                     let h = l.row_h * 0.8;
                     let y = top + row as f32 * l.row_h + (l.row_h - h) / 2.0;
                     pc.quad(Rect { x: ox + x - 0.5, y, width: 2.0, height: h }, th.caret);
+                    // Where an input method puts its candidates, and that
+                    // text is wanted at all.
+                    let (dx, dy) = pc.offset();
+                    crate::ime::report_caret(ox + x - 0.5 + dx, y + dy, 2.0, h);
                 }
                 i += 1;
             }
@@ -986,6 +1119,57 @@ mod tests {
         e
     }
 
+    /// A composition is in the caret line's layout and never in the buffer:
+    /// drawn as typed and underlined, the caret where the input method has
+    /// its cursor, columns read off the line mapped across it; keys wait
+    /// while it is up; the commit is typed; a press drops it, asks the input
+    /// method to cancel, and places the caret; the caret is reported.
+    #[test]
+    fn a_composition_is_laid_out_in_place_and_never_held() {
+        use crate::ime::{self, Preedit};
+        let rect = Rect { x: 0.0, y: 0.0, width: 600.0, height: 400.0 };
+        let mut e = editor("ab\ncd");
+        e.buf.caret = Pos::new(0, 1);
+        e.buf.anchor = None;
+        let mut pc = PaintCtx::new();
+        e.paint(&mut pc, rect, true);
+        let plain_caret = e.caret_rect().x;
+        let _ = ime::take_reset();
+
+        ime::set_preedit(Some(Preedit::new("にほ", Some((3, 3)))));
+        ime::begin_frame();
+        e.paint(&mut pc, rect, true);
+        ime::end_frame();
+        assert_eq!(e.text(), "ab\ncd", "never in the buffer");
+        let l = e.layouts[0].as_ref().unwrap();
+        let drawn: String = l.runs.iter().map(|r| r.text.as_str()).collect();
+        assert_eq!(drawn, "aにほb");
+        assert!(e.caret_rect().x > plain_caret, "the caret is after に");
+        assert!(ime::caret().is_some(), "the caret is reported");
+        // Past the composition on its line is the source's end of line.
+        assert_eq!(e.pos_at(rect.x + 590.0, rect.y + e.pad + 2.0), Pos::new(0, 2));
+        assert_eq!(e.key(&typed("x")), Response::None);
+        assert_eq!(e.text(), "ab\ncd");
+
+        // The commit: the composition ends, its text is typed.
+        ime::set_preedit(None);
+        assert_eq!(e.key(&typed("日本")), Response::Changed);
+        assert_eq!(e.text(), "a日本b\ncd");
+        assert_eq!(e.buf.caret, Pos::new(0, 7));
+
+        // A press drops a composition, asks for it to be cancelled, and
+        // places the caret.
+        ime::set_preedit(Some(Preedit::new("か", None)));
+        e.paint(&mut pc, rect, true);
+        assert!(e.composing());
+        e.press(rect.x + 590.0, rect.y + e.pad + 2.0, false, false);
+        e.release();
+        assert!(!e.composing());
+        assert!(ime::take_reset());
+        assert_eq!(e.text(), "a日本b\ncd");
+        assert_eq!(e.buf.caret, Pos::new(0, "a日本b".len()));
+    }
+
     #[test]
     fn an_embed_line_shows_its_image_and_the_raw_text_with_the_caret() {
         let mut e = DocEditor::new("top\n![[pic.png|200]]\nend", EditorTheme::new(14.0), false);
diff --git a/src/widget/line_edit.rs b/src/widget/line_edit.rs
index 41dc911..a7c859b 100644
--- a/src/widget/line_edit.rs
+++ b/src/widget/line_edit.rs
@@ -11,6 +11,20 @@
 //! [`LineEdit::press`] / [`LineEdit::drag_to`] / [`LineEdit::release`]. cce-browser's URL bar and its dialog
 //! fields (HTTP auth, JS prompts) are built on it.
 //!
+//! **An input method's composition** (see `crate::ime`) is SHOWN, never
+//! held: `text` keeps what was typed, and [`LineEdit::display`] splices the
+//! composition in at the caret, with [`LineEdit::display_index`] /
+//! [`LineEdit::text_index`] mapping across it (the caret lands where the
+//! input method has its cursor; a press inside the composition is the caret)
+//! and [`LineEdit::composition_range`] the span to underline. The app calls
+//! [`LineEdit::sync_ime`] each frame the field has the keyboard, before
+//! drawing it, reports the caret it draws with `ime::report_caret` (that is
+//! also what tells the shell text input is wanted), and calls
+//! [`LineEdit::drop_composition`] when the field loses the keyboard. The
+//! commit arrives as typed text through [`LineEdit::handle_key`], which
+//! takes no other key while a composition is up — they are the input
+//! method's.
+//!
 //! Indices are **byte** offsets into `text`, always on a char boundary
 //! ([`prev_boundary`] / [`next_boundary`] step them), which is what slicing
 //! and shaping want. [`TextEditorState`](super::TextEditorState), the model
@@ -23,6 +37,7 @@ use std::time::Duration;
 use web_time::Instant;
 
 use crate::history::History;
+use crate::ime::{self, Preedit};
 use crate::widget::{ElementState, Key, KeyEvent, NamedKey};
 
 /// How close two presses at the same offset must be to count as one double
@@ -60,6 +75,10 @@ pub struct LineEdit {
     /// Undo and redo for what [`LineEdit::handle_key`] changes. Never kept
     /// for a masked field (see [`LineEdit::undo`]).
     history: History<Snapshot>,
+    /// The input method's composition, shown at the caret (see the module
+    /// doc), and the `ime::generation` it was taken at.
+    composition: Option<Preedit>,
+    ime_seen: u64,
 }
 
 /// What an undo step restores: the text, and where the caret and selection
@@ -129,13 +148,77 @@ impl LineEdit {
         Self { masked: true, ..Self::default() }
     }
 
-    /// What to draw. Never returns the password itself.
+    /// What to draw: the text, with an input method's composition at the
+    /// caret. Never returns the password itself.
     pub fn display(&self) -> String {
-        if self.masked {
+        let mut shown = if self.masked {
             "\u{2022}".repeat(self.text.chars().count())
         } else {
             self.text.clone()
+        };
+        if let Some(p) = &self.composition {
+            shown.insert_str(self.display_index_held(self.cursor), &self.shown(p));
+        }
+        shown
+    }
+
+    /// A composition as drawn: its text, or a bullet a char when masked.
+    fn shown(&self, p: &Preedit) -> String {
+        if self.masked {
+            "\u{2022}".repeat(p.text.chars().count())
+        } else {
+            p.text.clone()
+        }
+    }
+
+    // ---- the input method ----
+
+    /// Take up the input method's composition, if it has moved since this
+    /// field last did. Call each frame the field has the keyboard, before
+    /// drawing it; true when what [`LineEdit::display`] shows changed. A
+    /// composition begun over a selection replaces it, as typing would.
+    pub fn sync_ime(&mut self) -> bool {
+        let generation = ime::generation();
+        if generation == self.ime_seen {
+            return false;
+        }
+        self.ime_seen = generation;
+        let next = ime::preedit();
+        if next.is_some() && self.composition.is_none() && self.selection.is_some_and(|(a, b)| a < b) {
+            let before = self.snapshot();
+            self.take_selection();
+            if !self.masked {
+                self.history.record(before);
+            }
+        }
+        if next.is_some() {
+            self.selection = None;
+        }
+        let changed = next != self.composition;
+        self.composition = next;
+        changed
+    }
+
+    /// The field lost the keyboard, or a press moved its caret: a
+    /// composition it was showing is dropped, and the input method asked to
+    /// cancel it.
+    pub fn drop_composition(&mut self) {
+        if self.composition.take().is_some() {
+            ime::request_reset();
         }
+        self.ime_seen = ime::generation();
+    }
+
+    /// Whether an input method's composition is showing.
+    pub fn composing(&self) -> bool {
+        self.composition.is_some()
+    }
+
+    /// The composition's span in [`LineEdit::display`], to underline.
+    pub fn composition_range(&self) -> Option<(usize, usize)> {
+        let p = self.composition.as_ref()?;
+        let at = self.display_index_held(self.cursor);
+        Some((at, at + self.shown(p).len()))
     }
 
     pub fn select_all(&mut self) {
@@ -166,6 +249,8 @@ impl LineEdit {
     }
 
     fn press_at(&mut self, at: usize, extend: bool, now: Instant) {
+        // The caret moves: a composition is left where it was, cancelled.
+        self.drop_composition();
         // A click moves the caret: typing after it is a new undo step.
         self.history.break_group();
         let at = self.boundary(at);
@@ -317,7 +402,19 @@ impl LineEdit {
     /// A byte offset into [`LineEdit::display`] as the offset into `text` it
     /// stands for: the same offset unless the field is masked, where each
     /// bullet stands for one character of the text.
+    /// Across a composition: a point inside it is the caret, one after it
+    /// the text it is drawn after.
     pub fn text_index(&self, display_at: usize) -> usize {
+        match self.composition_range() {
+            Some((a, _)) if display_at <= a => self.text_index_held(display_at),
+            Some((_, b)) if display_at < b => self.cursor,
+            Some((a, b)) => self.text_index_held(display_at - (b - a)),
+            None => self.text_index_held(display_at),
+        }
+    }
+
+    /// [`LineEdit::text_index`] over the held text alone.
+    fn text_index_held(&self, display_at: usize) -> usize {
         if !self.masked {
             return self.boundary(display_at);
         }
@@ -328,7 +425,25 @@ impl LineEdit {
     /// The other direction: a byte offset into `text` (the caret, a selection
     /// edge) as the offset into [`LineEdit::display`] where it is drawn — the
     /// same offset unless masked, where it is that many bullets in.
+    ///
+    /// Across a composition: the caret is drawn where the input method has
+    /// its cursor in it, and what follows the caret after it.
     pub fn display_index(&self, at: usize) -> usize {
+        let held = self.display_index_held(at);
+        let Some(p) = &self.composition else { return held };
+        let at = self.boundary(at);
+        if at > self.cursor {
+            held + self.shown(p).len()
+        } else if at == self.cursor {
+            let caret = p.caret_chars();
+            held + if self.masked { caret * '\u{2022}'.len_utf8() } else { p.text.char_indices().nth(caret).map_or(p.text.len(), |(b, _)| b) }
+        } else {
+            held
+        }
+    }
+
+    /// [`LineEdit::display_index`] over the held text alone.
+    fn display_index_held(&self, at: usize) -> usize {
         let at = self.boundary(at);
         if !self.masked {
             return at;
@@ -388,6 +503,12 @@ impl LineEdit {
     /// redo. (A DE chord bound to Ctrl+Y is offered to the app first, as
     /// any chord is, and never gets this far.)
     pub fn handle_key(&mut self, event: &KeyEvent) -> EditOutcome {
+        // 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_ime();
+        if self.composition.is_some() {
+            return EditOutcome::Edited;
+        }
         // Before the recording below, which would file the redo as a fresh
         // edit and so drop everything left to redo.
         if event.state == ElementState::Pressed
@@ -644,6 +765,52 @@ mod tests {
         ev(Key::Named(n), false)
     }
 
+    /// A composition is shown, not held: `display` has it at the caret, the
+    /// indices map across it, `text` never sees it; keys wait while it is up;
+    /// the commit is typed; a press drops it and asks the input method to
+    /// cancel; a masked field shows it as bullets.
+    #[test]
+    fn a_composition_is_shown_at_the_caret_and_never_held() {
+        let mut e = LineEdit::with_text("ab");
+        e.cursor = 1;
+        let _ = ime::take_reset();
+        // "にほ", the input method's cursor after "に" (three bytes in).
+        ime::set_preedit(Some(Preedit::new("にほ", Some((3, 3)))));
+        assert!(e.sync_ime());
+        assert_eq!(e.text, "ab");
+        assert_eq!(e.display(), "aにほb");
+        assert_eq!(e.composition_range(), Some((1, 7)));
+        assert_eq!(e.display_index(1), 4, "the caret after に");
+        assert_eq!(e.display_index(2), 8, "what follows, after the composition");
+        assert_eq!(e.text_index(5), 1, "inside the composition is the caret");
+        assert_eq!(e.text_index(8), 2);
+        assert_eq!(e.handle_key(&ch("x")), EditOutcome::Edited);
+        assert_eq!(e.text, "ab", "keys wait while the input method composes");
+
+        // The commit: the composition ends, its text is typed.
+        ime::set_preedit(None);
+        e.handle_key(&ch("日本"));
+        assert_eq!((e.text.as_str(), e.cursor), ("a日本b", 7));
+        assert_eq!(e.display(), "a日本b");
+
+        // A press drops a composition and asks for it to be cancelled.
+        ime::set_preedit(Some(Preedit::new("か", None)));
+        e.sync_ime();
+        assert!(e.composing());
+        e.press(0, false);
+        assert!(!e.composing());
+        assert!(ime::take_reset());
+        assert_eq!(e.text, "a日本b");
+
+        // Masked: bullets for the composition too.
+        let mut m = LineEdit::masked();
+        ime::set_preedit(Some(Preedit::new("ab", None)));
+        m.sync_ime();
+        assert_eq!(m.display(), "\u{2022}\u{2022}");
+        assert_eq!(m.text, "");
+        ime::set_preedit(None);
+    }
+
     fn typed(e: &mut LineEdit, s: &str) {
         for c in s.chars() {
             e.handle_key(&ch(&c.to_string()));