diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index d4f58602d..494ff0b69 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -9,7 +9,8 @@ //! Fields: `wordMin`, `wordDesired`, `wordMax` (0..1000), `letterMin`, `letterDesired`, //! `letterMax` (-100..500), `glyphMin`, `glyphDesired`, `glyphMax` (50..200), `autoLeading` //! (0..500), `singleWord` (`fullJustify`, `alignLeft`, `alignCenter`, `alignRight`) and `preview`, -//! with `__initial` (the values it opened with) and `__targets` (the type and range it acts on). +//! with `__initial` (the values it opened with), `__targets` (the type and range it acts on) and, +//! after Select All in threaded text, `__story` (the edited frame, whose story it acts on). //! //! Behaviour reference (public documentation): //! . @@ -29,6 +30,9 @@ pub const KIND: &str = "justification"; const INITIAL: &str = "__initial"; /// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on. const TARGETS: &str = "__targets"; +/// The field holding the frame the Type tool edits when the dialog opened after Select All in +/// threaded text: the dialog then acts on that frame's story as the document holds it. +const STORY: &str = "__story"; const CMD: &str = "text.setFormat"; pub(super) const SPEC: DialogSpec = DialogSpec { @@ -85,13 +89,12 @@ pub fn open(app: &mut VectorcraftApp) -> Result { crate::panels::character::text_style(app).ok_or("select some type first")?; // While typing, the dialog acts on the paragraphs of the text being edited. let mut targets = json!({}); + let mut story = None; if let Some((id, a, b)) = crate::panels::character::text_editing(app) { crate::panels::character::end_typing(app); - targets = match crate::panels::character::text_story(app) { - // After Select All in threaded text: every paragraph of every frame. - Some(story) => json!({"ids": story.iter().map(|f| f.0).collect::>()}), - None => json!({"ids": [id.0], "start": a, "end": b}), - }; + targets = json!({"ids": [id.0], "start": a, "end": b}); + // After Select All in threaded text: every paragraph of every frame ([`params`]). + story = crate::panels::character::text_story(app).map(|_| id.0); } let mut values = Map::new(); for (_, prefix, _) in ROWS { @@ -115,6 +118,9 @@ pub fn open(app: &mut VectorcraftApp) -> Result { let mut fields = Value::Object(values.clone()); fields[INITIAL] = Value::Object(values); fields[TARGETS] = targets; + if let Some(id) = story { + fields[STORY] = json!(id); + } fields["preview"] = json!(false); app.ui.dialog = Some(Dialog::new(KIND, fields)); Ok(Value::Null) @@ -179,17 +185,30 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { ui.add_space(6.0); ui.colored_label(crate::theme::Tokens::get(ui.ctx()).error, why); } - None => form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(params(d))), + None => { + let p = params(app, d); + form::preview(app, ui, d, tl!("Justification"), CMD, Value::Object(p)); + } } false } /// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, -/// on the dialog's type and range. -fn params(d: &Dialog) -> Map { +/// on the dialog's type and range. After Select All in threaded text the type is every frame of +/// the edited frame's thread as the document holds it now; when that frame is no longer threaded, +/// the range in that frame. +fn params(app: &VectorcraftApp, d: &Dialog) -> Map { let initial = d.fields.get(INITIAL).and_then(Value::as_object); let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_f64); let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default(); + if let (Some(id), Some(st)) = (d.fields.get(STORY).and_then(Value::as_u64), app.session.active()) { + let story = st.doc.story_of(vectorcraft_doc::NodeId(id)); + if story.len() >= 2 { + p.remove("start"); + p.remove("end"); + p.insert("ids".into(), json!(story.iter().map(|f| f.0).collect::>())); + } + } for (param, prefix, _) in ROWS { let mut changed = Map::new(); for (suffix, name) in COLUMNS { @@ -229,7 +248,7 @@ fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result { if let Some(why) = problem(d) { return Err(why.into()); } - let p = params(d); + let p = params(app, d); if !changes(&p) { if app.session.in_interaction() { let _ = app.session.cancel_interaction(); @@ -352,6 +371,33 @@ mod tests { assert_eq!(paras(&app, id).0.justification.auto_leading, 120.0); } + /// After Select All in threaded text, OK changes the frames in the edited frame's thread when OK + /// is pressed: not a frame that left the thread while the dialog was open, and a frame that + /// joined it. + #[test] + fn justification_dialog_follows_the_story_thread_at_ok() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 300})).unwrap(); + let story = "The quick brown fox jumps over the lazy dog and keeps running far beyond the first frame.\n".repeat(6); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 60}}); + let a = app.run("text.create", area).unwrap()["id"].as_u64().unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [400, 10, 150, 200]})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + app.run("ui.justification", json!({})).unwrap(); + app.run("text.thread.break", json!({"id": b, "port": "out"})).unwrap(); + let d = app.run("text.thread.add", json!({"from": a, "rect": [600, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + set(&mut app, "autoLeading", json!(140)); + super::super::confirm(&mut app).unwrap(); + for id in [a, d, b] { + assert_eq!(paras(&app, id).0.justification.auto_leading, 140.0, "frame {id} takes the change"); + } + assert_eq!(paras(&app, c).0.justification.auto_leading, 120.0, "the frame that left keeps its paragraphs"); + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() { diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index d1b4b57fc..67e589104 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -72,11 +72,18 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m })); } -/// Paragraphs touched by the Type selection, or all paragraphs of selected text objects. +/// Paragraphs touched by the Type selection (every paragraph of every frame after Select All in +/// threaded text), or all paragraphs of selected text objects. fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { - if let NodeKind::Text(text) = &node.kind { + if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { + for frame in story.iter().filter_map(|id| st.doc.node(*id)) { + if let NodeKind::Text(text) = &frame.kind { + append_indent_targets(frame, 0..text.paragraph_count(), &mut targets); + } + } + } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } } else if let Some(st) = app.session.active() { diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 7a92ff80c..42aea4317 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -404,6 +404,66 @@ fn lower_marker_keeps_each_first_line_position_in_one_undo_step() { assert_eq!(indent_values(&app, 1), (40.0, 28.0)); } +/// After Select All in threaded text, a marker change keeps the left indent and first-line position +/// of each paragraph in every frame of the story, when the edited frame's paragraphs agree and +/// another frame's differ. +#[test] +fn indent_markers_after_select_all_keep_each_story_paragraphs_indents() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 300})).unwrap(); + let story = (1..=12).map(|i| format!("line {i}")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 200, "height": 60}})).unwrap()["id"] + .as_u64() + .unwrap(); + app.run("text.setFormat", json!({"ids": [a], "leftIndent": 24, "firstLineIndent": 12})).unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 250]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 60, "firstLineIndent": -10})).unwrap(); + let paragraphs = |app: &VectorcraftApp| -> Vec<(u64, f64, f64)> { + let st = app.session.active().unwrap(); + [a, b] + .into_iter() + .flat_map(|id| match &st.doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => { + (0..t.paragraph_count()).map(|i| (id, t.para_at(i).left_indent, t.para_at(i).first_line_indent)).collect::>() + } + _ => panic!("not text"), + }) + .collect() + }; + assert!(paragraphs(&app).iter().any(|p| p.1 == 24.0) && paragraphs(&app).iter().any(|p| p.1 == 60.0), "{:?}", paragraphs(&app)); + let select_all = |app: &mut VectorcraftApp| { + app.run("select.set", json!({"ids": [a]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": a, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); + assert_eq!(super::super::character::text_story(app), Some(vec![vectorcraft_doc::NodeId(a), vectorcraft_doc::NodeId(b)])); + }; + select_all(&mut app); + + let before = paragraphs(&app); + let (node, para, span) = current_para(&app).unwrap(); + assert_eq!(node.id.0, a); + let targets = indent_targets(&app, &node); + set_marker(&mut app, IndentMarker::Upper, 70.0, ¶, span, &targets, false); + let after = paragraphs(&app); + assert_eq!(after.len(), before.len()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.1 + now.2), (was.1, 70.0), "a paragraph of frame {} keeps its left indent: {after:?}", now.0); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paragraphs(&app), before); + + select_all(&mut app); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + set_marker(&mut app, IndentMarker::Lower, 40.0, ¶, span, &targets, false); + let after = paragraphs(&app); + assert_eq!(after.len(), before.len()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.1 + now.2), (40.0, was.1 + was.2), "a paragraph of frame {} keeps its first-line position: {after:?}", now.0); + } +} + #[test] fn lower_marker_bounds_cover_each_touched_first_line() { let targets = [IndentTarget { id: 1, start: 0, left: 24.0, first_line: -20.0 }, IndentTarget { id: 1, start: 4, left: 24.0, first_line: 280.0 }]; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 4d9254351..0af2f0670 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -739,6 +739,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - The function key shortcuts for Brushes, Color, Layers, Info, Gradient, Stroke, Attributes, Graphic Styles, Appearance, Align, Transform, Pathfinder, Transparency, and Symbols activate a background tab on the first press, hide its expanded dock or floating group when that tab is in front, and restore the group with all its tabs together on the next press; in an icon column they open and close the flyout while keeping the icons. A reassigned chord keeps that behavior, and Window > a hidden sibling restores the group. - Cmd/Ctrl+A follows the keyboard: in a panel, dialog or search field it selects the field's text, while the Type tool edits it selects all of that text (also `select.all` over MCP), and only otherwise every object. On macOS the menu bar's Select All, Cut, Copy and Paste (keys or clicks) act on a focused field too, and elsewhere the in-window menus' do: the field that had the keyboard when the menu opened gets them, with its selection, and the keyboard back (#516). In a frame of threaded text, Select All selects the whole story, highlighted in every frame: Copy and Cut take all of it, typing or Delete replaces it as one undo step, and a fill, a size step or a Character or Paragraph panel change applies to every frame's text (VectorCraft's own behavior; `textedit::select_all_in_a_threaded_frame_selects_the_whole_story`, `textedit::typing_over_the_whole_story_replaces_every_frames_text_in_one_step`). - With the whole story of threaded text selected by Select All, Remove Line Breaks, Remove Paragraph Breaks and Type › Bullets and Numbering › Convert to Text act on the text of every frame as one undo step, and the story stays selected after Convert to Text; a selection inside one frame changes only that frame (VectorCraft's own behavior; `textedit::remove_breaks_takes_the_whole_story_that_select_all_selected`, `textedit::convert_to_text_takes_the_whole_story_that_select_all_selected`). + - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`, `panels::tabs::tests::indent_markers_after_select_all_keep_each_story_paragraphs_indents`, `dialogs::justification::tests::justification_dialog_follows_the_story_thread_at_ok`). - With the whole story of threaded text selected by Select All, the Character, Paragraph, OpenType, Tabs and Text Styles panels, the Justification dialog and the font menu change the frames that are in the edited frame's thread at the time of the change, locked and hidden frames included. A frame that has left the thread is not changed, and a frame added to the thread is (VectorCraft's own behavior; `panels::character::tests::story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves`, `story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined`, `story_changes_stay_in_the_edited_frame_once_its_thread_is_removed`, `story_changes_take_in_locked_and_hidden_frames`). - Switching from a Type tool to the Eyedropper keeps a whole story selected by Select All: a pick applies to the text of every frame as one undo step, and switching back restores the story selection (VectorCraft's own behavior; `textedit::switching_to_the_eyedropper_keeps_the_whole_story_selected`). - While the Type tool edits text, a character typed with AltGr is typed and runs no command. Firefox on Windows reports AltGr as Ctrl and Alt held, so in the web build AltGr+L (ł on a Polish layout) does not also open Layer Options for a new layer, and AltGr+Q (@ on a German layout) does not also run Reset Tracking and Kerning. A Ctrl+Alt chord that types no character runs its command.