From 53374aab24a8f41606eda76dd12bdb893dda0aba Mon Sep 17 00:00:00 2001 From: Andres Torresi Date: Wed, 14 Oct 2026 10:30:59 +0100 Subject: [PATCH] Type tool: dead keys and input-method composition on the canvas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Typing into a text frame never turned on the OS input method, so macOS dead keys didn't compose: ⌥E then I typed "ei" instead of "í" (same for ⌥N, ⌥U and Japanese/Chinese input). The canvas now asks for the input method while the Type tool has a caret and handles its events. The text being composed shows in the story as an interaction preview (new `text.compose` command), so it takes the format the next typed character gets, drawn on a yellow box. On commit it's dropped and the result typed with `text.insert` (one undo step); any other edit, tool change or drag drops it first and tells the input method to forget it. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 100 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 8 +++ crates/engine/src/tooling.rs | 24 +++++++ crates/ui-egui/src/canvas.rs | 129 +++++++++++++++++++++++++++++----- 4 files changed, 243 insertions(+), 18 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 0b87820f..3ff557da 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -55,6 +55,18 @@ pub fn specs() -> Vec { ok() }), cmd!("text.insert", "Type", [], None, "{text, raw?: bool (no typographer's quotes)} — replaces the selected text", has_text, insert), + // Neither journalled nor undoable: what is composed is a preview until it's typed. + CommandSpec { + id: "text.compose", + label: "Compose Text", + menu: &[], + shortcut: None, + params: "{text} — input-method text being composed (a dead key's accent), shown at the caret in the text's format until text.insert types the result; \"\" drops it", + enabled: has_doc, + run: compose_text, + journal: false, + undoable: false, + }, cmd!("text.delete", "Delete Text", [], None, "{forward?: bool, word?: bool}", has_text, delete), cmd!(noundo "text.move", "Move Caret", [], None, "{dir: left|right|up|down|lineStart|lineEnd|storyStart|storyEnd, extend?, word?}", has_text, move_caret), cmd!(noundo "text.exitToFrame", "Select Frame", [], None, "{}", has_doc, |s, _| { @@ -492,6 +504,30 @@ fn release(s: &mut Session, p: &Value) -> Result { }) } +/// `text.compose`: show input-method text at the caret, typed in the format the next +/// character would get, without making it part of the story. Replaces what was composed before. +fn compose_text(s: &mut Session, p: &Value) -> Result { + let text = super::text_param(p, "text"); + s.end_composition(); + if text.is_empty() { + return ok(); + } + has_text(s).map_err(|e| bad("text.compose", &e))?; + // The preview types the text; the format chosen at the caret is still for the real typing. + let typing = s.typing_format.clone(); + s.begin_interaction("Type")?; + let r = s.preview("text.insert", json!({"text": text, "raw": true})); + s.typing_format = typing; + if let Err(e) = r { + s.cancel_interaction(); + return Err(e); + } + let uid = s.doc()?.uid; + s.composition = Some((uid, text.len())); + let pos = s.doc()?.selection.text.map_or(0, |t| t.focus); + Ok(json!({"range": [pos.saturating_sub(text.len()), pos]})) +} + fn insert(s: &mut Session, p: &Value) -> Result { let text = super::text_param(p, "text"); // Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`). @@ -2558,3 +2594,67 @@ mod sentence_selection_tests { assert_eq!(para_bounds(s, s.len()), (s.len(), s.len())); } } + +#[cfg(test)] +mod compose_tests { + use serde_json::json; + + use crate::Session; + + /// A story "Ta" with the caret after "T" and 30 pt chosen for the next typing. + fn session() -> (Session, u64) { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 200], "content": "text", "text": "Ta"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.set_tool("type"); + s.execute("text.select", &json!({"story": sid, "anchor": 1})).unwrap(); + s.execute("type.char", &json!({"attrs": {"size": 30}})).unwrap(); + (s, sid) + } + + fn text(s: &Session, sid: u64) -> String { + s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().text.clone() + } + + /// ⌥E shows the accent in the story, in the format it will be typed in, then I types "í": + /// one undo step, and the accent was never part of the document. + #[test] + fn composed_text_shows_in_the_typing_format_until_it_is_typed() { + let (mut s, sid) = session(); + let r = s.execute("text.compose", &json!({"text": "´"})).unwrap(); + assert_eq!(r["range"], json!([1, 3])); + assert_eq!(text(&s, sid), "T´a"); + let size_at = |s: &Session, b| s.doc().unwrap().doc.story(designcraft_doc::StoryId(sid)).unwrap().char_format_at(b).over.size; + assert_eq!(size_at(&s, 2), Some(30.0), "the accent is shown in the format chosen at the caret"); + assert_eq!(s.composition().map(|c| c.2), Some(1..3)); + s.execute("text.compose", &json!({"text": "¨"})).unwrap(); + assert_eq!(text(&s, sid), "T¨a", "a new composition replaces the last one"); + s.execute("text.compose", &json!({"text": ""})).unwrap(); + assert_eq!(text(&s, sid), "Ta"); + assert!(s.composition().is_none()); + s.execute("text.insert", &json!({"text": "í"})).unwrap(); + assert_eq!(text(&s, sid), "Tía"); + assert_eq!(size_at(&s, 2), Some(30.0), "the typing format survived the composition"); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&s, sid), "Ta"); + } + + /// Anything else that edits, a tool change or a drag drops the uncommitted accent. + #[test] + fn other_edits_drop_the_composed_text() { + for act in 0..3 { + let (mut s, sid) = session(); + s.execute("text.compose", &json!({"text": "´"})).unwrap(); + match act { + 0 => { + s.execute("text.move", &json!({"dir": "right"})).unwrap(); + } + 1 => s.set_tool("selection"), + _ => s.begin_interaction("Move").unwrap(), + } + assert_eq!(text(&s, sid), "Ta", "case {act}"); + assert!(s.composition().is_none()); + } + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 900bd886..537f2f41 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -278,6 +278,9 @@ pub struct Session { pub(crate) text_drag: bool, /// Character formatting chosen at a bare caret, for the text typed there next. pub(crate) typing_format: Option, + /// Text an input method is composing (a dead key's accent), shown in the story as an + /// interaction preview until it commits: (document uid, byte length before the caret). + pub(crate) composition: Option<(u64, usize)>, /// The open Object Library (File › New / Open Library). pub library: Option, /// The open book (File › Open Book). @@ -310,6 +313,7 @@ impl Session { transforms: Default::default(), text_drag: false, typing_format: None, + composition: None, library: None, conveyor: Vec::new(), book: None, @@ -405,6 +409,10 @@ impl Session { fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result { let spec = find_command(id).ok_or_else(|| EngineError::UnknownCommand(id.into()))?; + // Text still being composed isn't part of the story: any other edit drops it first. + if id != "text.compose" && (spec.undoable || spec.journal) { + self.end_composition(); + } if let Err(e) = (spec.enabled)(self) { // A command that takes `ids` acts on the objects the call names, so it needs no // selection: with a text caret or nothing selected, `{"ids": [5]}` still runs. diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 1bf8db93..98868b7d 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -2,6 +2,7 @@ use std::sync::Arc; +use designcraft_doc::{CellAddr, StoryId}; use designcraft_geom::Unit; pub use designcraft_tools::SnapView; use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey}; @@ -58,6 +59,7 @@ impl Session { if self.tool.id() == id { return; } + self.end_composition(); self.tool = designcraft_tools::create(id); // Leaving the Type tool keeps the text frame selected. if id != "type" @@ -143,7 +145,29 @@ impl Session { Ok(()) } + /// The text an input method is composing at the caret, as story bytes: it is in the + /// document as a preview until `text.compose` commits or drops it. + pub fn composition(&self) -> Option<(StoryId, Option, std::ops::Range)> { + let (uid, len) = self.composition?; + let st = self.active().filter(|st| st.uid == uid)?; + let t = st.selection.text?; + Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus)) + } + + /// Drop the text being composed: the document goes back to how it was before it. + pub fn end_composition(&mut self) { + let Some((uid, _)) = self.composition.take() else { return }; + if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid) + && let Some(it) = st.interaction.take() + { + st.doc = it.doc; + st.selection = it.selection; + st.revision += 1; + } + } + pub fn begin_interaction(&mut self, label: &str) -> Result<()> { + self.end_composition(); let st = self.doc_mut()?; st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None }); Ok(()) diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 5d8218b7..7a50c15b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -991,7 +991,11 @@ fn draw_text_selection( cells: Option, ) { let cs = app.session.cache.get(doc, ts.story, None); - let range = ts.range(); + // Text an input method is composing at the caret is marked like it is in every Mac text + // field: a yellow box behind the glyphs. + let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2); + let range = composing.clone().unwrap_or_else(|| ts.range()); + let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.7) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) }; // Selected table cells: tint every selected cell. if let Some(sel) = cells.filter(|c| c.story == ts.story) { let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default(); @@ -1044,7 +1048,7 @@ fn draw_text_selection( let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64); let poly = [Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p))); - painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE)); } } } @@ -1080,7 +1084,7 @@ fn draw_text_selection( } } if !runs.is_empty() { - draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs); + draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some()); } if ts.is_caret() && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus) @@ -1088,22 +1092,38 @@ fn draw_text_selection( && let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) { let m = a * doc.text_xf(it); + // Type on a path: the caret stands across the path where it is along it. + let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); + let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); + let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - // Type on a path: the caret stands across the path where it is along it. - let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p)); - let p0 = xf.to_screen(m * on(Point::new(x, bl - asc))); - let p1 = xf.to_screen(m * on(Point::new(x, bl + desc))); painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } + remember_ime_caret(painter, p0, p1); painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } } +/// Where the text caret stood on screen last frame: the OS puts its input-method window there. +fn ime_caret_id() -> egui::Id { + egui::Id::new("canvas_ime_caret") +} + +/// The input method is composing text (sent to `text.compose`) that it hasn't committed. +fn ime_composing_id() -> egui::Id { + egui::Id::new("canvas_ime_composing") +} + +/// Remembers the caret at `p0`–`p1` for the input method's window. +fn remember_ime_caret(painter: &egui::Painter, p0: egui::Pos2, p1: egui::Pos2) { + painter.ctx().data_mut(|d| d.insert_temp(ime_caret_id(), egui::Rect::from_two_pos(p0, p1))); +} + /// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin). #[allow(clippy::too_many_arguments)] fn draw_cell_selection( - _app: &DesignApp, + app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, @@ -1121,7 +1141,9 @@ fn draw_cell_selection( let Some(ft) = cs.frames.get(fi) else { return }; let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return }; let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2()); - let range = ts.range(); + let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2); + let range = composing.clone().unwrap_or_else(|| ts.range()); + let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.6) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) }; if !range.is_empty() && let Some(cft) = text.frames.first() { @@ -1134,7 +1156,7 @@ fn draw_cell_selection( let past = e == l.range.end && range.end > l.range.end; for q in compose::highlight_quads(l, s, e, past) { let poly = q.map(|p| xf.to_screen(m * p)); - painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE)); } } } @@ -1145,20 +1167,25 @@ fn draw_cell_selection( } { let m = a * doc.text_xf(it); + let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))); let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0; if blink { - painter.line_segment( - [xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))], - Stroke::new(1.0, Color32::BLACK), - ); + painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK)); } + remember_ime_caret(painter, p0, p1); painter.ctx().request_repaint_after(std::time::Duration::from_millis(330)); } } +/// The box behind text an input method is composing. +const COMPOSING_FILL: Color32 = Color32::from_rgb(255, 247, 153); + /// InDesign draws selected text inverted: a black highlight with the selected glyphs in the /// inverse of their colour (white for black text). The highlight and glyph outlines are /// rasterised with `vello_cpu` into a small screen-space texture over the selection. +/// `composing`: the text is an input method's, still being composed: a yellow box with the +/// glyphs in their own colour. +#[allow(clippy::too_many_arguments)] fn draw_inverse_highlight( app: &DesignApp, painter: &egui::Painter, @@ -1167,6 +1194,7 @@ fn draw_inverse_highlight( cs: &compose::ComposedStory, story: designcraft_doc::StoryId, runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)], + composing: bool, ) { use designcraft_render::vello_cpu::{self, kurbo, peniko}; let ctx = painter.ctx(); @@ -1193,7 +1221,7 @@ fn draw_inverse_highlight( let Some(st) = app.session.active() else { return }; let key = egui::Id::new(("inverse_sel", st.uid, st.revision, std::sync::Arc::as_ptr(&st.doc) as usize, story.0)) .with((runs.iter().map(|r| r.2.len() + r.3.len() * 7919).sum::(), w, h)) - .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)))); + .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)), composing)); let cache_id = egui::Id::new("inverse_sel_tex"); let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id)); let tex = match cached { @@ -1201,7 +1229,8 @@ fn draw_inverse_highlight( _ => { let view = xf.affine(ppp as f64, bbox.min); let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() }); - rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); + let [br, bg, bb, _] = if composing { COMPOSING_FILL.to_array() } else { [0, 0, 0, 255] }; + rc.set_paint(peniko::Color::from_rgba8(br, bg, bb, 255)); for (_, m, quads, _) in runs { rc.set_transform(view * *m); for q in quads { @@ -1222,8 +1251,12 @@ fn draw_inverse_highlight( let Some(g) = l.glyphs.get(gi) else { continue }; let Some(style) = cs.styles.get(g.style as usize) else { continue }; let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue }; - let [r, gg, b, _] = c.to_rgba8(1.0); - rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + let [r, gg, b, a] = c.to_rgba8(1.0); + if composing { + rc.set_paint(peniko::Color::from_rgba8(r, gg, b, a)); + } else { + rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255)); + } let outline = db.outline(&g.face, g.gid); let skew = if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY }; @@ -1533,12 +1566,41 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { return; } + // The engine drops composed text when something else edits or the caret moves; the input + // method must forget it too, or the next key would finish an accent that is gone. + let composing = ui.data(|d| d.get_temp::(ime_composing_id())).unwrap_or(false); + let interrupt = composing && app.session.composition().is_none(); + if interrupt || !wants_text { + ui.data_mut(|d| d.remove::(ime_composing_id())); + } + if wants_text { + // Turn the OS input method on: macOS composes dead keys (⌥E then I types í) and + // Japanese/Chinese input only for a window that asks for it. Without it ⌥E typed "e". + let cursor_rect = ui.data(|d| d.get_temp(ime_caret_id())).unwrap_or(egui::Rect::from_min_size(rect.min, egui::Vec2::ZERO)); + ui.ctx().output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: interrupt }); + }); + } let evs = ui.input(|i| i.events.clone()); for e in evs { match e { egui::Event::Text(t) if wants_text => { let _ = app.run("text.insert", json!({"text": t})); } + // What is composed shows in the story, in the text's own format, until it commits. + egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if wants_text => { + let on = !text.is_empty(); + if app.run("text.compose", json!({"text": text})).is_ok() { + ui.data_mut(|d| d.insert_temp(ime_composing_id(), on)); + } + } + egui::Event::Ime(egui::ImeEvent::Commit(t)) if wants_text => { + ui.data_mut(|d| d.remove::(ime_composing_id())); + let _ = app.run("text.compose", json!({"text": ""})); + if !t.is_empty() { + let _ = app.run("text.insert", json!({"text": t})); + } + } egui::Event::Paste(t) if wants_text => { // Formatted when the system clipboard still holds what was copied here; // Shift (⇧⌘V) pastes without formatting. @@ -2088,6 +2150,37 @@ mod tests { assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h)); } + /// ⌥E then I types "í" on a Mac: the input method composes the dead key's accent, which + /// it does only for a window that turned it on. The canvas never did, so ⌥E typed "e". + #[test] + fn dead_keys_compose_through_the_input_method() { + let (mut h, id) = typing("T"); + h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [390.0, 80.0]})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.is_some(), "a text caret turns the input method on"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "´".into(), active_range_chars: None })); + h.run_steps(2); + let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.stories.values().map(|s| s.text.clone()).collect::(); + assert_eq!(text(&h), "T´", "the accent being composed shows in the story, in its format"); + assert!(h.state().session.composition().is_some(), "and is marked as composed, not typed"); + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None })); + h.event(egui::Event::Ime(egui::ImeEvent::Commit("í".into()))); + h.run_steps(2); + assert_eq!(text(&h), "Tí"); + assert!(h.state().session.composition().is_none()); + // Moving the caret mid-composition drops the accent and tells the input method so. + h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "˜".into(), active_range_chars: None })); + h.run_steps(2); + h.state_mut().run("text.move", json!({"dir": "left"})).unwrap(); + h.step(); + assert_eq!(text(&h), "Tí"); + assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition)); + // Another tool takes no text: the input method is off. + h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap(); + h.run_steps(2); + assert!(h.output().platform_output.ime.is_none()); + } + #[test] fn rulers_open_no_units_menu_without_a_document() { let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()));