From 40a46e539deff6fc0e2fd2abd58053bbdb2bb23b Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 13:55:55 -0800 Subject: [PATCH 1/4] Type: story-wide panel changes follow the story's current thread After Select All in threaded text, the panels read the frames from the list saved at Select All. A frame that had left the thread still took Paragraph, Tabs, Justification and Text Styles changes, a frame added later did not, and when the first saved frame had left, Character, OpenType and font changes went to that frame alone. text_story now reads the frames of the edited frame's thread from the document. Locked and hidden frames stay in the story. --- crates/ui-egui/src/panels/character.rs | 16 ++- crates/ui-egui/src/panels/character/tests.rs | 102 +++++++++++++++++++ docs/mcp.md | 2 + docs/target-app-parity.md | 1 + 4 files changed, 112 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 34a3e006f..9dd969c5c 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -499,18 +499,16 @@ pub(crate) fn text_editing(app: &VectorcraftApp) -> Option<(NodeId, usize, usize } /// The frames of the threaded story whose whole text the Type tool has selected after Select All, -/// in thread order; `None` when its selection is in one type object. +/// in the order of the edited frame's thread as the document holds it now; `None` when its +/// selection is in one type object. pub(crate) fn text_story(app: &VectorcraftApp) -> Option> { let o = app.session.tool_options(); let st = app.session.active()?; - let ids: Vec = o - .get("story")? - .as_array()? - .iter() - .filter_map(Value::as_u64) - .map(NodeId) - .filter(|id| st.doc.node(*id).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) - .collect(); + let id = NodeId(o.get("editing")?.as_u64()?); + if o.get("story")?.as_array()?.is_empty() { + return None; + } + let ids = st.doc.story_of(id); (ids.len() >= 2).then_some(ids) } diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index e6927f843..577a081ef 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -1329,3 +1329,105 @@ fn select_all_in_threaded_text_formats_and_copies_the_whole_story() { .collect(); assert_eq!(copied, [story]); } + +/// A story threaded through three frames in order, the first `height` tall. +fn three_frame_story(height: u32) -> (VectorcraftApp, u64, u64, u64) { + 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. ".repeat(6); + let area = json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": height}}); + 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, a, b, c) +} + +/// Select All with the Type tool in frame `id` of a story. +fn select_all_from(app: &mut VectorcraftApp, id: u64) { + app.run("select.set", json!({"ids": [id]})).unwrap(); + app.session.select_tool("type", Default::default()).unwrap(); + app.session.set_tool_option("textSelection", &json!({"editing": id, "anchor": 0, "caret": 0})); + app.run("select.all", json!({})).unwrap(); +} + +fn story_frame(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject { + match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +fn story_sizes(app: &VectorcraftApp, id: u64) -> Vec { + story_frame(app, id).runs.iter().map(|r| r.style.size).collect() +} + +/// The frames that Select All saved are not the frames of the story once the first of them has +/// left the thread: the edited frame's story takes the change. +#[test] +fn story_changes_follow_the_edited_frames_thread_after_the_first_saved_frame_leaves() { + // The first frame is too short for a line, so Select All edits the second. + let (mut app, a, b, c) = three_frame_story(1); + select_all_from(&mut app, c); + assert_eq!(app.session.tool_options()["story"], json!([a, b, c])); + assert_eq!(text_editing(&app).unwrap().0, NodeId(b)); + app.run("text.thread.break", json!({"id": a, "port": "out"})).unwrap(); + assert_eq!(text_editing(&app).unwrap().0, NodeId(b)); + + assert_eq!(text_story(&app), Some(vec![NodeId(b), NodeId(c)])); + format(&mut app, json!({"size": 9})); + assert!(story_sizes(&app, a).iter().all(|s| (*s - 12.0).abs() < 1e-9), "the frame that left keeps its size"); + for id in [b, c] { + assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size"); + } +} + +/// A frame that left the thread after Select All takes no change, and a frame added to it does. +#[test] +fn story_changes_leave_out_a_frame_that_left_and_take_in_a_frame_that_joined() { + let (mut app, a, b, c) = three_frame_story(60); + select_all_from(&mut app, c); + assert_eq!(text_editing(&app).unwrap().0, NodeId(a)); + app.run("text.thread.break", json!({"id": b, "port": "out"})).unwrap(); + assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b)])); + let d = app.run("text.thread.add", json!({"from": a, "rect": [600, 10, 150, 60]})).unwrap()["id"].as_u64().unwrap(); + assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(d), NodeId(b)])); + + crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap(); + for id in [a, d, b] { + assert!((story_frame(&app, id).para_at(0).space_before - 4.0).abs() < 1e-9, "frame {id} takes the space"); + } + assert!(story_frame(&app, c).para_at(0).space_before.abs() < 1e-9, "the frame that left keeps its paragraphs"); +} + +/// With the thread gone, the selection is in the one edited frame. +#[test] +fn story_changes_stay_in_the_edited_frame_once_its_thread_is_removed() { + let (mut app, a, b, c) = three_frame_story(60); + select_all_from(&mut app, c); + assert_eq!(text_editing(&app).unwrap().0, NodeId(a)); + app.run("text.thread.remove", json!({})).unwrap(); + assert_eq!(text_story(&app), None); + + crate::panels::paragraph::run_on_paragraphs(&mut app, "text.setFormat", json!({"spaceBefore": 4})).unwrap(); + assert!((story_frame(&app, a).para_at(0).space_before - 4.0).abs() < 1e-9); + for id in [b, c] { + assert!(story_frame(&app, id).para_at(0).space_before.abs() < 1e-9, "frame {id} keeps its paragraphs"); + } +} + +/// A selected story is one text: its locked and hidden frames take the change too. +#[test] +fn story_changes_take_in_locked_and_hidden_frames() { + let (mut app, a, b, c) = three_frame_story(60); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + select_all_from(&mut app, a); + assert_eq!(text_story(&app), Some(vec![NodeId(a), NodeId(b), NodeId(c)])); + + format(&mut app, json!({"size": 9})); + for id in [a, b, c] { + assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size"); + } +} diff --git a/docs/mcp.md b/docs/mcp.md index 12f216ef5..1adcf32b1 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -5071,6 +5071,8 @@ and Cut take. Typing, Delete and Cut replace the whole story as one undo step (` frames whose text goes), and a fill, stroke, `type.step` or Character or Paragraph panel change applies to the text of every frame (the Character panel sends `text.setRangeStyle {id, story: true}`, which styles every frame's text in one undo step). Remove Line Breaks, Remove Paragraph Breaks and Convert to Text also act on the text of every frame, as +one undo step. The panels change the frames that are in the edited frame's thread at the time of the change, locked and +hidden frames included, and not a frame that has left it. A caret move or a click ends the story selection. one undo step. A caret move or a click ends the story selection. Switching to the Eyedropper keeps the story selected, and switching back to a Type tool restores it. `appearance.copyFrom` and `paint.sampleColor` then apply to the text of every frame as one undo step, and `appearance.copyFrom` leaves out a frame that is its `source`. While the story stays diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 98132eaec..4d9254351 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`). - 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. - On a layout without Latin letters, a one-key shortcut goes by the position of the key that typed the character, in the web build too, where the character arrives before its key press: on a Russian layout the P key (`з`) chooses the Pen, and two keys pressed in one frame each run their own shortcut. From c592b31a1eeefc09103a8a622f6e1669e7c70748 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 14:50:11 -0800 Subject: [PATCH 2/4] Type: Tabs indent markers and Justification follow the whole story After Select All in threaded text, the Tabs panel's indent markers read the indents of the edited frame alone. When another frame's paragraphs had a different left indent, a marker drag changed them by the edited frame's values, or left them alone. The markers now take every paragraph of every frame in the story, so each paragraph keeps its own left indent or first-line position. The Justification dialog kept the frames of the story from the moment it opened. OK now acts on the frames in the edited frame's thread when it is pressed, so a frame that left the thread while the dialog was open is not changed, and a frame that joined it is. --- crates/ui-egui/src/dialogs/justification.rs | 66 +++++++++++++++++---- crates/ui-egui/src/panels/tabs.rs | 11 +++- crates/ui-egui/src/panels/tabs/tests.rs | 60 +++++++++++++++++++ docs/target-app-parity.md | 1 + 4 files changed, 126 insertions(+), 12 deletions(-) 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. From 24a42c5bb07ba1eec80d8cfae8b95c4ef4d23f45 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:56:44 -0800 Subject: [PATCH 3/4] Type: Tabs indent markers apply to every paragraph when threaded text reflows With mixed indents, the Tabs panel sends one text.setFormat per paragraph, addressed by frame and byte offset. Threaded text reflows after each step, so an indent that made earlier paragraphs wrap onto more lines moved text into the next frame, and the later steps landed on the wrong paragraphs or on none. The steps now run from the story's last paragraph to its first, so each step's offset comes before every change already made. --- crates/ui-egui/src/panels/tabs.rs | 6 +- crates/ui-egui/src/panels/tabs/tests.rs | 74 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 67e589104..a1a8c7fb1 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -181,7 +181,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. +/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values +/// of their own get one command each, the story's last paragraph first: threaded text re-flows +/// after each command, and a re-flow moves only text after the paragraph that changed. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -196,6 +198,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: super::character::end_typing(app); let commands: Vec = targets .iter() + .rev() .map(|p| { json!({"command": "text.setFormat", "params": { "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left @@ -222,6 +225,7 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: super::character::end_typing(app); let commands: Vec = targets .iter() + .rev() .map(|p| { json!({"command": "text.setFormat", "params": { "ids": [p.id], "start": p.start, "end": p.start, diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 42aea4317..3244ba243 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -464,6 +464,80 @@ fn indent_markers_after_select_all_keep_each_story_paragraphs_indents() { } } +/// After Select All in threaded text with mixed indents, a marker change that makes the first +/// frame's paragraphs wrap onto more lines pushes text into the next frame while the change is +/// applied. Every paragraph of the story still gets the new position: the upper marker keeps each +/// left indent, and the lower marker keeps each first-line position. +#[test] +fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 600, "height": 1000})).unwrap(); + let story = (1..=12).map(|i| format!("p{i} aaa bbb ccc")).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(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 950]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + // Each paragraph of the story by its first word, with its left indent and first-line position; + // a paragraph continued from the frame before is counted once. No paragraph of the story is + // empty, so an empty piece (after a frame's last line break) is not one. + let paragraphs = |app: &VectorcraftApp| -> Vec<(String, 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) => t + .plain_text() + .split('\n') + .enumerate() + .filter(|(i, text)| !text.is_empty() && (*i > 0 || !t.first_para_continues)) + .map(|(i, text)| { + let p = t.para_at(i); + (text.split(' ').next().unwrap_or_default().to_string(), p.left_indent, p.left_indent + p.first_line_indent) + }) + .collect::>(), + _ => panic!("not text"), + }) + .collect() + }; + let in_a = |app: &VectorcraftApp| match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().kind { + NodeKind::Text(t) => t.plain_text().len(), + _ => panic!("not text"), + }; + // Select All in the first frame and move `marker` to `to` → the story's paragraphs before and + // after. + let apply = |app: &mut VectorcraftApp, marker, to| { + let before = paragraphs(app); + assert_eq!(before.len(), 12, "{before:?}"); + let shown = in_a(app); + 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(); + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + set_marker(app, marker, to, ¶, span, &targets, false); + let after = paragraphs(app); + assert!(in_a(app) < shown, "the first frame holds less text once it wraps: {} of {shown} bytes, {after:?}", in_a(app)); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + (before, after) + }; + let (before, after) = apply(&mut app, IndentMarker::Upper, 150.0); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 150.0), "{} keeps its left indent and starts at 150 pt: {after:?}", now.0); + } + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(paragraphs(&app), before); + // First lines at 150 and 160 pt; a left indent of 180 pt leaves room for one word a line. + app.run("text.setFormat", json!({"ids": [a, b], "firstLineIndent": 150})).unwrap(); + let (before, after) = apply(&mut app, IndentMarker::Lower, 180.0); + assert!(before.iter().any(|p| p.2 == 150.0) && before.iter().any(|p| p.2 == 160.0), "{before:?}"); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (180.0, was.2), "{} is indented 180 pt and 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 }]; From 1c2a8c7f7adea6b1ebd405810e666fdcc740e82e Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 6 Nov 2026 15:56:44 -0800 Subject: [PATCH 4/4] Type: Justification after Select All covers a frame whose thread broke After Select All in threaded text, the Justification dialog kept the edited frame's selected range when that frame was no longer threaded at OK. A thread broken after the frame moves the rest of the story into it, so the paragraphs past the old range kept their values. The dialog now drops the range and changes every paragraph in that frame. --- crates/ui-egui/src/dialogs/justification.rs | 31 +++++++++++++++++++-- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs/justification.rs b/crates/ui-egui/src/dialogs/justification.rs index 494ff0b69..dc42dcff8 100644 --- a/crates/ui-egui/src/dialogs/justification.rs +++ b/crates/ui-egui/src/dialogs/justification.rs @@ -196,16 +196,16 @@ fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool { /// `text.setFormat` parameters: the fields that differ from the values the dialog opened with, /// 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. +/// every paragraph 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)); + p.remove("start"); + p.remove("end"); if story.len() >= 2 { - p.remove("start"); - p.remove("end"); p.insert("ids".into(), json!(story.iter().map(|f| f.0).collect::>())); } } @@ -398,6 +398,31 @@ mod tests { assert_eq!(paras(&app, c).0.justification.auto_leading, 120.0, "the frame that left keeps its paragraphs"); } + /// After Select All in threaded text, a thread broken after the edited frame while the dialog is + /// open leaves the whole story in that frame, and OK changes every paragraph it now holds. + #[test] + fn justification_dialog_takes_the_story_into_a_frame_that_left_its_thread() { + 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(); + app.run("text.thread.add", json!({"from": a, "rect": [200, 10, 150, 400]})).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": a, "port": "out"})).unwrap(); + set(&mut app, "autoLeading", json!(140)); + super::super::confirm(&mut app).unwrap(); + let NodeKind::Text(t) = &app.session.active().unwrap().doc.node(NodeId(a)).unwrap().kind else { panic!("not text") }; + assert!(t.paragraph_count() >= 6, "the story flows into the frame: {} paragraphs", t.paragraph_count()); + for i in 0..t.paragraph_count() { + assert_eq!(t.para_at(i).justification.auto_leading, 140.0, "paragraph {i}"); + } + } + /// When paragraphs in scope disagree, only shared justification values are filled in. #[test] fn justification_dialog_mixed_paragraph_fields_show_blank() {