# Conflicts:
#	crates/engine/src/cmd/text.rs
This commit is contained in:
Brandon Thomas
2026-10-14 23:31:45 -04:00
4 changed files with 243 additions and 18 deletions
+100
View File
@@ -55,6 +55,18 @@ pub fn specs() -> Vec<CommandSpec> {
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, _| {
@@ -508,6 +520,30 @@ fn release(s: &mut Session, p: &Value) -> Result<Value> {
})
}
/// `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<Value> {
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<Value> {
let text = super::text_param(p, "text");
// Tab in a table cell moves to the next cell (Shift-Tab: `table.prevCell`).
@@ -2908,3 +2944,67 @@ 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());
}
}
}
+8
View File
@@ -291,6 +291,9 @@ pub struct Session {
pub(crate) text_drag: bool,
/// Character formatting chosen at a bare caret, for the text typed there next.
pub(crate) typing_format: Option<cmd::text::TypingFormat>,
/// 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<cmd::library::Library>,
/// The open book (File › Open Book).
@@ -324,6 +327,7 @@ impl Session {
transforms: Default::default(),
text_drag: false,
typing_format: None,
composition: None,
library: None,
conveyor: Vec::new(),
book: None,
@@ -433,6 +437,10 @@ impl Session {
fn execute_unguarded(&mut self, id: &str, params: &Value) -> Result<Value> {
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.
+24
View File
@@ -2,6 +2,7 @@
use std::sync::Arc;
use designcraft_doc::{CellAddr, StoryId};
use designcraft_geom::Unit;
pub use designcraft_tools::SnapView;
use designcraft_tools::{Action, CanvasLayout, Cursor, Mods, Overlay, PointerEvent, ToolContext, ToolKey};
@@ -60,6 +61,7 @@ impl Session {
if self.tool.id() == id {
return;
}
self.end_composition();
self.tool = designcraft_tools::create(id);
// Any other tool unloads the place cursor.
if id != "placeGun" {
@@ -150,7 +152,29 @@ impl Session {
Ok(())
}
/// The text an input method is composing at the caret, as story bytes: it is in the
/// document as a preview until `text.compose` commits or drops it.
pub fn composition(&self) -> Option<(StoryId, Option<CellAddr>, std::ops::Range<usize>)> {
let (uid, len) = self.composition?;
let st = self.active().filter(|st| st.uid == uid)?;
let t = st.selection.text?;
Some((t.story, t.cell, t.focus.checked_sub(len)?..t.focus))
}
/// Drop the text being composed: the document goes back to how it was before it.
pub fn end_composition(&mut self) {
let Some((uid, _)) = self.composition.take() else { return };
if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid)
&& let Some(it) = st.interaction.take()
{
st.doc = it.doc;
st.selection = it.selection;
st.revision += 1;
}
}
pub fn begin_interaction(&mut self, label: &str) -> Result<()> {
self.end_composition();
let st = self.doc_mut()?;
st.interaction = Some(Interaction { label: label.into(), doc: st.doc.clone(), selection: st.selection.clone(), preview: None });
Ok(())
+111 -18
View File
@@ -1044,7 +1044,11 @@ fn draw_text_selection(
cells: Option<designcraft_doc::TableSel>,
) {
let cs = app.session.cache.get(doc, ts.story, None);
let range = ts.range();
// Text an input method is composing at the caret is marked like it is in every Mac text
// field: a yellow box behind the glyphs.
let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2);
let range = composing.clone().unwrap_or_else(|| ts.range());
let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.7) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) };
// Selected table cells: tint every selected cell.
if let Some(sel) = cells.filter(|c| c.story == ts.story) {
let selected = doc.story(sel.story).and_then(|s| s.tables.get(&sel.table)).map(|t| t.selected_owners(sel.range)).unwrap_or_default();
@@ -1097,7 +1101,7 @@ fn draw_text_selection(
let (a, b) = (x0 + (x1 - x0) * k as f64 / n as f64, x0 + (x1 - x0) * (k + 1) as f64 / n as f64);
let poly =
[Point::new(a, y0), Point::new(b, y0), Point::new(b, y1), Point::new(a, y1)].map(|p| xf.to_screen(m * map.to_path(p)));
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE));
}
}
}
@@ -1133,7 +1137,7 @@ fn draw_text_selection(
}
}
if !runs.is_empty() {
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs);
draw_inverse_highlight(app, painter, xf, doc, &cs, ts.story, &runs, composing.is_some());
}
if ts.is_caret()
&& let Some((fi, x, bl, asc, desc)) = compose::caret(&cs, ts.focus)
@@ -1141,22 +1145,38 @@ fn draw_text_selection(
&& let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame))
{
let m = a * doc.text_xf(it);
// Type on a path: the caret stands across the path where it is along it.
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
// Type on a path: the caret stands across the path where it is along it.
let on = |p: Point| compose::PathTextMap::new(it, ft).map_or(p, |map| map.to_path(p));
let p0 = xf.to_screen(m * on(Point::new(x, bl - asc)));
let p1 = xf.to_screen(m * on(Point::new(x, bl + desc)));
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
}
remember_ime_caret(painter, p0, p1);
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
}
}
/// Where the text caret stood on screen last frame: the OS puts its input-method window there.
fn ime_caret_id() -> egui::Id {
egui::Id::new("canvas_ime_caret")
}
/// The input method is composing text (sent to `text.compose`) that it hasn't committed.
fn ime_composing_id() -> egui::Id {
egui::Id::new("canvas_ime_composing")
}
/// Remembers the caret at `p0`–`p1` for the input method's window.
fn remember_ime_caret(painter: &egui::Painter, p0: egui::Pos2, p1: egui::Pos2) {
painter.ctx().data_mut(|d| d.insert_temp(ime_caret_id(), egui::Rect::from_two_pos(p0, p1)));
}
/// Caret / text highlight inside a table cell (the cell's lines are offset by the cell origin).
#[allow(clippy::too_many_arguments)]
fn draw_cell_selection(
_app: &DesignApp,
app: &DesignApp,
painter: &egui::Painter,
xf: &Xf,
doc: &Document,
@@ -1174,7 +1194,9 @@ fn draw_cell_selection(
let Some(ft) = cs.frames.get(fi) else { return };
let (Some((a, _)), Some(it)) = (item_canvas_xf(doc, layout, ft.frame), doc.item(ft.frame)) else { return };
let m = a * doc.text_xf(it) * designcraft_geom::Affine::translate(origin.to_vec2());
let range = ts.range();
let composing = app.session.composition().filter(|c| c.0 == ts.story && c.1 == ts.cell && ts.is_caret()).map(|c| c.2);
let range = composing.clone().unwrap_or_else(|| ts.range());
let tint = if composing.is_some() { COMPOSING_FILL.gamma_multiply(0.6) } else { Color32::from_rgba_unmultiplied(80, 140, 255, 110) };
if !range.is_empty()
&& let Some(cft) = text.frames.first()
{
@@ -1187,7 +1209,7 @@ fn draw_cell_selection(
let past = e == l.range.end && range.end > l.range.end;
for q in compose::highlight_quads(l, s, e, past) {
let poly = q.map(|p| xf.to_screen(m * p));
painter.add(egui::Shape::convex_polygon(poly.to_vec(), Color32::from_rgba_unmultiplied(80, 140, 255, 110), Stroke::NONE));
painter.add(egui::Shape::convex_polygon(poly.to_vec(), tint, Stroke::NONE));
}
}
}
@@ -1198,20 +1220,25 @@ fn draw_cell_selection(
}
{
let m = a * doc.text_xf(it);
let (p0, p1) = (xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc)));
let blink = (painter.ctx().input(|i| i.time) * 1.6) as i64 % 2 == 0;
if blink {
painter.line_segment(
[xf.to_screen(m * Point::new(x, bl - asc)), xf.to_screen(m * Point::new(x, bl + desc))],
Stroke::new(1.0, Color32::BLACK),
);
painter.line_segment([p0, p1], Stroke::new(1.0, Color32::BLACK));
}
remember_ime_caret(painter, p0, p1);
painter.ctx().request_repaint_after(std::time::Duration::from_millis(330));
}
}
/// The box behind text an input method is composing.
const COMPOSING_FILL: Color32 = Color32::from_rgb(255, 247, 153);
/// InDesign draws selected text inverted: a black highlight with the selected glyphs in the
/// inverse of their colour (white for black text). The highlight and glyph outlines are
/// rasterised with `vello_cpu` into a small screen-space texture over the selection.
/// `composing`: the text is an input method's, still being composed: a yellow box with the
/// glyphs in their own colour.
#[allow(clippy::too_many_arguments)]
fn draw_inverse_highlight(
app: &DesignApp,
painter: &egui::Painter,
@@ -1220,6 +1247,7 @@ fn draw_inverse_highlight(
cs: &compose::ComposedStory,
story: designcraft_doc::StoryId,
runs: &[(usize, Affine, Vec<[Point; 4]>, Vec<(usize, usize)>)],
composing: bool,
) {
use designcraft_render::vello_cpu::{self, kurbo, peniko};
let ctx = painter.ctx();
@@ -1246,7 +1274,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::<usize>(), w, h))
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus))));
.with((bbox.min.x.to_bits(), bbox.min.y.to_bits(), xf.zoom.to_bits(), st.selection.text.map(|t| (t.anchor, t.focus)), composing));
let cache_id = egui::Id::new("inverse_sel_tex");
let cached: Option<(egui::Id, egui::TextureHandle)> = ctx.data(|d| d.get_temp(cache_id));
let tex = match cached {
@@ -1254,7 +1282,8 @@ fn draw_inverse_highlight(
_ => {
let view = xf.affine(ppp as f64, bbox.min);
let mut rc = vello_cpu::RenderContext::new_with(w as u16, h as u16, vello_cpu::RenderSettings { num_threads: 0, ..Default::default() });
rc.set_paint(peniko::Color::from_rgba8(0, 0, 0, 255));
let [br, bg, bb, _] = if composing { COMPOSING_FILL.to_array() } else { [0, 0, 0, 255] };
rc.set_paint(peniko::Color::from_rgba8(br, bg, bb, 255));
for (_, m, quads, _) in runs {
rc.set_transform(view * *m);
for q in quads {
@@ -1275,8 +1304,12 @@ fn draw_inverse_highlight(
let Some(g) = l.glyphs.get(gi) else { continue };
let Some(style) = cs.styles.get(g.style as usize) else { continue };
let Some(c) = doc.resolve_color(&style.fill, style.fill_tint) else { continue };
let [r, gg, b, _] = c.to_rgba8(1.0);
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
let [r, gg, b, a] = c.to_rgba8(1.0);
if composing {
rc.set_paint(peniko::Color::from_rgba8(r, gg, b, a));
} else {
rc.set_paint(peniko::Color::from_rgba8(255 - r, 255 - gg, 255 - b, 255));
}
let outline = db.outline(&g.face, g.gid);
let skew =
if style.skew != 0.0 { Affine::new([1.0, 0.0, -style.skew.to_radians().tan(), 1.0, 0.0, 0.0]) } else { Affine::IDENTITY };
@@ -1664,12 +1697,41 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r
if app.pane != app.focus_pane || (ui.ctx().text_edit_focused() && !resp.has_focus()) {
return;
}
// The engine drops composed text when something else edits or the caret moves; the input
// method must forget it too, or the next key would finish an accent that is gone.
let composing = ui.data(|d| d.get_temp::<bool>(ime_composing_id())).unwrap_or(false);
let interrupt = composing && app.session.composition().is_none();
if interrupt || !wants_text {
ui.data_mut(|d| d.remove::<bool>(ime_composing_id()));
}
if wants_text {
// Turn the OS input method on: macOS composes dead keys (⌥E then I types í) and
// Japanese/Chinese input only for a window that asks for it. Without it ⌥E typed "e".
let cursor_rect = ui.data(|d| d.get_temp(ime_caret_id())).unwrap_or(egui::Rect::from_min_size(rect.min, egui::Vec2::ZERO));
ui.ctx().output_mut(|o| {
o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Normal, rect, cursor_rect, should_interrupt_composition: interrupt });
});
}
let evs = ui.input(|i| i.events.clone());
for e in evs {
match e {
egui::Event::Text(t) if wants_text => {
let _ = app.run("text.insert", json!({"text": t}));
}
// What is composed shows in the story, in the text's own format, until it commits.
egui::Event::Ime(egui::ImeEvent::Preedit { text, .. }) if wants_text => {
let on = !text.is_empty();
if app.run("text.compose", json!({"text": text})).is_ok() {
ui.data_mut(|d| d.insert_temp(ime_composing_id(), on));
}
}
egui::Event::Ime(egui::ImeEvent::Commit(t)) if wants_text => {
ui.data_mut(|d| d.remove::<bool>(ime_composing_id()));
let _ = app.run("text.compose", json!({"text": ""}));
if !t.is_empty() {
let _ = app.run("text.insert", json!({"text": t}));
}
}
egui::Event::Paste(t) if wants_text => {
// Formatted when the system clipboard still holds what was copied here;
// Shift (⇧⌘V) pastes without formatting.
@@ -2342,6 +2404,37 @@ mod tests {
assert!(selected(&h).is_empty(), "three clicks half a second apart leave a caret: {:?}", selected(&h));
}
/// ⌥E then I types "í" on a Mac: the input method composes the dead key's accent, which
/// it does only for a window that turned it on. The canvas never did, so ⌥E typed "e".
#[test]
fn dead_keys_compose_through_the_input_method() {
let (mut h, id) = typing("T");
h.state_mut().run("text.placeCaret", json!({"frame": id, "point": [390.0, 80.0]})).unwrap();
h.run_steps(2);
assert!(h.output().platform_output.ime.is_some(), "a text caret turns the input method on");
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "´".into(), active_range_chars: None }));
h.run_steps(2);
let text = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.stories.values().map(|s| s.text.clone()).collect::<String>();
assert_eq!(text(&h), "T´", "the accent being composed shows in the story, in its format");
assert!(h.state().session.composition().is_some(), "and is marked as composed, not typed");
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: String::new(), active_range_chars: None }));
h.event(egui::Event::Ime(egui::ImeEvent::Commit("í".into())));
h.run_steps(2);
assert_eq!(text(&h), "Tí");
assert!(h.state().session.composition().is_none());
// Moving the caret mid-composition drops the accent and tells the input method so.
h.event(egui::Event::Ime(egui::ImeEvent::Preedit { text: "˜".into(), active_range_chars: None }));
h.run_steps(2);
h.state_mut().run("text.move", json!({"dir": "left"})).unwrap();
h.step();
assert_eq!(text(&h), "Tí");
assert!(h.output().platform_output.ime.as_ref().is_some_and(|i| i.should_interrupt_composition));
// Another tool takes no text: the input method is off.
h.state_mut().run("tool.select", json!({"tool": "selection"})).unwrap();
h.run_steps(2);
assert!(h.output().platform_output.ime.is_none());
}
#[test]
fn rulers_open_no_units_menu_without_a_document() {
let mut h = harness(DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()));