Type: a repeated alignment records no undo step; menu-bar inserts stop with the window's shortcuts

- Align Left, Center, Right, Justify Left, Justify All Lines and
  type.align leave the document and the undo list alone when every
  target paragraph already has that alignment as its own value.
- On the Mac, Em Space, En Space and Current Page Number from the menu
  bar also do nothing while Quick Apply or a dialog is open, an input
  method is composing or a canvas context menu is open, the cases that
  stop the window's own shortcuts.
- Tests for both, and for Justify All Lines across threaded frames,
  over a paragraph style, through IDML export and import, and with
  parameters it does not take.
This commit is contained in:
dotCooCoo committed 2026-11-11 20:48:43 -08:00
1 parent fc09aa9124
commit 9cea62b238
5 files changed
+187 -27

No files matched your search

+1 -2
View File
@@ -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.
+156 -18
View File
@@ -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<CommandSpec> {
),
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::<Option<Align>>(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<CommandSpec> {
]
}
/// 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<Align>) -> Result<Value> {
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<Align> {
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, &params).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, &params).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]);
}
}
+5 -3
View File
@@ -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
+1 -1
View File
@@ -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()
+24 -3
View File
@@ -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());