diff --git a/ROADMAP.md b/ROADMAP.md index b5967dd01..8dc79c909 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -387,6 +387,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-11-12** · Type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus, a dialog or Quick Apply is open, or an input method is composing. An alignment the paragraphs already have records no undo step (#1138). No readiness percentage change. - **2026-11-04** · Striped strokes retain out-and-back cubic segments whose second control point is away from the coincident endpoints and first control point. A geometry regression checks the offset extent and both stripe bands. No readiness percentage change. - **2026-11-04** · Esperanto is a text language (Character › Language, `type.char {language: "Esperanto"}`, IDML `$ID/Esperanto`): tag `eo`, English-style quotes; not hyphenated yet (#1201). No readiness percentage change. @@ -860,5 +861,3 @@ The shared craft-installer UI preserves this revision's install options and prov - **2026-10-15** · Windows recovery folders validate the identity of the live locked file without reading it through a second handle. Missing or replaced locks disable writes and cleanup; regression tests cover retained exclusive locking and replacement-file protection. No readiness percentage change. - PDF reader infrastructure: pinned shared parsing/rasterization, transformed page-box geometry and explicit failures for known incomplete layered-PDF exports. Application ownership and parity targets are unchanged. - -- **2026-10-16** · InDesign's type shortcuts: Justify All Lines ⇧⌘F, Insert White Space › Em Space ⇧⌘M and En Space ⇧⌘N (shown in the menu, enabled at an insertion point), Paragraph Rules… ⌥⌘J (shown in the Paragraph panel menu). On the Mac a menu-bar insert (Em/En Space, Current Page Number) no longer types into the document while a text field has focus. No readiness percentage change. diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index eb6e3e0b5..7f8def96c 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -2,7 +2,7 @@ use designcraft_compose as compose; use designcraft_doc::story::floor_char_boundary; -use designcraft_doc::{CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel}; +use designcraft_doc::{Align, CellAddr, CharAttrs, Content, ItemId, ParaAttrs, Selection, StoryId, TextSel}; use designcraft_geom::Point; use serde_json::{Value, json}; @@ -240,21 +240,19 @@ pub fn specs() -> Vec { ), cmd!("type.align", "Align", [], None, "{align: left|center|right|leftJustified|…|null} (required)", has_text_or_frames, |s, p| { let align = p.get("align").cloned().ok_or_else(|| bad("type.align", "`align` required"))?; - format_paras(s, &json!({ "align": align })) + // A value that isn't an alignment goes to `format_paras`, which names the error. + match serde_json::from_value::>(align.clone()) { + Ok(a) => align_paras(s, a), + Err(_) => format_paras(s, &json!({ "align": align })), + } }), - cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "left"}))), - cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| format_paras( + cmd!("type.alignLeft", "Align Left", [], Some("Cmd+Shift+L"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::Left))), + cmd!("type.alignCenter", "Align Center", [], Some("Cmd+Shift+C"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::Center))), + cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::Right))), + cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| align_paras(s, Some(Align::LeftJustified))), + cmd!("type.justifyAll", "Justify all lines", [], Some("Cmd+Shift+F"), "{}", has_text_or_frames, |s, _| align_paras( s, - &json!({"align": "center"}) - )), - cmd!("type.alignRight", "Align Right", [], Some("Cmd+Shift+R"), "{}", has_text_or_frames, |s, _| format_paras(s, &json!({"align": "right"}))), - cmd!("type.justify", "Justify Left", [], Some("Cmd+Shift+J"), "{}", has_text_or_frames, |s, _| format_paras( - s, - &json!({"align": "leftJustified"}) - )), - cmd!("type.justifyAll", "Justify all lines", [], Some("Cmd+Shift+F"), "{}", has_text_or_frames, |s, _| format_paras( - s, - &json!({"align": "fullyJustified"}) + Some(Align::FullyJustified) )), cmd!( "type.bold", @@ -448,24 +446,164 @@ pub fn specs() -> Vec { ] } +/// Sets the alignment of the paragraphs a formatting command targets (`None` removes the +/// override, back to the style's value). When each of them already carries that value as its +/// own, or there is no paragraph to change, the document is left as it is and no undo step is +/// recorded. +fn align_paras(s: &mut Session, align: Option) -> Result { + let targets = format_targets(s); + let doc = &s.doc()?.doc; + let unchanged = targets.iter().all(|t| { + doc.text_story(t.story, t.cell).is_none_or(|st| { + let (a, b) = (st.para_at(t.range.start), st.para_at(t.range.end.max(t.range.start))); + st.paras.get(a..=b).is_none_or(|paras| paras.iter().all(|p| p.para.align == align)) + }) + }); + if unchanged { + return ok(); + } + format_paras(s, &json!({ "align": align })) +} + #[cfg(test)] mod alignment_shortcut_tests { - use serde_json::json; + use designcraft_doc::{Align, Document}; + use serde_json::{Value, json}; use crate::Session; - /// InDesign's Justify All Lines is ⇧⌘F (Shift+Ctrl+F), next to Justify Left ⇧⌘J. + fn undo_steps(s: &Session) -> usize { + s.doc().unwrap().history.undo.len() + } + + fn selection_align(s: &mut Session) -> Value { + s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"].clone() + } + + /// The resolved alignment of every paragraph of the story whose text starts with `start`. + fn story_aligns(d: &Document, start: &str) -> Vec { + let st = d.stories.values().find(|st| st.text.starts_with(start)).unwrap(); + st.paras.iter().map(|p| d.styles.resolve_para(p).0.align).collect() + } + + /// Justify All Lines is ⇧⌘F (Shift+Ctrl+F), next to Justify Left ⇧⌘J. #[test] - fn justify_all_lines_has_indesigns_shortcut_and_justifies_the_last_line_too() { + fn justify_all_lines_has_its_shortcut_and_justifies_the_last_line_too() { let mut s = Session::new(); s.execute("file.new", &json!({})).unwrap(); let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring issue"})).unwrap(); s.execute("text.select", &json!({"story": r["story"], "anchor": 3, "focus": 3})).unwrap(); - // From Justify Left, so the result is not a default or a neighbouring justification. + // From Justify Left, so the result is not a default or a neighboring justification. s.execute("type.justify", &json!({})).unwrap(); assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "leftJustified"); s.execute("type.justifyAll", &json!({})).unwrap(); assert_eq!(s.execute("type.selectionAttrs", &json!({})).unwrap()["para"]["align"], "fullyJustified"); assert_eq!(crate::find_command("type.justifyAll").and_then(|c| c.shortcut), Some("Cmd+Shift+F")); } + + /// A second press of an alignment shortcut, or `type.align` with the value the paragraphs + /// already have, leaves the document and the undo list alone. + #[test] + fn an_alignment_the_paragraphs_already_have_records_no_undo_step() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One\nTwo"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + for (id, params, align) in [ + ("type.justifyAll", json!({}), "fullyJustified"), + ("type.align", json!({"align": "center"}), "center"), + ("type.alignLeft", json!({}), "left"), + ("type.alignCenter", json!({}), "center"), + ("type.alignRight", json!({}), "right"), + ("type.justify", json!({}), "leftJustified"), + ] { + let before = undo_steps(&s); + s.execute(id, ¶ms).unwrap(); + assert_eq!(selection_align(&mut s), align, "{id}"); + assert_eq!(undo_steps(&s), before + 1, "{id}: one step"); + let rev = s.doc().unwrap().doc.clone(); + s.execute(id, ¶ms).unwrap(); + assert_eq!(undo_steps(&s), before + 1, "{id} again: no step"); + assert!(std::sync::Arc::ptr_eq(&rev, &s.doc().unwrap().doc), "{id} again: the document is unchanged"); + } + // Removing the override: once it is gone, a second removal does nothing. + s.execute("type.align", &json!({"align": null})).unwrap(); + let before = undo_steps(&s); + s.execute("type.align", &json!({"align": null})).unwrap(); + assert_eq!(undo_steps(&s), before); + // One paragraph that differs is enough to apply it, to both. + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.justifyAll", &json!({})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + let before = undo_steps(&s); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), before + 1); + assert_eq!(story_aligns(&s.doc().unwrap().doc, "One"), [Align::FullyJustified; 2]); + } + + /// The shortcut commands take no parameters: a misspelled call is refused and changes nothing. + #[test] + fn justify_all_lines_refuses_parameters() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 1, "focus": 1})).unwrap(); + let before = undo_steps(&s); + assert!(s.execute("type.justifyAll", &json!({"align": "left"})).is_err()); + assert_eq!(selection_align(&mut s), "left"); + assert_eq!(undo_steps(&s), before); + } + + /// With text frames selected, every paragraph of their stories, across threaded frames; + /// other selected frames are skipped, and a selection with no text frame is refused. + #[test] + fn justify_all_lines_on_frames_reaches_threaded_text_and_skips_other_frames() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let a = s.execute("frame.create", &json!({"rect": [36, 36, 300, 60], "content": "text", "text": "Alpha\nBeta\nGamma\nDelta"})).unwrap(); + let b = s.execute("frame.create", &json!({"rect": [36, 300, 300, 500], "content": "text"})).unwrap(); + s.execute("text.thread", &json!({"from": a["id"], "to": b["id"]})).unwrap(); + let rect = s.execute("frame.create", &json!({"rect": [400, 36, 500, 136]})).unwrap(); + s.execute("selection.set", &json!({"ids": [rect["id"]]})).unwrap(); + let before = undo_steps(&s); + assert!(s.execute("type.justifyAll", &json!({})).is_err(), "no text frame selected"); + assert_eq!(undo_steps(&s), before); + // The second frame of the thread and the rectangle. + s.execute("selection.set", &json!({"ids": [b["id"], rect["id"]]})).unwrap(); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(undo_steps(&s), before + 1); + assert_eq!(story_aligns(&s.doc().unwrap().doc, "Alpha"), [Align::FullyJustified; 4]); + } + + /// Over a paragraph style's alignment, Justify All Lines is a local override: the style keeps + /// its value, and removing the override goes back to it. + #[test] + fn justify_all_lines_overrides_the_paragraph_style() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring issue"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 2, "focus": 2})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Caption", "para": {"align": "right"}})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Caption"})).unwrap(); + assert_eq!(selection_align(&mut s), "right"); + s.execute("type.justifyAll", &json!({})).unwrap(); + assert_eq!(selection_align(&mut s), "fullyJustified"); + let d = &s.doc().unwrap().doc; + assert_eq!(d.styles.resolve_para_style("Caption").0.align, Align::Right); + s.execute("type.align", &json!({"align": null})).unwrap(); + assert_eq!(selection_align(&mut s), "right"); + } + + /// IDML writes the paragraph's Justification as FullyJustified and reads it back. + #[test] + fn justify_all_lines_survives_idml_export_and_import() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text", "text": "Spring\nissue"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("type.justifyAll", &json!({})).unwrap(); + let bytes = designcraft_idml::export_idml(&s.doc().unwrap().doc); + s.execute("file.openIdml", &json!({"base64": crate::cmd::base64_encode(&bytes), "name": "in.idml"})).unwrap(); + assert_eq!(story_aligns(&s.doc().unwrap().doc, "Spring"), [Align::FullyJustified, Align::Left]); + } } diff --git a/crates/ui-egui/src/menus/invoke.rs b/crates/ui-egui/src/menus/invoke.rs index 33096849d..492cdc7e2 100644 --- a/crates/ui-egui/src/menus/invoke.rs +++ b/crates/ui-egui/src/menus/invoke.rs @@ -146,9 +146,11 @@ pub fn activate_native(app: &mut DesignApp, ctx: &egui::Context, id: &str, param if params.is_null() && ctx.text_edit_focused() && forward_to_text_edit(app, ctx, id) { return; } - // The menu bar takes ⇧⌘M before the window: with a field focused (Story Editor, Find/Change) - // or a dialog open, the character would land at the canvas caret behind it. - if params.is_null() && (ctx.text_edit_focused() || app.ui.dialog.is_some()) && TEXT_INSERTS.contains(&id) { + // The menu bar takes ⇧⌘M before the window: with a field focused (Story Editor, Find/Change), + // a dialog or Quick Apply open, an input method composing or a context menu open, the + // character would land at the canvas caret behind it. The window's shortcuts stop for the + // same cases. + if params.is_null() && (ctx.text_edit_focused() || super::keys::keys_taken(app, ctx)) && TEXT_INSERTS.contains(&id) { return; } // The canvas text editor is not an egui TextEdit, so its clipboard events must pass through diff --git a/crates/ui-egui/src/menus/keys.rs b/crates/ui-egui/src/menus/keys.rs index 8b2179469..a4cf59eed 100644 --- a/crates/ui-egui/src/menus/keys.rs +++ b/crates/ui-egui/src/menus/keys.rs @@ -66,7 +66,7 @@ pub fn forwarded_shortcuts(app: &mut DesignApp, ctx: &egui::Context, events: Vec /// Whether something other than the shortcuts has the keyboard: a dialog or Quick Apply, an input /// method composing at the text caret, an open canvas context menu (Escape closes it, as a /// native menu would) or the menu search. -fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool { +pub(super) fn keys_taken(app: &DesignApp, ctx: &egui::Context) -> bool { app.ui.dialog.is_some() || app.ui.palette.is_some() || app.session.tool_composing() diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index 5958bcd4d..24ccb02a0 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -2499,14 +2499,14 @@ fn story_text(app: &crate::DesignApp, story: &Value) -> String { } #[test] -fn indesigns_type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_rules() { +fn type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_rules() { use egui::{Key, Modifiers}; let ctx = egui::Context::default(); let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); let frame = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap(); let story = frame["story"].clone(); - // A selected frame and no insertion point: the spaces have nowhere to go, as in InDesign. + // A selected frame and no insertion point: the spaces have nowhere to go. app.run("selection.set", json!({"ids": [frame["id"]]})).unwrap(); assert!(app.session.doc().unwrap().selection.text.is_none()); assert!(!menu_enabled(&app, "app.insertEmSpace") && !menu_enabled(&app, "app.insertEnSpace")); @@ -2521,7 +2521,7 @@ fn indesigns_type_shortcuts_insert_spaces_justify_all_lines_and_open_paragraph_r app.run("type.justify", json!({})).unwrap(); press(&mut app, &ctx, Key::F, Modifiers::COMMAND | Modifiers::SHIFT); assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["align"], "fullyJustified", "⇧⌘F justifies all lines"); - // Guard: ⌥⇧⌘J is another chord (InDesign's Justification…); the rules want Shift up. + // Guard: ⌥⇧⌘J is another chord (Justification…); the rules want Shift up. press(&mut app, &ctx, Key::J, Modifiers::COMMAND | Modifiers::ALT | Modifiers::SHIFT); assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("justification"), "⌥⇧⌘J opens Justification"); app.ui.dialog = None; @@ -2581,6 +2581,27 @@ fn native_insert_shortcuts_leave_the_document_alone_while_a_field_has_focus() { assert_eq!(app.ui.palette.as_deref(), Some("x")); } +#[test] +fn native_insert_shortcuts_leave_the_document_alone_while_a_dialog_or_quick_apply_is_open() { + // The same cases stop the window's shortcuts. In the first frame nothing has keyboard focus + // yet, so only the open dialog or Quick Apply keeps the character out of the story. + for open in ["", "dialog", "palette"] { + let ctx = egui::Context::default(); + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({})).unwrap(); + let story = app.run("frame.create", json!({"rect": [36, 36, 300, 200], "content": "text", "text": "ab"})).unwrap()["story"].clone(); + app.run("text.select", json!({"story": story, "anchor": 1, "focus": 1})).unwrap(); + match open { + "dialog" => app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphRules", json!({}))), + "palette" => app.ui.palette = Some(String::new()), + _ => {} + } + native_frame(&mut app, &ctx, vec![], Some("app.insertEmSpace")); + let want = if open.is_empty() { "a\u{2003}b" } else { "ab" }; + assert_eq!(story_text(&app, &story), want, "{open:?}"); + } +} + #[test] fn custom_workspaces_save_apply_and_delete() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());