diff --git a/Cargo.lock b/Cargo.lock index 6b735c5d7..fffbb49f3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -905,6 +905,8 @@ dependencies = [ "egui", "log", "muda", + "objc2 0.6.4", + "objc2-app-kit 0.3.2", "rfd", "serde", "serde_json", diff --git a/ROADMAP.md b/ROADMAP.md index 13e2ece93..0333c63f1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -303,14 +303,14 @@ Interface strings translated, of 1,724 measured. Detail: [docs/localization-pari | Language | Code | Status | Translated | |---|---|---|---| | English | en | full | 100% | -| Simplified Chinese | zh | partial (no canvas IME) | 83% | +| Simplified Chinese | zh | partial | 83% | | Spanish | es | partial | 83% | | Hindi | hi | none | 0% | | Arabic | ar | partial (RTL UI and composition) | 85% | | French | fr | partial | 83% | | Portuguese (Brazil) | pt-br | partial | 61% | | Indonesian | id | none | 0% | -| Japanese | ja | partial (no canvas IME) | 95% | +| Japanese | ja | partial | 95% | | German | de | partial | 83% | | Korean | ko | none | 0% | | Vietnamese | vi | none | 0% | @@ -356,6 +356,7 @@ Newest first. - **2026-10-15** · The Typography panel controls every OpenType feature a character format carries: Other Features lists Proportional Metrics, Horizontal Kana, Localized Forms, Mark Positioning, Justification Alternates and Roman Italics. Lining, Oldstyle, Tabular or Proportional chosen alone also sets the other figure axis, so IDML export keeps the figure style instead of writing Default. Standard Ligatures follows and sets Character › Ligatures. No readiness percentage change. - **2026-10-15** · Join Path and Convert Point leave a path on a locked layer or inside a locked group unchanged, as the other path edits do. No readiness percentage change. - **2026-10-15** · A kerning value chosen in the Character panel or Properties at a caret kerns the pair at the caret (the character before it takes the value, in one undo step), and the field shows that pair's kerning; at the start of a story or paragraph it is kept for the next typing, and a selection is kerned as before (`type.kernPair`). No readiness percentage change. +- **2026-10-15** · Japanese, Chinese and Korean input methods type into stories with the Type tool: the marked text shows in place, underlined (the clause being converted twice), with the candidate window at the caret, and the commit is one undo step. Keys, shortcuts, the clipboard and Undo wait while the input method composes; a click, a tool switch or another command keeps the marked text as typed. Not yet checked by hand with a system input method. No readiness percentage change. - **2026-10-14** · The Type tool draws a new text frame over a locked empty frame, as InDesign does, instead of turning the locked frame into a text frame. No readiness percentage change. - **2026-10-14** · 100% black shows as pure black on screen by default, as InDesign's Appearance of Black does out of the box (Display All Blacks as Rich Black); Preferences › Appearance of Black › Display All Blacks Accurately still shows it as the dark grey it prints, and a saved choice is kept. No readiness percentage change. - **2026-10-14** · Pathfinder keeps the frontmost object, as InDesign does: a photo placed over shapes and intersected with them shows through the shapes (it was dropped, and the shapes kept their black fill); Add, Exclude Overlap and Minus Back take the front object's fill too, Subtract still keeps the back one. No readiness percentage change. diff --git a/apps/designcraft-web/src/web.rs b/apps/designcraft-web/src/web.rs index c532b5dca..dbe0c982e 100644 --- a/apps/designcraft-web/src/web.rs +++ b/apps/designcraft-web/src/web.rs @@ -373,7 +373,7 @@ impl eframe::App for WebShell { fn raw_input_hook(&mut self, ctx: &egui::Context, raw: &mut egui::RawInput) { let _ = self.graphics.run_if_healthy(|| { - designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused()); + designcraft_ui_egui::drop_key_name_text(raw, ctx.text_edit_focused() || self.app.canvas_ime()); self.app.raw_input_hook(raw); }); } diff --git a/apps/designcraft/Cargo.toml b/apps/designcraft/Cargo.toml index 0f08ae8ed..176bcf8ad 100644 --- a/apps/designcraft/Cargo.toml +++ b/apps/designcraft/Cargo.toml @@ -35,3 +35,6 @@ winit = { version = "0.30", default-features = false, features = ["x11"] } # The same winit as eframe; only the native termination opt-in is used. winit = { version = "0.30", default-features = false } muda = { version = "0.21", default-features = false } +# The input context, to drop the IME's marked text when the Type tool ends a composition. +objc2 = "0.6" +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSTextInputContext"] } diff --git a/apps/designcraft/src/main.rs b/apps/designcraft/src/main.rs index 2f90f6484..d4830babd 100644 --- a/apps/designcraft/src/main.rs +++ b/apps/designcraft/src/main.rs @@ -58,6 +58,10 @@ impl eframe::App for App { } fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { self.app.ui(ui); + #[cfg(target_os = "macos")] + if self.app.take_ime_discard() { + discard_marked_text(); + } } fn on_exit(&mut self) { // A normal exit before the third frame is not a crash. @@ -72,6 +76,17 @@ impl eframe::App for App { } } +/// Tell the macOS input method to drop its composition (the Type tool kept the marked text as +/// typed). winit's IME toggle only clears its own copy, so the IME would type it again. +#[cfg(target_os = "macos")] +fn discard_marked_text() { + if let Some(mtm) = objc2::MainThreadMarker::new() + && let Some(ic) = objc2_app_kit::NSTextInputContext::currentInputContext(mtm) + { + ic.discardMarkedText(); + } +} + /// Orderly OS termination can bypass egui's cancelable close event. Finish only a complete, /// current quit plan; otherwise preserve every live document, including edits after the timer. fn preserve_documents_on_exit(app: &mut DesignApp) -> Result<(), String> { diff --git a/crates/engine/src/cmd/datamerge/mod.rs b/crates/engine/src/cmd/datamerge/mod.rs index 9015187eb..64a5546ea 100644 --- a/crates/engine/src/cmd/datamerge/mod.rs +++ b/crates/engine/src/cmd/datamerge/mod.rs @@ -204,7 +204,7 @@ fn preview_cmd(s: &mut Session, p: &Value) -> Result { fill_row(&mut doc, &source.fields, &row, record as u32, &options, &maps, data_dir.as_deref(), doc_dir.as_deref()); st.doc = Arc::new(doc); st.preview_record = Some(record as u32); - st.revision = st.revision.saturating_add(1); + st.bump_revision(); Ok(json!({"record": record})) } @@ -716,7 +716,7 @@ fn remember_source_status(s: &mut Session, status: SourceStatus) { return; } st.doc = Arc::new(doc); - st.revision = st.revision.saturating_add(1); + st.bump_revision(); } fn load_inline(p: &Value) -> std::result::Result<(Table, Option), String> { diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index a6fc061ba..5d46b98dd 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -174,7 +174,7 @@ pub fn specs() -> Vec { st.history.redo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); st.doc = e.doc; st.selection = e.selection; - st.revision += 1; + st.bump_revision(); // Pending typing overrides belong to the current edit state, not a restored snapshot. s.typing_format = None; Ok(json!({"undone": e.label})) @@ -185,7 +185,7 @@ pub fn specs() -> Vec { st.history.undo.push(HistoryEntry { label: e.label.clone(), doc: st.doc.clone(), selection: st.selection.clone() }); st.doc = e.doc; st.selection = e.selection; - st.revision += 1; + st.bump_revision(); s.typing_format = None; Ok(json!({"redone": e.label})) }), @@ -206,7 +206,7 @@ pub fn specs() -> Vec { } st.selection.text = None; st.selection.cells = None; - st.revision += 1; + st.bump_revision(); ok() }), cmd!(noundo "selection.toggle", "Toggle Selection", [], None, "{id}", has_doc, |s, p| { @@ -219,7 +219,7 @@ pub fn specs() -> Vec { } else { st.selection.items.push(id); } - st.revision += 1; + st.bump_revision(); ok() }), cmd!(noundo "edit.selectAll", "Select All", ["Edit"], Some("Cmd+A"), "{} — all items on the active spreads, or all text in the story", has_doc, |s, _| { @@ -243,13 +243,13 @@ pub fn specs() -> Vec { } st.selection = Selection::items(ids); } - st.revision += 1; + st.bump_revision(); ok() }), cmd!(noundo "edit.deselectAll", "Deselect All", ["Edit"], Some("Cmd+Shift+A"), "{}", has_doc, |s, _| { let st = s.doc_mut()?; st.selection = Selection::default(); - st.revision += 1; + st.bump_revision(); ok() }), cmd!("edit.clear", "Clear", ["Edit"], Some("Delete"), "{ids?} — delete selected items (or the selected text)", has_doc, |s, p| { diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index f63f0d6e9..8467f773d 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -276,7 +276,7 @@ fn file_save(s: &mut Session, p: &Value) -> Result { crate::io::write_file(&path, &bytes).map_err(EngineError::Other)?; if paths_changed { st.doc = saved; - st.revision = st.revision.saturating_add(1); + st.bump_revision(); } st.path = Some(path.clone()); st.saved_revision = st.revision; @@ -722,7 +722,7 @@ fn file_revert(s: &mut Session, _: &Value) -> Result { let mut fresh = DocState::new(d, Some(path.clone())); fresh.fonts = fonts; fresh.uid = uid; - fresh.revision = s.doc()?.revision + 1; + // A new state has a revision no state had before, so caches don't show the old document. crate::recovery::discard_document(s, index, recovery_dir.as_deref()); *s.doc_mut()? = fresh; Ok(json!({"path": path})) diff --git a/crates/engine/src/cmd/find.rs b/crates/engine/src/cmd/find.rs index f57845bab..83d137248 100644 --- a/crates/engine/src/cmd/find.rs +++ b/crates/engine/src/cmd/find.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { if p.get("select").and_then(Value::as_bool).unwrap_or(true) && !ids.is_empty() { let st = s.doc_mut()?; st.selection = designcraft_doc::Selection::items(ids.clone()); - st.revision += 1; + st.bump_revision(); } Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) }), @@ -424,7 +424,7 @@ fn find_next(s: &mut Session, p: &Value) -> Result { let frame = st.doc.story(sid).and_then(|x| x.frames.first().copied()); let stm = s.doc_mut()?; stm.selection = Selection::text(TextSel { story: sid, anchor: a, focus: b, frame, cell: None }); - stm.revision += 1; + stm.bump_revision(); context.acknowledge(stm); s.find_context = Some(context); Ok(json!({"story": sid.0, "start": a, "end": b})) @@ -837,7 +837,7 @@ mod tests { s.execute("text.selectWord", &json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); assert!(!s.text_drag, "explicit word reselection does not start a text drag"); assert_eq!(s.doc().unwrap().selection, selected, "same full selection, including frame"); - assert_eq!(s.doc().unwrap().revision, revision + 1); + assert!(s.doc().unwrap().revision > revision, "a new revision"); assert!(std::sync::Arc::ptr_eq(&before, &s.doc().unwrap().doc)); assert_eq!(s.doc().unwrap().history.undo.len(), history); assert!(s.find_context.is_none()); @@ -916,7 +916,7 @@ mod tests { frame: None, cell: None, }); - st.revision += 1; + st.bump_revision(); for command in ["find.find", "find.next", "find.change"] { let before = s.doc().unwrap().doc.clone(); let err = s.execute(command, &json!({"find": "x", "change": "y", "scope": "selection"})).unwrap_err(); @@ -976,7 +976,7 @@ mod tests { // and would itself restore the template before selecting. let st = s.doc_mut().unwrap(); st.selection = designcraft_doc::Selection::text(designcraft_doc::TextSel { story: sid, anchor: 0, focus: 7, frame: None, cell: None }); - st.revision += 1; + st.bump_revision(); let p = json!({"find": "red", "change": "blue", "scope": "selection"}); assert_eq!(s.execute("find.find", &p).unwrap().as_array().unwrap().len(), 2); let history = s.doc().unwrap().history.undo.len(); diff --git a/crates/engine/src/cmd/interactive.rs b/crates/engine/src/cmd/interactive.rs index 276b0211b..32562737c 100644 --- a/crates/engine/src/cmd/interactive.rs +++ b/crates/engine/src/cmd/interactive.rs @@ -51,7 +51,7 @@ pub fn specs() -> Vec { designcraft_doc::Selection::text(designcraft_doc::TextSel { story, anchor: start, focus: end, frame, cell: None }) } }; - st.revision += 1; + st.bump_revision(); Ok(Value::Null) }), cmd!("bookmark.rename", "Rename Bookmark", [], None, "{index, name}", has_doc, |s, p| { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index fe06ad3ee..489fc634e 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -250,7 +250,7 @@ pub fn specs() -> Vec { let st = s.doc_mut()?; st.editing_parents = p.get("on").and_then(Value::as_bool).unwrap_or(!st.editing_parents); st.selection = Default::default(); - st.revision += 1; + st.bump_revision(); ok() }), cmd!( @@ -499,7 +499,7 @@ pub fn specs() -> Vec { st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; - st.revision += 1; + st.bump_revision(); Ok(json!({"page": page.map(|p| p + 1)})) } ), @@ -578,7 +578,7 @@ pub fn specs() -> Vec { let st = s.doc_mut()?; let ids: Vec<_> = st.doc.spreads.iter().flat_map(|sp| sp.items.iter().filter(|i| i.layer == id && !i.locked).map(|i| i.id)).collect(); st.selection = designcraft_doc::Selection::items(ids); - st.revision += 1; + st.bump_revision(); ok() }), cmd!("layer.new", "New Layer…", [], None, "{name?}", has_doc, |s, p| { @@ -733,7 +733,7 @@ pub fn specs() -> Vec { return Err(bad("layer.activate", "no such layer")); } st.active_layer = id; - st.revision += 1; + st.bump_revision(); ok() }), ] diff --git a/crates/engine/src/cmd/links.rs b/crates/engine/src/cmd/links.rs index 9db6c6cb8..3b6eda5b6 100644 --- a/crates/engine/src/cmd/links.rs +++ b/crates/engine/src/cmd/links.rs @@ -81,7 +81,7 @@ pub fn specs() -> Vec { return Err(bad("links.goTo", "the graphic isn't placed")); } st.selection = Selection::items(ids.clone()); - st.revision += 1; + st.bump_revision(); Ok(json!({"ids": ids.iter().map(|i| i.0).collect::>()})) }), ] diff --git a/crates/engine/src/cmd/notes.rs b/crates/engine/src/cmd/notes.rs index e3b3ccc52..bdb273798 100644 --- a/crates/engine/src/cmd/notes.rs +++ b/crates/engine/src/cmd/notes.rs @@ -161,7 +161,7 @@ fn go_to_reference(s: &mut Session, _: &Value) -> Result { let at = st.doc.story(t.story).and_then(|x| x.note_anchor(id)).ok_or_else(|| bad("footnote.goToReference", "footnote not found"))?; let pos = at + designcraft_doc::FOOTNOTE_REF.len_utf8(); st.selection.text = Some(TextSel { anchor: pos, focus: pos, cell: None, ..t }); - st.revision += 1; + st.bump_revision(); Ok(json!({"story": t.story.0, "pos": pos})) } diff --git a/crates/engine/src/cmd/scripts.rs b/crates/engine/src/cmd/scripts.rs index 5b6d288aa..1719f5e3e 100644 --- a/crates/engine/src/cmd/scripts.rs +++ b/crates/engine/src/cmd/scripts.rs @@ -23,7 +23,7 @@ fn run(s: &mut crate::Session, p: &Value) -> Result { st.doc = Arc::clone(doc); st.selection = selection.clone(); st.history = history.clone(); - st.revision = st.revision.saturating_add(1); + st.bump_revision(); } else if st.history.undo.len() > history.undo.len() { st.history.undo.truncate(history.undo.len()); } diff --git a/crates/engine/src/cmd/select.rs b/crates/engine/src/cmd/select.rs index 0d09ac869..0f77d5372 100644 --- a/crates/engine/src/cmd/select.rs +++ b/crates/engine/src/cmd/select.rs @@ -72,7 +72,7 @@ fn select_stacked(s: &mut Session, how: Step) -> Result { (Step::Down, Some(i)) => i.saturating_sub(1), }; st.selection = Selection::items(vec![order[i]]); - st.revision += 1; + st.bump_revision(); Ok(json!({"id": order[i].0})) } @@ -90,7 +90,7 @@ fn select_container(s: &mut Session) -> Result { } else { return ok(); } - st.revision += 1; + st.bump_revision(); Ok(json!({"id": st.selection.items[0].0})) } @@ -105,7 +105,7 @@ fn select_content(s: &mut Session) -> Result { } else { return ok(); } - st.revision += 1; + st.bump_revision(); Ok(json!({"id": st.selection.items[0].0})) } @@ -125,7 +125,7 @@ fn select_sibling(s: &mut Session, dir: isize) -> Result { let j = (i as isize - dir).rem_euclid(n) as usize; let Some(next) = kids.get(j).map(|k| k.id) else { return ok() }; st.selection = Selection::items(vec![next]); - st.revision += 1; + st.bump_revision(); Ok(json!({"id": next.0})) } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 4b2962b4e..a3e60eac6 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -975,7 +975,7 @@ fn select(s: &mut Session, p: &Value) -> Result { text: Some(TextSel { story: g.story, anchor: 0, focus: 0, frame, cell: Some(CellAddr { table: g.table, row: r.r0, col: r.c0 }) }), ..Default::default() }; - st.revision += 1; + st.bump_revision(); Ok(json!({"story": g.story.0, "table": g.table, "range": r})) } @@ -1007,7 +1007,7 @@ pub(crate) fn step_cell(s: &mut Session, forward: bool) -> Result { let st = s.doc_mut()?; let len = st.doc.text_story(t.story, Some(CellAddr { table: c.table, row, col })).map_or(0, |x| x.len()); st.selection = Selection::text(TextSel { anchor: 0, focus: len, cell: Some(CellAddr { table: c.table, row, col }), ..t }); - st.revision += 1; + st.bump_revision(); Ok(json!({"row": row, "col": col})) } diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1bdc92bbf..2b34bb484 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -50,23 +50,11 @@ pub fn specs() -> Vec { let a = floor_char_boundary(text, p.get("anchor").and_then(Value::as_u64).unwrap_or(0) as usize); let f = floor_char_boundary(text, p.get("focus").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(a)); st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: f, frame: None, cell: None }); - st.revision += 1; + st.bump_revision(); s.typing_format = None; 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, _| { @@ -76,7 +64,7 @@ pub fn specs() -> Vec { st.selection.items = f.into_iter().collect(); } st.selection.cells = None; - st.revision += 1; + st.bump_revision(); ok() }), cmd!( @@ -397,7 +385,7 @@ fn select_at(s: &mut Session, p: &Value, unit: &str) -> Result { let st = s.doc_mut()?; st.selection = Selection::text(TextSel { story: sid, anchor: a, focus: e, frame: Some(frame), cell }); // Explicit reselection invalidates the Find scope even when the range is unchanged. - st.revision = st.revision.wrapping_add(1); + st.bump_revision(); Ok(json!({"story": sid.0, "anchor": a, "focus": e})) } @@ -483,7 +471,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { { let range = designcraft_doc::CellRange::new(a.row, a.col, c.row, c.col); st.selection.cells = Some(designcraft_doc::TableSel { story: sid, table: c.table, range }); - st.revision += 1; + st.bump_revision(); return Ok(json!({"story": sid.0, "cells": range})); } let t = match (extend, st.selection.text) { @@ -491,7 +479,7 @@ fn place(s: &mut Session, p: &Value, extend: bool) -> Result { _ => TextSel { story: sid, anchor: b, focus: b, frame: Some(frame), cell }, }; st.selection = Selection::text(t); - st.revision += 1; + st.bump_revision(); Ok(json!({"story": sid.0, "pos": b, "cell": cell})) } @@ -529,30 +517,6 @@ 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`). @@ -643,7 +607,8 @@ fn insert(s: &mut Session, p: &Value) -> Result { sel.text = Some(TextSel { anchor: pos, focus: pos, ..t }); Ok(json!({"pos": pos})) }); - if result.is_ok() { + // A preview (IME marked text) leaves the typing format to the text typed in the end. + if result.is_ok() && s.active().is_none_or(|d| d.interaction.is_none()) { s.typing_format = None; } result @@ -834,7 +799,7 @@ fn select_around(s: &mut Session, p: &Value, id: &str, bounds: fn(&str, usize) - let text = st.doc.text_story(sid, cell).map(|x| x.text.clone()).unwrap_or_default(); let (anchor, focus) = bounds(&text, floor_char_boundary(&text, b)); st.selection = Selection::text(TextSel { story: sid, anchor, focus, frame: Some(frame), cell }); - st.revision += 1; + st.bump_revision(); Ok(json!({"story": sid.0, "anchor": anchor, "focus": focus})) } @@ -962,7 +927,7 @@ fn move_caret(s: &mut Session, p: &Value) -> Result { s.typing_format = None; let stm = s.doc_mut()?; stm.selection.text = Some(if extend { TextSel { focus: new, ..t } } else { TextSel { anchor: new, focus: new, ..t } }); - stm.revision += 1; + stm.bump_revision(); Ok(json!({"pos": new})) } @@ -3075,70 +3040,6 @@ mod gradient_text_tests { } } -#[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()); - } - } -} - #[cfg(test)] mod placeholder_tests { use serde_json::json; diff --git a/crates/engine/src/guard.rs b/crates/engine/src/guard.rs index 74e16b882..92ef1332f 100644 --- a/crates/engine/src/guard.rs +++ b/crates/engine/src/guard.rs @@ -39,7 +39,6 @@ impl Session { // selection, which one is active, and the journal (nested commands journal too). let docs = self.docs.clone(); let active = self.active; - let revision = self.active().map_or(0, |d| d.revision); let journal = self.journal.len(); match catch_unwind(AssertUnwindSafe(|| f(self))) { Ok(r) => r, @@ -53,7 +52,7 @@ impl Session { self.journal.truncate(journal); if let Some(st) = self.active_mut() { st.interaction = None; - st.revision = revision.max(st.revision).saturating_add(1); + st.bump_revision(); } if let Some(dir) = self.recovery_dir.clone() && let Err(e) = crate::recovery::save(self, &dir) diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 745e45647..8b770aceb 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -78,6 +78,8 @@ pub struct Interaction { pub doc: Arc, pub selection: Selection, pub preview: Option<(String, Value)>, + /// The document's revision when the gesture began: a cancel puts it back. + pub revision: u64, } #[derive(Clone, Debug)] @@ -86,6 +88,8 @@ pub struct DocState { pub selection: Selection, pub history: History, pub path: Option, + /// Names the document state for caches keyed `(uid, revision)` (see + /// [`DocState::bump_revision`]). pub revision: u64, pub saved_revision: u64, /// The document as last saved (dirty = the current document is a different allocation). @@ -129,19 +133,25 @@ impl Drop for FontScope { } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); +static NEXT_REVISION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); + +fn next_revision() -> u64 { + NEXT_REVISION.fetch_add(1, std::sync::atomic::Ordering::Relaxed) +} impl DocState { pub fn new(doc: Document, path: Option) -> Self { let active_layer = doc.default_layer(); let doc = Arc::new(doc); + let revision = next_revision(); DocState { saved_doc: doc.clone(), doc, selection: Selection::default(), history: History { limit: 1000, ..Default::default() }, path, - revision: 1, - saved_revision: 1, + revision, + saved_revision: revision, active_layer, interaction: None, editing_parents: false, @@ -152,6 +162,12 @@ impl DocState { fonts: None, } } + /// The document changed: it gets a revision no document state had before. A cancelled + /// gesture puts back the revision it began at, so the numbers its previews used are never + /// handed out again (a cache entry for one would show the cancelled preview). + pub fn bump_revision(&mut self) { + self.revision = next_revision(); + } pub fn is_dirty(&self) -> bool { !Arc::ptr_eq(&self.doc, &self.saved_doc) } @@ -295,9 +311,6 @@ 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). @@ -335,7 +348,6 @@ impl Session { transforms: Default::default(), text_drag: false, typing_format: None, - composition: None, library: None, conveyor: Vec::new(), book: None, @@ -400,12 +412,19 @@ impl Session { pub fn set_active(&mut self, i: usize) { if i < self.docs.len() && self.active != Some(i) { + // The composition belongs to the document it shows in. + if let Err(e) = self.end_composition() { + log::warn!("ending the IME composition: {e}"); + } self.reset_find_scope(); self.stop_data_preview(); self.active = Some(i); } } pub fn add_document(&mut self, d: DocState) -> usize { + if let Err(e) = self.end_composition() { + log::warn!("ending the IME composition: {e}"); + } self.reset_find_scope(); self.stop_data_preview(); self.docs.push(d); @@ -418,7 +437,7 @@ impl Session { st.preview_record = None; let Some(stash) = st.preview_stash.take() else { return }; st.doc = stash; - st.revision = st.revision.saturating_add(1); + st.bump_revision(); } pub fn close_document(&mut self, i: usize) { if self.active == Some(i) { @@ -440,15 +459,17 @@ impl Session { /// Run command `id`. A panic inside it becomes [`EngineError::Internal`] and leaves the /// document as it was (see [`guard`]). pub fn execute(&mut self, id: &str, params: &Value) -> Result { + // A command from outside the IME ends its composition first, keeping the marked text as + // typed: Undo then takes back that typing, and nothing runs on top of the preview the + // composition would drop. Queries and recovery saves leave it alone. + if self.tool.composing() && id != "file.recovery.save" && find_command(id).is_some_and(|c| c.journal || c.undoable) { + self.end_composition()?; + } self.guarded(id, |s| s.execute_unguarded(id, params)) } 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. @@ -549,7 +570,7 @@ impl Session { } if let Some(st) = self.active_mut() { st.doc = Arc::new(d); - st.revision += 1; + st.bump_revision(); } } @@ -592,7 +613,7 @@ impl Session { doc.sync_endnote_story(); st.doc = Arc::new(doc); st.selection = sel; - st.revision += 1; + st.bump_revision(); self.reset_find_scope(); Ok(r) } @@ -615,6 +636,8 @@ mod tests_file_guard; #[cfg(test)] mod tests_idml; #[cfg(test)] +mod tests_ime; +#[cfg(test)] mod tests_roots; #[cfg(test)] mod tests_table; diff --git a/crates/engine/src/recovery.rs b/crates/engine/src/recovery.rs index ac1d8d346..46c21ceee 100644 --- a/crates/engine/src/recovery.rs +++ b/crates/engine/src/recovery.rs @@ -797,7 +797,7 @@ fn open_with_publisher(s: &mut Session, dir: &Path, mut publish: impl FnMut(&Pat st.fonts = fonts; // Unsaved: the copy on disk (if any) is older than what was recovered. st.saved_doc = std::sync::Arc::new((*st.doc).clone()); - st.revision += 1; + st.bump_revision(); let index = s.add_document(st); opened.push(index); // New uids are published without replacement, and the original is consumed only after diff --git a/crates/engine/src/tests_ime.rs b/crates/engine/src/tests_ime.rs new file mode 100644 index 000000000..266470914 --- /dev/null +++ b/crates/engine/src/tests_ime.rs @@ -0,0 +1,181 @@ +//! Input method (IME) composition in the Type tool: marked text previews in place, a commit is +//! one `text.insert`, a cancel leaves nothing behind, and keys wait for the IME. + +use designcraft_tools::{Mods, PointerEvent, PointerKind, ToolKey}; +use serde_json::json; + +use super::*; + +fn session() -> Session { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s +} + +/// A text frame holding `text`, with the Type tool's caret at its end; returns the story id. +fn typing_into(s: &mut Session, text: &str) -> u64 { + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + s.set_tool("type"); + if !text.is_empty() { + s.execute("text.insert", &json!({"text": text, "raw": true})).unwrap(); + } + r["story"].as_u64().unwrap() +} + +fn text(s: &mut Session, sid: u64) -> String { + s.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string() +} + +fn steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() +} + +#[test] +fn ime_composition_shows_in_place_and_commits_as_one_step() { + // Romaji → kana → conversion → commit, as the macOS Japanese IME sends it. + let mut s = session(); + let v = ViewInfo::default(); + let sid = typing_into(&mut s, "曲:"); + let (before, journal) = (steps(&s), s.journal.len()); + let mut carets = Vec::new(); + for (t, r) in [("g", 1..1), ("が", 1..1), ("がg", 2..2), ("ががく", 3..3)] { + s.tool_preedit(t, Some(r), v).unwrap(); + assert_eq!(text(&mut s, sid), format!("曲:{t}"), "marked text lays out in place"); + assert!(s.tool_composing()); + carets.push(s.tool_ime_caret(v).expect("the candidate window has a place")); + } + // The candidate window follows the IME's cursor along the line. + assert!(carets.windows(2).all(|w| w[1].0.x >= w[0].0.x), "{carets:?}"); + let (top, bottom) = carets[3]; + assert!((top.x - bottom.x).abs() < 1e-6 && top.y < bottom.y, "an upright caret line in horizontal text: {top:?} {bottom:?}"); + // Conversion: the window goes to the clause being converted, here the whole marked text. + s.tool_preedit("雅楽", Some(0..2), v).unwrap(); + assert_eq!(text(&mut s, sid), "曲:雅楽"); + assert!(s.tool_ime_caret(v).unwrap().0.x < carets[3].0.x); + let marks = s.overlays(v); + assert!(marks.len() >= 2, "the marked text is underlined, the clause twice: {marks:?}"); + assert_eq!((steps(&s), s.journal.len()), (before, journal), "composing records nothing"); + // The commit replaces the marked text: one undo step, one command. + s.tool_ime_commit("雅楽", v).unwrap(); + assert!(!s.tool_composing()); + assert_eq!(text(&mut s, sid), "曲:雅楽"); + assert_eq!(steps(&s), before + 1); + assert_eq!(s.journal[journal..], [("text.insert".to_string(), json!({"text": "雅楽"}))]); + assert_eq!(s.doc().unwrap().selection.text.map(|t| t.focus), Some("曲:雅楽".len())); + assert!(s.overlays(v).is_empty(), "no underline once typed"); + // macOS and Windows clear the marked text before they commit: the same result. + s.tool_preedit("おと", Some(2..2), v).unwrap(); + s.tool_preedit("", None, v).unwrap(); + s.tool_ime_commit("音", v).unwrap(); + assert_eq!(text(&mut s, sid), "曲:雅楽音"); + assert_eq!(steps(&s), before + 2); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(text(&mut s, sid), "曲:"); +} + +#[test] +fn ime_cancel_leaves_no_undo_step_and_keeps_the_text() { + let mut s = session(); + let v = ViewInfo::default(); + let sid = typing_into(&mut s, "雅楽"); + let before = steps(&s); + let doc = s.doc().unwrap().doc.clone(); + s.tool_preedit("えんそう", Some(4..4), v).unwrap(); + assert_eq!(text(&mut s, sid), "雅楽えんそう"); + // Escape in the IME: the marked text goes and the document is the one before. + s.tool_preedit("", None, v).unwrap(); + assert!(!s.tool_composing()); + assert!(s.doc().unwrap().interaction.is_none(), "no gesture left open"); + assert!(Arc::ptr_eq(&s.doc().unwrap().doc, &doc)); + assert_eq!(steps(&s), before); + // A stray clear (no composition) never deletes the selected text. + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 3})).unwrap(); + s.tool_preedit("", None, v).unwrap(); + assert_eq!(text(&mut s, sid), "雅楽"); + // A composition over a selection replaces it; cancelling it brings the selection back. + s.tool_preedit("が", Some(1..1), v).unwrap(); + assert_eq!(text(&mut s, sid), "が楽"); + s.tool_preedit("", None, v).unwrap(); + assert_eq!(text(&mut s, sid), "雅楽"); + assert_eq!(s.doc().unwrap().selection.text.map(|t| t.range()), Some(0..3)); + s.tool_preedit("が", Some(1..1), v).unwrap(); + s.tool_ime_commit("我", v).unwrap(); + assert_eq!(text(&mut s, sid), "我楽"); + assert_eq!(steps(&s), before + 1); +} + +#[test] +fn keys_belong_to_the_ime_while_it_composes() { + let mut s = session(); + let v = ViewInfo::default(); + let sid = typing_into(&mut s, ""); + s.tool_preedit("しょうこ", Some(4..4), v).unwrap(); + // Backspace, arrows, Enter and Escape edit the composition in the IME, not the story. + for k in [ToolKey::Backspace, ToolKey::Left, ToolKey::Enter, ToolKey::Escape] { + assert!(s.tool_key(k, Mods::default(), v).unwrap(), "{k:?} is the IME's"); + } + assert_eq!(text(&mut s, sid), "しょうこ"); + assert!(s.tool_composing()); + assert_eq!(s.tool_id(), "type", "Escape didn't leave the Type tool"); + // An Undo that comes anyway (a script, the control channel) ends the composition first: the + // marked text becomes typing, which Undo takes back. + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.tool_composing()); + assert_eq!(text(&mut s, sid), ""); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(text(&mut s, sid), "しょうこ"); + // A click keeps the marked text as typed. + s.tool_preedit("ひちりき", Some(4..4), v).unwrap(); + let at = s.layout().to_canvas(designcraft_doc::SpreadRef::Doc(0), designcraft_geom::Point::new(100.0, 150.0)); + s.pointer(&PointerEvent::new(PointerKind::Down, at.x, at.y), v).unwrap(); + s.pointer(&PointerEvent::new(PointerKind::Up, at.x, at.y), v).unwrap(); + assert!(!s.tool_composing()); + assert_eq!(text(&mut s, sid), "しょうこひちりき"); +} + +#[test] +fn an_ime_commit_takes_the_format_chosen_at_the_caret() { + let mut s = session(); + let v = ViewInfo::default(); + let sid = typing_into(&mut s, "A"); + s.execute("type.char", &json!({"attrs": {"size": 24}})).unwrap(); + s.tool_preedit("にほん", Some(3..3), v).unwrap(); + s.tool_ime_commit("日本", v).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 1, "focus": 7})).unwrap(); + assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["chars"]["size"], 24.0); +} + +#[test] +fn a_cancelled_gesture_puts_back_the_revision_and_leaves_a_saved_document_saved() { + let mut s = session(); + let v = ViewInfo::default(); + let sid = typing_into(&mut s, "雅"); + // As if just saved. + let st = s.doc_mut().unwrap(); + st.saved_doc = st.doc.clone(); + let before = st.revision; + let mut seen = vec![before]; + // A cancelled composition… + for t in ["が", "がく"] { + s.tool_preedit(t, None, v).unwrap(); + seen.push(s.doc().unwrap().revision); + } + s.tool_preedit("", None, v).unwrap(); + assert_eq!(s.doc().unwrap().revision, before); + assert!(!s.doc().unwrap().is_dirty()); + // …and any other cancelled gesture. + let preview = |t: &str| designcraft_tools::Action::Preview("text.insert".into(), json!({"text": t, "raw": true})); + s.run_actions(vec![designcraft_tools::Action::Begin("Type".into()), preview("x")]).unwrap(); + seen.push(s.doc().unwrap().revision); + s.run_actions(vec![preview("xy"), designcraft_tools::Action::Cancel]).unwrap(); + assert_eq!(s.doc().unwrap().revision, before); + assert!(!s.doc().unwrap().is_dirty()); + assert_eq!(text(&mut s, sid), "雅"); + // The next change gets a revision no preview had: caches keyed by revision never show a + // cancelled preview. + s.execute("text.insert", &json!({"text": "楽"})).unwrap(); + let after = s.doc().unwrap().revision; + assert!(!seen.contains(&after), "{after} in {seen:?}"); + assert!(s.doc().unwrap().is_dirty()); +} diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index 820c270f4..c4e3e6fbc 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -2,10 +2,9 @@ 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}; +use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, PointerKind, ToolContext, ToolKey}; use serde::Serialize; use serde_json::Value; @@ -61,7 +60,10 @@ impl Session { if self.tool.id() == id { return; } - self.end_composition(); + // A composition ends with the tool that shows it: its marked text stays as typed. + if let Err(e) = self.end_composition() { + log::warn!("ending the IME composition: {e}"); + } self.tool = designcraft_tools::create(id); // Any other tool unloads the place cursor. if id != "placeGun" { @@ -107,11 +109,20 @@ impl Session { } pub fn pointer(&mut self, ev: &PointerEvent, view: ViewInfo) -> Result<()> { + // A press ends a composition, keeping its marked text as typed (like clicking away from + // one in a native text view); the UI tells the system IME to drop it. + if matches!(ev.kind, PointerKind::Down | PointerKind::DoubleClick) { + self.end_composition()?; + } let actions = self.with_ctx(view, |t, cx| t.pointer(cx, ev)).unwrap_or_default(); self.run_actions(actions) } pub fn tool_key(&mut self, key: ToolKey, mods: Mods, view: ViewInfo) -> Result { + // Keys belong to the IME while it composes: nothing else (Escape's deselect) takes them. + if self.tool.composing() { + return Ok(true); + } let actions = self.with_ctx(view, |t, cx| t.key(cx, key, mods)).unwrap_or_default(); let handled = !actions.is_empty(); self.run_actions(actions)?; @@ -134,6 +145,55 @@ impl Session { self.with_ctx(ViewInfo::at_zoom(1.0), |t, cx| t.wants_text(cx)).unwrap_or(false) } + /// IME marked text at the text caret (see `Tool::ime_preedit`; `active_chars` counts + /// characters of `text`). It shows in place as a preview: no undo step, nothing journaled. + pub fn tool_preedit(&mut self, text: &str, active_chars: Option>, view: ViewInfo) -> Result<()> { + let actions = self.with_ctx(view, |t, cx| t.ime_preedit(cx, text, active_chars)).unwrap_or_default(); + let r = self.run_actions(actions); + if r.is_err() && self.tool.composing() { + // The marked text can't show (its story went away): the composition ends with nothing + // typed, so no gesture stays open. + match self.with_ctx(view, |t, cx| t.ime_preedit(cx, "", None)) { + Some(cancel) => self.run_actions(cancel)?, + None => self.tool = designcraft_tools::create(self.tool.id()), + } + } + r + } + + /// The IME committed `text`: it replaces the marked text as one `text.insert` (one undo step, + /// one journaled command). + pub fn tool_ime_commit(&mut self, text: &str, view: ViewInfo) -> Result<()> { + let actions = self.with_ctx(view, |t, cx| t.ime_commit(cx, text)).unwrap_or_default(); + self.run_actions(actions) + } + + /// Is the active tool showing uncommitted IME text? + pub fn tool_composing(&self) -> bool { + self.tool.composing() + } + + /// End an IME composition from outside the IME (a click, a tool switch, another command): the + /// marked text stays as typed, as one undo step. + pub fn end_composition(&mut self) -> Result<()> { + if !self.tool.composing() { + return Ok(()); + } + match self.with_ctx(ViewInfo::default(), |t, cx| t.ime_end(cx)) { + Some(actions) => self.run_actions(actions), + None => { + // No document to type into: only the tool's state goes. + self.tool = designcraft_tools::create(self.tool.id()); + Ok(()) + } + } + } + + /// The caret line (canvas space) the IME candidate window follows. + pub fn tool_ime_caret(&mut self, view: ViewInfo) -> Option<(designcraft_geom::Point, designcraft_geom::Point)> { + self.with_ctx(view, |t, cx| t.ime_caret(cx)).flatten() + } + pub fn run_actions(&mut self, actions: Vec) -> Result<()> { for a in actions { match a { @@ -152,31 +212,10 @@ 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 }); + st.interaction = + Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None, revision: st.revision }); Ok(()) } @@ -186,7 +225,9 @@ impl Session { let Some(it) = st.interaction.clone() else { return Err(EngineError::Other("no interaction".into())) }; st.doc = it.doc.clone(); st.selection = it.selection.clone(); - let r = self.execute(cmd, ¶ms); + // Not through `execute`: a preview is part of the gesture (an IME composition included), + // not a command that ends it. + let r = self.guarded(cmd, |s| s.execute_unguarded(cmd, ¶ms)); if let Some(st) = self.active_mut() && let Some(i) = st.interaction.as_mut() { @@ -217,9 +258,10 @@ impl Session { if let Some(st) = self.active_mut() && let Some(it) = st.interaction.take() { + // Nothing changed: the document goes back to the state and revision it began with. st.doc = it.doc; st.selection = it.selection; - st.revision += 1; + st.revision = it.revision; if self.transforms.2 { self.transforms.0.pop(); self.transforms.2 = false; diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index f2f0fbc4e..592640d6a 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -201,6 +201,32 @@ impl Headless { Ok(json!({"inserted": t.chars().count()})) } + /// `ui.ime {preedit?, active?: [start, end], commit?}`: input method events at the Type tool's + /// caret, as the app's `ui.ime` sends them: marked text shows in place with nothing recorded, + /// the committed text is typed as one `text.insert`, an empty `preedit` cancels. + fn ime(&mut self, p: &Value) -> Result { + let caret = self.session.active().is_some_and(|st| st.selection.text.is_some_and(|t| st.doc.text_story(t.story, t.cell).is_some())); + if !caret || !self.session.wants_text() { + return Err("no text caret for the input method: click into a text frame with the type tool first (pointer with tool \"type\")".into()); + } + let n = |v: &Value| v.as_u64().and_then(|n| usize::try_from(n).ok()); + let r = (|| { + if let Some(text) = s(p, "preedit") { + let active = p.get("active").and_then(Value::as_array).and_then(|a| Some(n(a.first()?)?..n(a.get(1)?)?)); + self.session.tool_preedit(text, active, VIEW)?; + } + match s(p, "commit") { + // A bare line break confirms the marked text, as Enter does in some IMEs. + Some("\n" | "\r") => self.session.end_composition(), + Some(text) => self.session.tool_ime_commit(text, VIEW), + None => Ok(()), + } + })(); + self.session.ui_requests.clear(); + r.map_err(|e| e.to_string())?; + Ok(json!({"composing": self.session.tool_composing(), "selection": self.selection()})) + } + fn render(&mut self, p: &Value) -> Result { let st = self.session.active().ok_or("no document open")?; let page = page_index(p, st.doc.page_count())?; @@ -276,6 +302,7 @@ impl Headless { "ui.pointer" => self.pointer(p), "ui.key" => self.key(p), "ui.text" => self.text(p), + "ui.ime" => self.ime(p), "ui.render" => self.render(p), "app.open" => self.exec("file.open", &json!({"path": s(p, "path")})), "app.save" => self.exec("file.save", &json!({"path": s(p, "path")})), diff --git a/crates/mcp/src/tests.rs b/crates/mcp/src/tests.rs index 0f3cdd56c..647a9789a 100644 --- a/crates/mcp/src/tests.rs +++ b/crates/mcp/src/tests.rs @@ -147,6 +147,7 @@ fn tool_definitions_are_well_formed() { "pointer", "key", "type_text", + "ime", "click", "drag", "select_tool", @@ -235,6 +236,34 @@ fn type_tool_gesture_then_typing() { assert_eq!(ok(&mut s, "key", json!({"key": "V"}))["tool"], "selection"); } +#[test] +fn ime_composes_at_the_type_tool_caret() { + let mut s = server(); + // No caret yet: the error says how to get one. + assert_eq!(call(&mut s, "ime", json!({"preedit": "に"}))["isError"], true); + ok( + &mut s, + "pointer", + json!({"tool": "type", "events": [{"kind": "down", "x": 72, "y": 72}, {"kind": "drag", "x": 300, "y": 200}, {"kind": "up", "x": 300, "y": 200}]}), + ); + let story = |s: &mut Server| { + let doc = ok(s, "inspect_document", json!({})); + let id = doc["stories"][0]["id"].as_u64().unwrap(); + ok(s, "get_story", json!({"story": id}))["text"].as_str().unwrap().to_string() + }; + assert_eq!(ok(&mut s, "ime", json!({"preedit": "にほん", "active": [3, 3]}))["composing"], true); + ok(&mut s, "ime", json!({"preedit": "日本", "active": [0, 2]})); + assert_eq!(story(&mut s), "日本", "marked text shows in place"); + assert_eq!(ok(&mut s, "ime", json!({"commit": "日本"}))["composing"], false); + assert_eq!(story(&mut s), "日本"); + // A cancelled composition leaves nothing; the commit is one undo step. + ok(&mut s, "ime", json!({"preedit": "ご"})); + ok(&mut s, "ime", json!({"preedit": ""})); + assert_eq!(story(&mut s), "日本"); + ok(&mut s, "execute", json!({"command": "edit.undo"})); + assert_eq!(story(&mut s), ""); +} + #[test] fn batch_stops_on_first_error() { let mut s = server(); diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index d1a1ae53a..77f194186 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -312,6 +312,25 @@ pub fn tool_definitions() -> Vec { obj(json!({"text": string("Text to type (\\n = new paragraph)")}), &["text"]), false, ), + tool( + "ime", + "Input method", + "Input method (IME) events at the Type tool's caret, as a system IME sends them: `preedit` is the marked text \ + (shown in place, no undo step; an empty string cancels the composition), `active` the clause being converted \ + in characters of it, `commit` the text the IME types in its place (one undo step). Example, Japanese: \ + {preedit:\"にほん\", active:[3,3]} then {preedit:\"日本\", active:[0,2]} then {commit:\"日本\"}. Returns \ + whether a composition is still open.", + obj( + json!({ + "preedit": string("Marked text (replaces the previous marked text; empty = cancel)"), + "active": {"type": "array", "items": {"type": "integer", "minimum": 0}, "minItems": 2, "maxItems": 2, + "description": "[start, end] of the clause being converted, in characters of preedit (start = end: the IME's cursor)"}, + "commit": string("Committed text, typed in place of the marked text"), + }), + &[], + ), + false, + ), tool( "click", "Click", @@ -573,6 +592,7 @@ fn dispatch(b: &mut dyn Backend, name: &str, a: &Args) -> Result j(b.call("ui.text", json!({"text": req_str(a, "text")?}))?), + "ime" => j(b.call("ui.ime", pick(a, &["preedit", "active", "commit"]))?), "click" | "drag" => { need_ui(b, name)?; let keys: &[&str] = &["x", "y", "toX", "toY", "steps", "button", "count", "shift", "alt", "cmd", "ctrl"]; diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 3f042d4a0..46175dda6 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -419,6 +419,30 @@ pub trait Tool: Send { fn wants_text(&self, _cx: &ToolContext) -> bool { false } + /// IME composition (marked text) at the text caret: `text` replaces the previous marked text + /// (or the selected text when a composition starts); an empty `text` ends the composition with + /// nothing typed. `active_chars` is the clause being converted (empty: the IME's cursor), in + /// characters of `text`. The result arrives through [`Tool::ime_commit`]. + fn ime_preedit(&mut self, _cx: &ToolContext, _text: &str, _active_chars: Option>) -> Vec { + vec![] + } + /// The IME committed `text`: it replaces the marked text and is typed as one step. + fn ime_commit(&mut self, _cx: &ToolContext, _text: &str) -> Vec { + vec![] + } + /// End a composition from outside the IME (a click, another command): the marked text stays + /// as typed. + fn ime_end(&mut self, _cx: &ToolContext) -> Vec { + vec![] + } + /// Is uncommitted IME text being shown? Keys, shortcuts and Undo wait while it is. + fn composing(&self) -> bool { + false + } + /// Where the IME candidate window goes: a caret line (top, bottom) in canvas space. + fn ime_caret(&self, _cx: &ToolContext) -> Option<(Point, Point)> { + None + } } pub fn create(id: &str) -> Box { diff --git a/crates/tools/src/text.rs b/crates/tools/src/text.rs index e2fe043de..9e6a8506c 100644 --- a/crates/tools/src/text.rs +++ b/crates/tools/src/text.rs @@ -1,12 +1,28 @@ //! Type tool (T): click in a frame to place the caret, drag in a frame to select text, drag on //! empty canvas to draw a new text frame. Keys move the caret / delete; typing goes through //! `text.insert` (sent by the UI as text input). +//! +//! IME: the marked text of a composition is a preview of `text.insert` at the caret (so it lays +//! out in place like typed text), underlined, until the IME commits it (one `text.insert`, one +//! undo step) or clears it (the preview goes, leaving no undo step). -use designcraft_geom::Point; +use std::ops::Range; + +use designcraft_compose as compose; +use designcraft_doc::{ItemId, TextSel}; +use designcraft_geom::{Affine, Point, Vec2}; use serde_json::json; use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext, ToolKey, frame::snapped_drag_rect, rect_json, spread_json}; +/// IME marked text shown at the caret through a preview of `text.insert`, not yet typed. +#[derive(Clone, Debug, PartialEq)] +struct Preedit { + text: String, + /// The clause being converted (empty: the IME's cursor), in bytes of `text`. + active: Option>, +} + #[derive(Default)] pub struct TypeTool { /// Vertical Type Tool: frames it draws set text vertically. @@ -21,6 +37,7 @@ pub struct TypeTool { guides: Vec, /// The press selected a word, line, paragraph or story (several clicks): drags keep it. unit_selected: bool, + preedit: Option, } /// Are whole rows or whole columns of a table selected? @@ -37,6 +54,82 @@ impl TypeTool { } } +/// Byte range in `s` of the characters `r` (IME ranges count characters; carets count bytes). +/// `None` when `r` runs past the end of `s` or backwards. +fn char_range_to_bytes(s: &str, r: Range) -> Option> { + let n = s.chars().count(); + let byte = |c: usize| if c == n { Some(s.len()) } else { s.char_indices().nth(c).map(|(i, _)| i) }; + let (a, b) = (byte(r.start)?, byte(r.end)?); + (a <= b).then_some(a..b) +} + +/// Text space → canvas for frame `fid` (with the quarter turn of a vertical frame). +fn text_canvas_xf(cx: &ToolContext, fid: ItemId) -> Option { + Some(cx.item_canvas_xf(fid)? * cx.doc.text_xf(cx.doc.item(fid)?)) +} + +/// Where the marked text starts: the preview typed it just before the caret. +fn marked_start(cx: &ToolContext, ts: TextSel, p: &Preedit) -> usize { + let text = cx.doc.text_story(ts.story, ts.cell).map_or("", |s| s.text.as_str()); + designcraft_doc::story::floor_char_boundary(text, ts.focus.saturating_sub(p.text.len())) +} + +/// The caret line (top, bottom) at byte `pos` of the selection's text, in canvas space; `None` +/// when `pos` isn't composed (overset). +fn caret_line(cx: &ToolContext, ts: TextSel, pos: usize) -> Option<(Point, Point)> { + let cs = cx.cache.get(cx.doc, ts.story, None); + let (fi, x, bl, asc, desc) = match ts.cell { + Some(c) => match c.footnote_id() { + Some(id) => compose::note_caret(&cs, id, pos)?, + None => compose::cell_caret(&cs, c.table, c.row, c.col, pos)?, + }, + None => compose::caret(&cs, pos)?, + }; + let ft = cs.frames.get(fi)?; + let m = text_canvas_xf(cx, ft.frame)?; + // Type on a path: the caret stands across the path where it is along it. + let map = cx.doc.item(ft.frame).filter(|_| ts.cell.is_none()).and_then(|it| compose::PathTextMap::new(it, ft)); + let on = |p: Point| map.as_ref().map_or(p, |map| map.to_path(p)); + Some((m * on(Point::new(x, bl - asc)), m * on(Point::new(x, bl + desc)))) +} + +/// Underlines (start, end in canvas space) under bytes `r` of the selection's text. +fn underlines(cx: &ToolContext, ts: TextSel, r: Range) -> Vec<(Point, Point)> { + let mut out = Vec::new(); + let mut add = |lines: &[compose::Line], m: Affine| { + for l in lines { + let (s, e) = (r.start.max(l.range.start), r.end.min(l.range.end)); + if s < e { + out.extend(compose::highlight_quads(l, s, e, false).into_iter().map(|q| (m * q[3], m * q[2]))); + } + } + }; + let cs = cx.cache.get(cx.doc, ts.story, None); + match ts.cell { + // A cell's or footnote's text is composed on its own and placed in the frame. + Some(c) => { + let found = match c.footnote_id() { + Some(id) => compose::find_note(&cs, id).map(|(fi, n)| (fi, n.origin, n.text.clone())), + None => compose::find_cell(&cs, c.table, c.row, c.col).map(|(fi, _, pc)| (fi, pc.origin, pc.text.clone())), + }; + if let Some((fi, origin, text)) = found + && let Some(m) = cs.frames.get(fi).and_then(|ft| text_canvas_xf(cx, ft.frame)) + && let Some(ft) = text.frames.first() + { + add(&ft.lines, m * Affine::translate(origin.to_vec2())); + } + } + None => { + for ft in &cs.frames { + if let Some(m) = text_canvas_xf(cx, ft.frame) { + add(&ft.lines, m); + } + } + } + } + out +} + impl Tool for TypeTool { fn id(&self) -> &'static str { if self.vertical { "verticalType" } else { "type" } @@ -142,7 +235,8 @@ impl Tool for TypeTool { } fn key(&mut self, cx: &ToolContext, key: ToolKey, mods: Mods) -> Vec { - if cx.selection.text.is_none() { + // The IME owns the keys while it composes (the UI holds them back; this is a guard). + if cx.selection.text.is_none() || self.preedit.is_some() { return vec![]; } let mv = |dir: &str| { @@ -164,8 +258,29 @@ impl Tool for TypeTool { } } - fn overlays(&self, _cx: &ToolContext) -> Vec { - if self.drawing { self.guides.clone() } else { vec![] } + fn overlays(&self, cx: &ToolContext) -> Vec { + if self.drawing { + return self.guides.clone(); + } + let (Some(p), Some(ts)) = (&self.preedit, cx.selection.text) else { return vec![] }; + // Marked text: a thin underline, a thick one under the clause being converted. + let start = marked_start(cx, ts, p); + let line = |(a, b): (Point, Point)| Overlay::Line { a, b, color: [0, 0, 0], dashed: false }; + let mut o: Vec = underlines(cx, ts, start..start + p.text.len()).into_iter().map(line).collect(); + match &p.active { + Some(r) if !r.is_empty() => { + for (a, b) in underlines(cx, ts, start + r.start..start + r.end) { + // A second line a pixel further from the text. + let d = b - a; + let n = Vec2::new(-d.y, d.x) * (cx.tol(1.0) / d.hypot().max(1e-9)); + o.push(line((a + n, b + n))); + } + } + // The IME's cursor inside the marked text (the text caret stays at its end). + Some(r) if r.start < p.text.len() => o.extend(caret_line(cx, ts, start + r.start).map(line)), + _ => {} + } + o } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { @@ -182,4 +297,81 @@ impl Tool for TypeTool { fn wants_text(&self, cx: &ToolContext) -> bool { cx.selection.text.is_some() } + + fn ime_preedit(&mut self, cx: &ToolContext, text: &str, active_chars: Option>) -> Vec { + let text: String = text.chars().filter(|c| !c.is_control()).collect(); + // A cancelled composition: the preview goes, leaving no undo step. A clear with nothing + // marked (some integrations send one when the IME is enabled) changes nothing, so it never + // deletes the selected text. + if text.is_empty() || cx.selection.text.is_none() { + return if self.preedit.take().is_some() { vec![Action::Cancel] } else { vec![] }; + } + let mut out = Vec::new(); + if self.preedit.is_none() { + out.push(Action::Begin("Type".into())); + } + // Each update replaces the previous marked text (or the selected text). Marked text shows + // as it is: typographer's quotes and autocorrect wait for the commit. + out.push(Action::Preview("text.insert".into(), json!({"text": text, "raw": true}))); + self.preedit = Some(Preedit { active: active_chars.and_then(|r| char_range_to_bytes(&text, r)), text }); + out + } + + fn ime_commit(&mut self, cx: &ToolContext, text: &str) -> Vec { + let text: String = text.chars().map(|c| if c == '\r' { '\n' } else { c }).filter(|c| !c.is_control() || matches!(c, '\n' | '\t')).collect(); + // The marked text goes; the result is typed in its place like any typing: one command, + // one undo step. + let mut out = if self.preedit.take().is_some() { vec![Action::Cancel] } else { vec![] }; + if !text.is_empty() && cx.selection.text.is_some() { + out.push(Action::Exec("text.insert".into(), json!({"text": text}))); + } + out + } + + fn ime_end(&mut self, cx: &ToolContext) -> Vec { + match self.preedit.as_ref().map(|p| p.text.clone()) { + Some(text) => self.ime_commit(cx, &text), + None => vec![], + } + } + + fn composing(&self) -> bool { + self.preedit.is_some() + } + + fn ime_caret(&self, cx: &ToolContext) -> Option<(Point, Point)> { + let ts = cx.selection.text?; + // The candidate window follows the clause being converted (or the IME's cursor), else the + // caret. + let at = match &self.preedit { + Some(p) => marked_start(cx, ts, p) + p.active.as_ref().map_or(0, |r| r.start), + None => ts.range().start, + }; + caret_line(cx, ts, at).or_else(|| { + // Overset text has no caret on a page: the window goes to the out port of the story's + // last frame. + let fid = cx.doc.story(ts.story).and_then(|s| s.frames.last().copied()).or(ts.frame)?; + let it = cx.doc.item(fid)?; + let m = cx.item_canvas_xf(fid)? * it.xf; + let r = it.inner_bounds(); + Some((m * Point::new(r.x1, r.y1 - 12.0), m * Point::new(r.x1, r.y1))) + }) + } +} + +#[cfg(test)] +mod ime_tests { + use super::char_range_to_bytes; + + #[test] + fn ime_character_ranges_become_byte_ranges() { + assert_eq!(char_range_to_bytes("雅楽", 0..2), Some(0..6)); + assert_eq!(char_range_to_bytes("がg", 1..2), Some(3..4)); + assert_eq!(char_range_to_bytes("がg", 2..2), Some(4..4)); + // Past the end or backwards: no clause. + assert_eq!(char_range_to_bytes("が", 1..3), None); + #[allow(clippy::reversed_empty_ranges)] + let backwards = 1..0; + assert_eq!(char_range_to_bytes("がg", backwards), None); + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 40b146f56..749596aeb 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -407,6 +407,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } let sel_rect = draw_selection(app, &painter, &xf, &doc, &layout); draw_tool_overlays(app, &painter, &xf); + ime_output(app, ui.ctx(), &xf); if rulers { draw_rulers(app, ui, full, rect, &xf, &doc, &layout, &t); ruler_units_menus(app, &resp, full, rect, &doc); @@ -1078,11 +1079,8 @@ fn draw_text_selection( cells: Option, ) { let cs = app.session.cache.get(doc, ts.story, None); - // 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) }; + let range = ts.range(); + let tint = 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(); @@ -1171,7 +1169,7 @@ fn draw_text_selection( } } if !runs.is_empty() { - draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some()); + draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs); } if ts.is_caret() && let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus) @@ -1187,30 +1185,14 @@ fn draw_text_selection( if blink { 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, @@ -1228,9 +1210,7 @@ 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 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) }; + let range = ts.range(); if !range.is_empty() && let Some(cft) = text.frames.first() { @@ -1243,7 +1223,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(), tint, Stroke::NONE)); + painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE)); } } } @@ -1259,20 +1239,13 @@ fn draw_cell_selection( if blink { 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, @@ -1281,7 +1254,6 @@ 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(); @@ -1308,7 +1280,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)), composing)); + .with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)))); 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 { @@ -1316,8 +1288,7 @@ 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() }); - 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)); + rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255)); for (_, m, quads, _) in runs { rc.set_transform(view * *m); for q in quads { @@ -1338,12 +1309,8 @@ 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, 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 [r, gg, b, _] = c.to_rgba8(1.0); + 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 }; @@ -1844,45 +1811,24 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if resp.clicked() || resp.drag_started() { resp.request_focus(); } - // Both split panes show the same document, but only the focused pane dispatches its keys. - if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) { + // Keyboard for the active tool. + if !keyboard_pane(app) || (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 }); - }); - } + // Keys of a frame that began composing are the IME's too (a Backspace that empties the marked + // text must not delete the character before it). + let composing_at_start = app.session.tool_composing(); let evs = ui.input(|i| i.events.clone()); for e in evs { + let composing = composing_at_start || app.session.tool_composing(); match e { - egui::Event::Text(t) if wants_text => { + egui::Event::Ime(ime) if wants_text => ime_event(app, ime, vi), + // Keys the IME passes through come as text only while nothing is marked. + egui::Event::Text(t) if wants_text && !composing => { 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})); - } - } + // The IME owns the keyboard until it commits: no typing, clipboard or editing keys. + egui::Event::Text(_) | egui::Event::Paste(_) | egui::Event::Copy | egui::Event::Cut | egui::Event::Key { .. } if composing => {} egui::Event::Paste(t) if wants_text => { // Formatted when the system clipboard still holds what was copied here; // Shift (⇧⌘V) pastes without formatting. @@ -1937,6 +1883,95 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r let _ = DVec2::ZERO; } +/// Does the current pane take the keyboard? Both panes (split, or the second window) see the same +/// events, so typing, tool keys, the clipboard and the input method serve only the focused pane. +fn keyboard_pane(app: &DesignApp) -> bool { + app.pane == app.focus_pane +} + +/// One input method event for the text caret: marked text previews in the story, a commit types. +fn ime_event(app: &mut DesignApp, e: egui::ImeEvent, vi: designcraft_engine::ViewInfo) { + let r = match e { + egui::ImeEvent::Preedit { text, active_range_chars } => { + app.ime_marked = Some(text.clone()).filter(|t| !t.is_empty()); + app.session.tool_preedit(&text, active_range_chars, vi).map_err(|e| e.to_string()) + } + // A bare line break confirms the composition (Enter with some IMEs): the marked text, not + // a new paragraph. + egui::ImeEvent::Commit(t) if t == "\n" || t == "\r" => { + keep_marked_text(app); + Ok(()) + } + egui::ImeEvent::Commit(t) => { + app.ime_marked = None; + app.session.tool_ime_commit(&t, vi).map_err(|e| e.to_string()) + } + // The web text agent's corrections and mobile Backspace: characters around the caret go. + egui::ImeEvent::DeleteSurrounding { before_chars, after_chars } if !app.session.tool_composing() => { + delete_surrounding(app, before_chars, after_chars) + } + // `Enabled` and `Disabled` carry nothing: the IME is on while `ime_output` sets `ime`. + _ => Ok(()), + }; + if let Err(e) = r { + app.status(e); + } + app.after_engine(); +} + +/// Delete `before` characters before the caret and `after` after it (sizes come from the browser: +/// a correction never deletes more than a word's worth). +fn delete_surrounding(app: &mut DesignApp, before: usize, after: usize) -> Result<(), String> { + for (n, forward) in [(before, false), (after, true)] { + for _ in 0..n.min(64) { + app.run("text.delete", json!({"forward": forward}))?; + } + } + Ok(()) +} + +/// End a composition outside the IME (a click away, the canvas losing the keyboard): the marked +/// text stays as typed, and the system IME is told to drop its copy. +pub(crate) fn keep_marked_text(app: &mut DesignApp) { + if app.ime_marked.take().is_some() { + app.ime_discard = true; + } + if app.session.tool_composing() { + if let Err(e) = app.session.end_composition() { + app.status(e.to_string()); + } + app.after_engine(); + } +} + +/// While the text caret is in the canvas, let the system IME compose (egui-winit allows it only in +/// frames that set `ime`; the web build focuses its hidden text input then) and keep the candidate +/// window at the caret, through zoom, scrolling, view rotation and vertical text. +fn ime_output(app: &mut DesignApp, ctx: &egui::Context, xf: &Xf) { + if !keyboard_pane(app) { + return; + } + // A text field, dialog or Quick Apply has the keyboard: egui's own text editing serves the IME. + let ours = app.session.wants_text() && !ctx.text_edit_focused() && app.ui.dialog.is_none() && app.ui.palette.is_none(); + let caret = if ours { app.session.tool_ime_caret(app.view_info()) } else { None }; + let Some((a, b)) = caret else { + // The IME goes away with its marked text: keep that text as typed, like a click away. + keep_marked_text(app); + return; + }; + let r = Rect::from_two_pos(xf.to_screen(a), xf.to_screen(b)).expand(1.0); + // The composition ended outside the IME (a click, a command): the IME drops what it has marked. + let interrupt = app.ime_marked.is_some() && !app.session.tool_composing(); + if interrupt { + app.ime_marked = None; + app.ime_discard = true; + } + app.canvas_ime = true; + ctx.output_mut(|o| { + o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect: r, cursor_rect: r, should_interrupt_composition: interrupt }); + }); +} + /// View → Show Hidden Characters: ¶ paragraph ends, » tabs, · spaces, ¬ forced line breaks, # end of story. fn draw_hidden_characters(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document, layout: &CanvasLayout) { for story in doc.stories.values() { @@ -2598,20 +2633,23 @@ mod tests { 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"); + assert_eq!(text(&h), "T´", "the accent being composed shows in the story"); + assert!(h.state().session.tool_composing(), "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. + assert!(!h.state().session.tool_composing()); + // Moving the caret mid-composition keeps the accent as typed and tells the input method + // to drop its copy. 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_eq!(text(&h), "Tí˜"); + assert!(!h.state().session.tool_composing()); assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition)); + assert!(h.state_mut().take_ime_discard()); // Another tool takes no text: the input method is off. h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap(); h.run_steps(2); @@ -2653,6 +2691,155 @@ mod tests { assert!(h.query_by_label(" Points").is_none()); } + /// One frame of the whole app with `events`; returns what it asks of the system IME. + fn ime_frame(app: &mut DesignApp, ctx: &egui::Context, events: Vec) -> Option { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1400.0, 900.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(ui.ctx()); + app.ui(ui); + }); + out.textures_delta.clear(); + out.platform_output.ime + } + + /// The Type tool's caret at the end of a new text frame holding `text`, the app's first frames + /// drawn; returns the story id. + fn typing_app(app: &mut DesignApp, ctx: &egui::Context, text: &str) -> Value { + app.run("file.new", json!({})).unwrap(); + let f = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": text})).unwrap(); + let sid = f["story"].clone(); + app.session.execute("text.select", &json!({"story": sid, "anchor": text.len(), "focus": text.len()})).unwrap(); + app.select_tool("type"); + for _ in 0..4 { + ime_frame(app, ctx, vec![]); + } + sid + } + + fn story_text(app: &mut DesignApp, sid: &Value) -> String { + app.session.execute("story.get", &json!({"story": sid})).unwrap()["text"].as_str().unwrap().to_string() + } + + /// Spread point `p` on screen. + fn on_screen(app: &DesignApp, p: Point) -> Pos2 { + let st = app.session.active().unwrap(); + let c = CanvasLayout::new(&st.doc, st.editing_parents).to_canvas(SpreadRef::Doc(0), p); + Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()).to_screen(c) + } + + fn preedit(t: &str, c: usize) -> egui::Event { + egui::Event::Ime(egui::ImeEvent::Preedit { text: t.into(), active_range_chars: Some(c..c) }) + } + + #[test] + #[allow(deprecated)] // `ImeEvent::Enabled` / `Disabled`: egui no longer sends them, other integrations may. + fn ime_events_compose_in_the_story_with_the_candidate_window_at_the_caret() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "曲:"); + // At a text caret the IME is allowed, its window on the caret as the canvas draws it. + let ime = ime_frame(&mut app, &ctx, vec![]).expect("IME allowed at a text caret"); + let (top, bottom) = { + let st = app.session.active().unwrap(); + let cs = app.session.cache.get(&st.doc, designcraft_doc::StoryId(sid.as_u64().unwrap()), None); + let (fi, x, bl, asc, desc) = compose::caret(&cs, "曲:".len()).unwrap(); + let fid = cs.frames[fi].frame; + let (a, _) = item_canvas_xf(&st.doc, &CanvasLayout::new(&st.doc, st.editing_parents), fid).unwrap(); + let m = a * st.doc.text_xf(st.doc.item(fid).unwrap()); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))) + }; + assert!(ime.rect.expand(1.0).contains(top) && ime.rect.expand(1.0).contains(bottom), "{:?} vs {top:?}–{bottom:?}", ime.rect); + assert_eq!(ime.cursor_rect, ime.rect); + assert!(!ime.should_interrupt_composition); + assert!(app.canvas_ime(), "the web build keeps whole words typed at the caret"); + // "ni" → に → 日, with the events winit sends. + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Enabled), preedit("ni", 2)]); + assert_eq!(story_text(&mut app, &sid), "曲:ni", "marked text shows in place"); + assert!(app.session.tool_composing()); + // Keys, plain text, the clipboard and Undo (the native menu's ⌘Z) wait for the IME. + let steps = app.session.doc().unwrap().history.undo.len(); + let key = |key, pressed| egui::Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + ime_frame( + &mut app, + &ctx, + vec![key(egui::Key::Backspace, true), key(egui::Key::Backspace, false), egui::Event::Text("x".into()), egui::Event::Paste("y".into())], + ); + crate::menus::activate(&mut app, "edit.undo", &Value::Null); + assert!(!crate::menus::enabled(&app, "edit.undo")); + assert_eq!(story_text(&mut app, &sid), "曲:ni"); + let ime2 = ime_frame(&mut app, &ctx, vec![preedit("に", 1)]).expect("IME allowed while composing"); + assert_eq!(story_text(&mut app, &sid), "曲:に"); + assert!(ime2.rect.center().x > ime.rect.center().x, "the window follows the IME's cursor: {:?} {:?}", ime2.rect, ime.rect); + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into())), egui::Event::Ime(egui::ImeEvent::Disabled)]); + assert_eq!(story_text(&mut app, &sid), "曲:日"); + assert!(!app.session.tool_composing()); + assert_eq!(app.session.doc().unwrap().history.undo.len(), steps + 1, "the commit is one undo step"); + assert_eq!(app.session.journal.last(), Some(&("text.insert".to_string(), json!({"text": "日"})))); + // After the commit, typing and Undo work again. + ime_frame(&mut app, &ctx, vec![egui::Event::Text("!".into())]); + assert_eq!(story_text(&mut app, &sid), "曲:日!"); + crate::menus::activate(&mut app, "edit.undo", &Value::Null); + crate::menus::activate(&mut app, "edit.undo", &Value::Null); + assert_eq!(story_text(&mut app, &sid), "曲:"); + // Without a text caret the IME is off: single-key tool shortcuts keep working. + app.select_tool("selection"); + assert!(ime_frame(&mut app, &ctx, vec![]).is_none()); + assert!(!app.canvas_ime()); + } + + /// Both panes of a split window see the same keyboard events: only the pane last clicked + /// types them. + #[test] + fn a_split_window_types_once() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, "ab"); + app.run("window.split", json!({"on": true})).unwrap(); + for _ in 0..3 { + ime_frame(&mut app, &ctx, vec![]); + } + assert!(app.other_pane.as_ref().and_then(|o| o.1).is_some(), "both panes are drawn"); + ime_frame(&mut app, &ctx, vec![egui::Event::Text("c".into())]); + assert_eq!(story_text(&mut app, &sid), "abc"); + let key = |pressed| egui::Event::Key { key: egui::Key::Backspace, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + ime_frame(&mut app, &ctx, vec![key(true), key(false)]); + assert_eq!(story_text(&mut app, &sid), "ab"); + ime_frame(&mut app, &ctx, vec![egui::Event::Paste("de".into())]); + assert_eq!(story_text(&mut app, &sid), "abde"); + ime_frame(&mut app, &ctx, vec![preedit("に", 1)]); + assert_eq!(story_text(&mut app, &sid), "abdeに"); + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("日".into()))]); + assert_eq!(story_text(&mut app, &sid), "abde日"); + } + + #[test] + fn clicking_away_mid_composition_keeps_the_text_and_interrupts_the_ime() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let sid = typing_app(&mut app, &ctx, ""); + ime_frame(&mut app, &ctx, vec![preedit("しょうこ", 4)]); + assert!(app.session.tool_composing()); + // A click in the frame (macOS and Windows send nothing for it): the marked text stays as + // typed and the IME is told to drop its composition, once. + let p = on_screen(&app, Point::new(150.0, 120.0)); + let press = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + let ime = ime_frame(&mut app, &ctx, vec![egui::Event::PointerMoved(p), press(true)]).expect("still at a text caret"); + assert!(ime.should_interrupt_composition); + ime_frame(&mut app, &ctx, vec![press(false)]); + assert!(!app.session.tool_composing()); + assert_eq!(story_text(&mut app, &sid), "しょうこ"); + assert!(app.take_ime_discard(), "the host tells the system IME to drop its marked text"); + assert!(!ime_frame(&mut app, &ctx, vec![]).expect("still at a text caret").should_interrupt_composition); + assert!(!app.take_ime_discard()); + // A bare line break from the IME confirms a composition: the marked text, no new paragraph. + ime_frame(&mut app, &ctx, vec![preedit("がく", 2)]); + ime_frame(&mut app, &ctx, vec![egui::Event::Ime(egui::ImeEvent::Commit("\n".into()))]); + assert!(!app.session.tool_composing()); + let typed = story_text(&mut app, &sid); + assert!(typed.contains("がく") && typed.contains("しょうこ") && !typed.contains('\n'), "{typed:?}"); + } + #[test] fn baseline_grid_lines_are_the_ones_on_the_page() { // A start far above the page used to be walked line by line, without end. diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 8ee456520..ac4b2fc29 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -10,6 +10,8 @@ //! drive the active tool through the same path as the mouse //! - `ui.key {key, shift?, alt?, cmd?}`: synthetic keyboard input //! - `ui.text {text}`: insert at the explicit caret, or type into a focused UI field +//! - `ui.ime {preedit?, active?: [start, end], commit?}`: input method events, as the system IME +//! sends them (marked text with its converting clause in characters, then the committed text) //! - `ui.move {x, y}` / `ui.click {x, y, button?, count?, shift?…}` / `ui.drag {x, y, toX, toY, steps?}`: //! real egui pointer input in screen points (reaches every widget: panels, flyouts, dialogs) //! - `ui.set {brightness?, panel?, dockTab?, rulers?, outline?, …}` @@ -272,6 +274,18 @@ fn handle_request(app: &mut DesignApp, ctx: &egui::Context, req: &ControlRequest wrap(app.run("text.insert", json!({"text": text}))) } } + "ui.ime" => { + let n = |v: &Value| v.as_u64().and_then(|n| usize::try_from(n).ok()); + if let Some(text) = s("preedit") { + let active = p.get("active").and_then(Value::as_array).and_then(|a| Some(n(a.first()?)?..n(a.get(1)?)?)); + app.synthetic.push(egui::Event::Ime(egui::ImeEvent::Preedit { text: text.to_string(), active_range_chars: active })); + } + if let Some(text) = s("commit") { + app.synthetic.push(egui::Event::Ime(egui::ImeEvent::Commit(text.to_string()))); + } + ctx.request_repaint(); + ok(Value::Null) + } "ui.set" => { let mut r = Ok(Value::Null); if let Some(b) = s("brightness") { diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 7d02776a2..a2947806d 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5668,7 +5668,7 @@ mod tests { let revision = app.session.doc().unwrap().revision; app.run("text.selectWord", json!({"frame": frame.0, "point": [point.x, point.y]})).unwrap(); assert_eq!(app.session.doc().unwrap().selection, selected); - assert_eq!(app.session.doc().unwrap().revision, revision + 1); + assert!(app.session.doc().unwrap().revision > revision, "a new revision"); assert!(std::sync::Arc::ptr_eq(&before, &app.session.doc().unwrap().doc)); scope_click(&mut app, &ctx, screen, "Count"); assert_eq!(app.ui.dialog.as_ref().unwrap().s("status"), "1 match(es)"); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 59c7ebb59..68f198b1d 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -560,6 +560,14 @@ pub struct DesignApp { /// (no native menu: without it the paste keys produce no paste event), or what it held then /// (native menu). It is never pasted as text: Paste with it pastes the copied objects. object_clip_text: Option, + /// The marked text the system IME last sent (`None` once it commits or clears). When the + /// composition ends some other way (a click, a tool switch), the IME is told to drop it. + pub(crate) ime_marked: Option, + /// The system IME must drop its marked text (see [`Self::take_ime_discard`]). + pub(crate) ime_discard: bool, + /// The canvas asked for the system IME in the last frame (the web build's text input has the + /// keyboard then). + pub(crate) canvas_ime: bool, /// Quit was chosen: close the window on the next frame, as its close button does. quit_requested: bool, /// Close the window without asking about unsaved changes (the control channel's `app.quit`). @@ -618,6 +626,9 @@ impl DesignApp { egui_ctx: None, in_control: false, object_clip_text: None, + ime_marked: None, + ime_discard: false, + canvas_ime: false, quit_requested: false, quit_without_asking: false, quitting: None, @@ -665,6 +676,21 @@ impl DesignApp { self.ui.status = s.into(); } + /// Did an IME composition end outside the IME this frame (a click, a tool switch)? egui's + /// `should_interrupt_composition` toggles winit's IME, which doesn't reach the macOS input + /// context: it keeps the marked text and types it again into the next composition, so the + /// host discards it there. + pub fn take_ime_discard(&mut self) -> bool { + std::mem::take(&mut self.ime_discard) + } + + /// Did the canvas ask for the system IME in the last frame (a Type tool caret)? The web + /// build's hidden text input has the keyboard then, so text events hold what the browser + /// typed (see [`drop_key_name_text`]). + pub fn canvas_ime(&self) -> bool { + self.canvas_ime + } + /// THE entry point for every action (menus, shortcuts, palette, panels, control channel). pub fn run(&mut self, id: &str, params: Value) -> Result { if let Some(r) = menus::run_ui(self, id, ¶ms) { @@ -1244,6 +1270,7 @@ impl DesignApp { /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { let ctx = ui.ctx().clone(); + self.canvas_ime = false; if !self.fonts_ready { ctx.request_repaint(); return; @@ -1468,19 +1495,50 @@ pub fn now_ms() -> f64 { } } -/// Is `text` the name of a key ("AltGraph", "Dead", "AudioVolumeUp") instead of typed text? -/// Browsers report a key press as a string: the character it types, or a name made of ASCII -/// letters and digits that starts with a capital. One key press never types such a word. +/// Named `KeyboardEvent.key` values (W3C UI Events) that a browser reports for a key that types +/// nothing, separated by spaces. eframe's web backend sends the ones it doesn't know as text. +const KEY_NAMES: &str = "\ + Unidentified Alt AltGraph CapsLock Control Fn FnLock Hyper Meta NumLock ScrollLock Shift Super Symbol SymbolLock OS Win Enter \ + Tab ArrowDown ArrowLeft ArrowRight ArrowUp End Home PageDown PageUp Backspace Clear Copy CrSel Cut Delete EraseEof ExSel \ + Insert Paste Redo Undo Accept Again Attn Cancel ContextMenu Esc Escape Execute Find Finish Help Pause Play Props Select \ + ZoomIn ZoomOut BrightnessDown BrightnessUp Eject LogOff Power PowerOff PrintScreen Hibernate Standby WakeUp AllCandidates \ + Alphanumeric CodeInput Compose Convert Dead FinalMode GroupFirst GroupLast GroupNext GroupPrevious ModeChange NextCandidate \ + NonConvert PreviousCandidate Process SingleCandidate HangulMode HanjaMode JunjaMode Eisu Hankaku Hiragana HiraganaKatakana \ + KanaMode KanjiMode Katakana Romaji Zenkaku ZenkakuHankaku Soft1 Soft2 Soft3 Soft4 ChannelDown ChannelUp Close MailForward \ + MailReply MailSend MediaClose MediaFastForward MediaPause MediaPlay MediaPlayPause MediaRecord MediaRewind MediaStop \ + MediaTrackNext MediaTrackPrevious New Open Print Save SpellCheck Key11 Key12 AudioBalanceLeft AudioBalanceRight \ + AudioBassBoostDown AudioBassBoostToggle AudioBassBoostUp AudioFaderFront AudioFaderRear AudioSurroundModeNext AudioTrebleDown \ + AudioTrebleUp AudioVolumeDown AudioVolumeUp AudioVolumeMute MicrophoneToggle MicrophoneVolumeDown MicrophoneVolumeUp \ + MicrophoneVolumeMute SpeechCorrectionList SpeechInputToggle LaunchApplication1 LaunchApplication2 LaunchCalendar \ + LaunchContacts LaunchMail LaunchMediaPlayer LaunchMusicPlayer LaunchPhone LaunchScreenSaver LaunchSpreadsheet \ + LaunchWebBrowser LaunchWebCam LaunchWordProcessor BrowserBack BrowserFavorites BrowserForward BrowserHome BrowserRefresh \ + BrowserSearch BrowserStop AppSwitch Call Camera CameraFocus EndCall GoBack GoHome HeadsetHook LastNumberRedial Notification \ + MannerMode VoiceDial TV TV3DMode TVAntennaCable TVAudioDescription TVAudioDescriptionMixDown TVAudioDescriptionMixUp \ + TVContentsMenu TVDataService TVInput TVInputComponent1 TVInputComponent2 TVInputComposite1 TVInputComposite2 TVInputHDMI1 \ + TVInputHDMI2 TVInputHDMI3 TVInputHDMI4 TVInputVGA1 TVMediaContext TVNetwork TVNumberEntry TVPower TVRadioService TVSatellite \ + TVSatelliteBS TVSatelliteCS TVSatelliteToggle TVTerrestrialAnalog TVTerrestrialDigital TVTimer AVRInput AVRPower ColorF0Red \ + ColorF1Green ColorF2Yellow ColorF3Blue ColorF4Grey ColorF5Brown ClosedCaptionToggle Dimmer DisplaySwap DVR Exit \ + FavoriteClear0 FavoriteClear1 FavoriteClear2 FavoriteClear3 FavoriteRecall0 FavoriteRecall1 FavoriteRecall2 FavoriteRecall3 \ + FavoriteStore0 FavoriteStore1 FavoriteStore2 FavoriteStore3 Guide GuideNextDay GuidePreviousDay Info InstantReplay Link \ + ListProgram LiveContent Lock MediaApps MediaAudioTrack MediaLast MediaSkipBackward MediaSkipForward MediaStepBackward \ + MediaStepForward MediaTopMenu NavigateIn NavigateNext NavigateOut NavigatePrevious NextFavoriteChannel NextUserProfile \ + OnDemand Pairing PinPDown PinPMove PinPToggle PinPUp PlaySpeedDown PlaySpeedReset PlaySpeedUp RandomToggle RcLowBattery \ + RecordSpeedNext RfBypass ScanChannelsToggle ScreenModeNext Settings SplitScreenToggle STBInput STBPower Subtitle Teletext \ + VideoModeNext Wink ZoomToggle"; + +/// Is `text` the name of a key ("AltGraph", "Dead", "F13") instead of typed text? pub fn is_key_name(text: &str) -> bool { - text.len() > 1 && text.starts_with(|c: char| c.is_ascii_uppercase()) && text.chars().all(|c| c.is_ascii_alphanumeric()) + let function_key = text.strip_prefix('F').is_some_and(|n| !n.is_empty() && n.bytes().all(|b| b.is_ascii_digit())); + function_key || KEY_NAMES.split_ascii_whitespace().any(|k| k == text) } -/// Drop text events that are key names. eframe's web backend sends the name of every key it -/// doesn't know as text, so AltGr typed "AltGraph" into the story. `text_field_focused`: an -/// egui text field has the keyboard; its text comes from the browser's input events, which can -/// hold whole words, and is left alone. -pub fn drop_key_name_text(raw: &mut egui::RawInput, text_field_focused: bool) { - if !text_field_focused { +/// Drop text events that are key names: eframe's web backend sends the name of every key it +/// doesn't know as text, so AltGr typed "AltGraph" into the story. `text_input_focused`: an egui +/// text field or the canvas's text caret has the keyboard, so the browser's hidden text input +/// sends the text, which holds only typed text (whole words from autocompletion too) and is left +/// alone. +pub fn drop_key_name_text(raw: &mut egui::RawInput, text_input_focused: bool) { + if !text_input_focused { raw.events.retain(|e| !matches!(e, egui::Event::Text(t) if is_key_name(t))); } } @@ -2024,7 +2082,14 @@ mod tests { let mut raw = egui::RawInput { events: events(), ..Default::default() }; drop_key_name_text(&mut raw, false); assert_eq!(raw.events, vec![text("é"), text("A"), text("ß"), text("ab"), text("Æ")]); - // A focused text field gets its text from input events, which can be whole words. + assert!(is_key_name("F13") && is_key_name("Escape") && is_key_name("ZoomToggle") && !is_key_name("F") && !is_key_name("Fx")); + // Capitalised words (mobile autocompletion) are text. + let words = vec![text("Hello"), text("Tokyo"), text("OK"), text("Hi there")]; + let mut raw = egui::RawInput { events: words.clone(), ..Default::default() }; + drop_key_name_text(&mut raw, false); + assert_eq!(raw.events, words); + // A focused text field or a canvas text caret gets its text from the browser's input + // events, which hold whole words, key-name-like ones too ("Help"). let mut raw = egui::RawInput { events: events(), ..Default::default() }; drop_key_name_text(&mut raw, true); assert_eq!(raw.events, events()); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index edab4329b..d6f313990 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1778,8 +1778,22 @@ pub(crate) fn export_pdf(app: &mut DesignApp, p: &Value) -> Result bool { + app.session.tool_composing() + && (matches!( + id, + "edit.undo" | "edit.redo" | "edit.cut" | "edit.copy" | "edit.clear" | "edit.duplicate" | "edit.selectAll" | "edit.deselectAll" + ) || id.starts_with("edit.paste")) +} + /// Is a command enabled (for menu greying)? pub fn enabled(app: &DesignApp, id: &str) -> bool { + if waits_for_ime(app, id) { + return false; + } match designcraft_engine::find_command(id) { Some(c) => (c.enabled)(&app.session).is_ok(), None => true, @@ -2012,6 +2026,9 @@ pub fn checked(app: &DesignApp, id: &str, params: &Value) -> Option { /// Enablement for menu display (UI commands need a document unless they're app/window-level). pub fn menu_enabled(app: &DesignApp, id: &str) -> bool { + if waits_for_ime(app, id) { + return false; + } if id.starts_with("fit.") { return enabled(app, "object.fit"); } @@ -2039,6 +2056,9 @@ pub fn close_document(app: &mut DesignApp, index: Option) { /// A menu item was chosen. An engine command whose label ends in "…" and that takes parameters /// opens a dialog built from its parameter documentation (see [`crate::dialogs::command_fields`]). pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { + if waits_for_ime(app, id) { + return; + } let was_find = app.ui.dialog.as_ref().is_some_and(|d| d.id == "findChange"); activate_inner(app, id, params); if was_find && app.ui.dialog.as_ref().is_none_or(|d| d.id != "findChange") { @@ -2355,7 +2375,8 @@ fn parse_shortcut(sc: &str) -> Option<(egui::Modifiers, egui::Key)> { pub fn shortcuts(app: &mut DesignApp, ctx: &egui::Context) { // Only a focused text field takes the keys: the canvas (or a button) having focus after a click // must not swallow tool shortcuts until Esc clears it. - if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() { + // While an IME composes at the text caret, the keyboard is its own. + if ctx.text_edit_focused() || app.ui.dialog.is_some() || app.ui.palette.is_some() || app.session.tool_composing() { return; } let typing = app.session.wants_text(); diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 25136a807..88e466f7e 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -39,6 +39,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.tool.select` | `{tool}` | Select a tool (`selection`, `type`, `rectangleFrame`, …) | | `ui.pointer` | `{events:[{kind: down\|drag\|up\|move\|doubleclick, x, y, space?: "screen"\|"canvas", clicks?}], mods?}` | Drive the active tool through the same code path as the mouse; `clicks` on a `down` is the press count (3 = triple click); returns the selection, the tool and its cursor at the last event (e.g. `LoadedText`, `ThreadLink`, `Unthread`) | | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | +| `ui.ime` | `{preedit?, active?: [start, end], commit?}` | Input method events as the system IME sends them: marked text (shown in place, `active` is the clause being converted, in characters) and the committed text (typed as one `text.insert`) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle`, `count?: positive integer` (click/drag, default 1), `steps?: positive integer` (drag, default 8) | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | | `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | diff --git a/docs/gaps.md b/docs/gaps.md index 21a18b369..4b8226356 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -99,9 +99,9 @@ Kind: **F** feature · **U** UI/UX · **T** typography · **FF** file format · ## High -### 8. Canvas text input has no IME (U, L) -- **Missing:** composition input for Chinese, Japanese and Korean on the canvas (#322). - VectorCraft has an implementation to port. +### 8. Canvas IME not checked with real input methods (U, L) +- **Have:** IME composition in the Type tool on macOS, Windows, Linux and the web (#322). +- **Missing:** a check by hand with a system IME on each platform, and the fixes it finds. - **Estimate:** 10–20 h. **Doc:** [localization-parity.md](localization-parity.md) ### 9. PDF output presets and standards (FF) diff --git a/docs/localization-parity.md b/docs/localization-parity.md index ea07c62b4..b69eec33c 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -28,16 +28,16 @@ come as Middle East editions with an English or French interface and right-to-le | Language | Code | InDesign 2026 UI | UI strings translated | Dialogs / tooltips / help | Script support (documents and UI) | Native review | Status | To `full` (h) | |---|---|---|---|---|---|---|---|---| | English | en | ✓ | 1,724 / 1,724 (100%) | all / all / none | Latin ✓ | yes | full | 0 | -| Simplified Chinese (Mandarin) | zh | ✓ (zh_CN) | 1,438 (83%) | menus, panels, most dialogs; tooltips partly; no help | CJK typesetting ✓ (cjk.md); **no canvas IME** (#322); CJK UI font not embedded in release builds, so boxes on Linux (#332) | no | partial | 25–40 | +| Simplified Chinese (Mandarin) | zh | ✓ (zh_CN) | 1,438 (83%) | menus, panels, most dialogs; tooltips partly; no help | CJK typesetting ✓ (cjk.md); canvas IME ✓, not checked by hand with a system IME (#322); CJK UI font not embedded in release builds, so boxes on Linux (#332) | no | partial | 25–40 | | Spanish | es | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; Spanish hyphenation ✓ (#222) | no | partial | 5–8 | | Hindi | hi | ✗ | 0 (0%) | none | Devanagari shaped by harfrust in documents but untested; no Hindi hyphenation or spelling | no | none | 20–30 | | Arabic | ar | Middle East edition (English/French UI) | 1,459 (85%) | menus, panels, most dialogs; no help | RTL UI text (`rtl.rs`: bidi + shaping); Arabic composition, kashida, digits, RTL stories and tables ✓ (arabic-typography.md); list bullets and multi-column RTL open (#100) | no | partial | 15–25 | | French | fr | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no French hyphenation | no | partial | 6–10 | | Portuguese (Brazil) | pt-br | ✓ (pt) | 1,057 (61%) | menus, panels, some dialogs; no help | Latin ✓; Portuguese hyphenation ✓ | no | partial | 8–12 | | Indonesian | id | ✗ | 0 (0%) | none | Latin ✓ | no | none | 12–18 | -| Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); **no canvas IME** (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME, shared with zh) | +| Japanese | ja | ✓ | 1,644 (95%) | whole interface (#272), vertical-type commands; no help | vertical text, kinsoku, aki, ruby, warichu ✓ (partial depth, cjk.md); canvas IME ✓, not checked by hand (#322); Japanese UI and Mincho fonts from craft-fonts | no | partial | 6–10 (+ IME checks, shared with zh) | | German | de | ✓ | 1,438 (83%) | menus, panels, most dialogs; no help | Latin ✓; no German hyphenation (InDesign ships 2006 reform patterns) | no | partial | 6–10 | -| Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; **no IME** (#322) | no | none | 15–25 | +| Korean | ko | ✓ | 0 (0%) | none | Korean line breaking at spaces ✓; canvas IME ✓, not checked by hand (#322) | no | none | 15–25 | | Vietnamese | vi | ✗ | 0 (0%) | none | Latin with stacked diacritics via harfrust, untested | no | none | 12–18 | Percentages are of the 1,724 measured strings. A language is `full` only when every user-visible @@ -68,7 +68,7 @@ locale shaping. See [typography-parity.md](typography-parity.md). | Fill the 83–85% catalogs (de, fr, es, zh, ar, it, uk; ~290 strings each) | 25–40 | | pt-BR to full | 8–12 | | New: Hindi, Indonesian, Korean, Vietnamese (1,724 strings, plural rules, tests) | 50–75 | -| Canvas IME (zh, ja, ko) | 10–20 | +| Canvas IME (zh, ja, ko): checks by hand with system IMEs, and fixes | 10–20 | | CJK UI font in every release build; Devanagari and Vietnamese rendering tests | 10–15 | | Catalog completeness test: every `tr` key and command label present in every catalog | 4–8 | | Help content (at least English, then the twelve) | 15–25 | diff --git a/docs/mcp.md b/docs/mcp.md index 7b6fadb0e..11e1a08d0 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -180,6 +180,7 @@ Use `execute` with `object.fit` to fit a selected graphic frame (or pass `ids`): | `pointer` | Mouse gesture events (`down`/`drag`/`up`/`move`/`doubleclick`) for the active tool, optional `tool` | | `key` | Key press with modifiers (headless: active tool → command shortcuts → tool shortcuts) | | `type_text` | Type at the text insertion point (or into the focused dialog field in the app) | +| `ime` | Input method events at the Type tool's caret: `preedit` (marked text in place, `active` = converting clause in characters), `commit` (typed as one step) | | `click` / `drag` | Real pointer input at screen points (connected only) | | `menu_list`, `ui_inspect`, `ui_set` | Menu tree, UI state, change UI state (connected only) | | `dialog_set` / `dialog_confirm` / `dialog_cancel` | Fill in and close the open dialog (connected only) | diff --git a/docs/roadmap.md b/docs/roadmap.md index 85424132d..331807156 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -57,7 +57,7 @@ set exists; depth gaps are in gaps.md. |---|---|---| | M0 | Skeleton + vertical slice | ✅ | | M1 | Selection, transform, layers, pages, MCP | ✅ core; handle and snapping issues (#186, #188, #337) | -| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool, composer and threading ✅; Primary Text Frame pages (#165, alpha blocker); caret formatting (#289); no IME (#322) | +| M2 | Type I (Type tool, threading, Character/Paragraph, composer) | partial: Type tool, composer and threading ✅; Primary Text Frame pages (#165, alpha blocker); caret formatting (#289); IME not checked by hand (#322) | | M3 | Styles (nested/GREP, bullets, keeps, span columns) | ✅; style editing reliability (#169) | | M4 | Color & effects | ✅ core; colour management depth | | M5 | Graphics & links | ✅ core; EPS preview only | diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 972551151..be3937239 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -61,7 +61,7 @@ they receive, setting type exactly, and basic editing that never fails. | Features (depth of each area) | 25% | 55% | 250–400 | 275 catalogue rows exist; many lack options (e.g. Books is 258 lines of engine code, Liquid Layout 329); see the area table | this file | | Typography and composition fidelity | 15% | 45% | 120–200 | Knuth–Plass composer, keeps, grids, CJK, Arabic all exist; no comparison with InDesign's line breaks; optical kerning falls back to metrics (#181); overset rule differs (#180); hyphenation patterns for English, Spanish, Portuguese and Norwegian only | [typography-parity.md](typography-parity.md) | | File formats | 20% | 40% | 150–250 (+20–60 for INDD integration) | INDD cannot be opened (#21); IDML both ways and verified in InDesign 2026, but real-file bugs open (#193, #338, #339, #340); PDF export strong; PDF/X-4 only | [file-format-parity.md](file-format-parity.md) | -| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165), canvas IME (#322) failing | [ui-parity.md](ui-parity.md) | +| UI/UX fidelity | 15% | 45% | 120–180 | Measured InDesign 2026 look; but users report object copy/paste (#351, #348, #185, #164), right-click menus (#238, #187, #160), out-port threading (#352, #194, #165) failing | [ui-parity.md](ui-parity.md) | | Stability | 10% | 45% | 60–100 | Never-crash rules and last-resort guards; startup failures on some GPUs and machines (#334, #273, #220, #192, #167, #166); no CI runs the tests on pull requests | [gaps.md](gaps.md) | | Performance | 5% | 65% | 40–80 | 300k characters compose in ~38 ms warm; typing repaints in < 1 ms (damage regions); no incremental composition; nothing benchmarked against InDesign; no large real documents | [gaps.md](gaps.md) | | Localization | 3% | 45% | 120–180 | 9 translations beyond English; Japanese 95%, most others 83–85%, pt-BR 61%; Hindi, Indonesian, Korean, Vietnamese none | [localization-parity.md](localization-parity.md) | @@ -211,7 +211,7 @@ table is the one summed. | Fill, stroke, colour | 100% | 60% | 20–35 | hex entry (#323); Assign Profiles / Convert to Profile; color-managed proofing depth; Adobe Color Themes OOS | | Effects & transparency | 100% | 50% | 20–30 | effects fidelity vs InDesign unmeasured; Clear Effects / Clear All Transparency; flattener depth | | Placing & links | 93% | 55% | 20–35 | EPS is preview-only (no PostScript interpreter); Edit Original; placed PDF layer visibility; INDD page place | -| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); canvas IME (#322); Notes/Track Changes menus; frame grids | +| Type & text frames | 99% | 45% | 40–60 | Primary Text Frame pages users can't delete (alpha blocker, #165); autoflow doesn't fill existing pages; no threading into grouped frames with the loaded cursor; caret formatting discarded (#289); Notes/Track Changes menus; frame grids | | Typography | 100% | 45% | (typography dimension) | see [typography-parity.md](typography-parity.md) | | Styles | 100% | 60% | 15–25 | Paragraph Styles edit/delete reported failing (#169); style options depth | | Tables | 100% | 55% | 25–40 | anchored objects in cells not drawn (#163); Table ▸ Table Options submenus, Diagonal Lines, Convert Rows, Edit Header/Footer | @@ -394,6 +394,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Object Export Options (`object.exportOptions`): tagged-PDF artifact, EPUB/HTML rasterize (the object as an image), alignment and page break before, alt text. - Scripts panel (Window ▸ Utilities ▸ Scripts, `script.run`): saved command scripts (with `$N.path` references to earlier results) run as one undo step; edit, add, load and delete them in the panel. Scripts use DesignCraft's command language, not ExtendScript. - Drag and drop text editing: drag selected text to move it (Alt copies) with its formatting (`text.release`). +- Input methods (IME) in the Type tool: a Chinese, Japanese or Korean composition shows its marked text in place in the story, underlined (the clause being converted twice), with the IME's candidate window at the caret through zoom, scrolling, view rotation and vertical text; the commit is one `text.insert` and one undo step, a cancelled composition leaves the document as it was; keys, shortcuts, the clipboard and Undo wait while the IME composes, and a click or another command keeps the marked text as typed. The control channel's `ui.ime` and the MCP `ime` tool (headless too) send IME events. Not yet checked by hand with a system IME (macOS, Windows, Linux, web). - Image Import Options: place any page of a multi-page PDF (`file.place {pdfPage}`; the app asks which page), shown on screen and embedded as that page in PDF export. - Define Lists: named numbered lists that continue across stories in page order (`list.define`, paragraph `listName` / `startAt`), round-tripped through IDML; `null` in `type.para` / `type.char` now removes an override. - View › Overprint Preview (⌥⇧⌘Y): overprinting fills, strokes and 100% [Black] text mix with what's beneath on screen. diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 30512bdf9..a66d3b64b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -40,7 +40,7 @@ basics of editing fail for too many users on Windows and Linux. | Threading: click out port, load text cursor, click frame | ✓ | ✗ ports drawn only; no thread/unthread command; Primary Text Frame and autoflow on Place | ✗ | **alpha blocker** (#352, #194, #165, #140) | | Copy / cut / paste objects and text, Paste Into / in Place | ✓ | ✓ on macOS | ~ | **failing on Windows/Linux** (#351, #348, #312, #185, #164, #201, #212) | | Context menus (canvas, panels, rulers) | ✓ everywhere | 9 context-menu sites, ruler units by right-click | ~ | **not opening for several users** (#238, #187, #160) | -| IME text input on the canvas | ✓ | ✗ | ✗ | #322 | +| IME text input on the canvas | ✓ | ✓ | ~ | not checked by hand with a system IME (#322) | | Font menu: search, preview, favourites, type-ahead | ✓ | ✓ | ~ | search field focus on macOS (#335, #86) | | Keyboard shortcuts: InDesign defaults, editor, sets | ✓ | ✓ editor; defaults from 09-shortcuts.md | ✓ | | | Quick Apply | ✓ | ✓ | ✓ | | @@ -64,7 +64,7 @@ context menus, typing at a caret) are things a layout artist does every minute, | Work | Opus 5.5 h | |---|---| | Copy/paste, context menus, threading clicks, caret formatting on every platform, with control-channel regression tests | 30–50 | -| Canvas IME (port from VectorCraft) | 10–20 | +| Canvas IME: checks by hand with system IMEs, and fixes | 10–20 | | Missing menu items (~150) and panels (Tabs, Navigator, Trap Presets) | 40–70 | | Handle/tool feel fixes (#186, #188, #337), font search focus | 10–20 | | Dialog depth pass against 07-dialogs.md | 30–40 |