diff --git a/crates/ui-egui/src/font_menu.rs b/crates/ui-egui/src/font_menu.rs index 554eb9229f..9b829114c2 100644 --- a/crates/ui-egui/src/font_menu.rs +++ b/crates/ui-egui/src/font_menu.rs @@ -1691,7 +1691,7 @@ pub(crate) fn end_stale_preview(app: &mut VectorcraftApp, ctx: &egui::Context) { } /// What the font menus preview on and apply to: the Type tool's selected range while it edits -/// text (the whole text when nothing is selected), else the selected text objects. +/// text (the whole text when nothing is selected), else every frame of each selected story. pub(crate) fn apply(app: &mut VectorcraftApp, ctx: &egui::Context, pick: FontPick) { let previewing = crate::panels::pstate::(ctx, PREVIEWING); match pick { @@ -1791,7 +1791,11 @@ fn font_command(app: &VectorcraftApp, family: &str, style: Option<&str>) -> (&'s } ("text.setRangeStyle", p) } - None => ("text.setStyle", p), + None => { + // With the Selection tool: every frame of each selected story. + crate::panels::character::target_selected_stories(app, &mut p); + ("text.setStyle", p) + } } } diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 44ae643e86..1e6ca2c8d5 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -2,6 +2,7 @@ //! scale, baseline shift, character rotation, the anti-aliasing method and the All Caps, Small Caps, //! Superscript, Subscript, Underline and Strikethrough toggles and the Language menu. +use std::collections::HashSet; use std::sync::{Mutex, OnceLock}; use egui::{Key, Ui, vec2}; @@ -99,8 +100,8 @@ pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc } /// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the -/// caret's style at a caret), else every run of the selected type, groups' included. `None` (a -/// blank field) where they differ, or with no type. +/// caret's style at a caret), else every run of every frame of the selected stories +/// ([`selected_stories`]). `None` (a blank field) where they differ, or with no type. pub(crate) fn shared(app: &VectorcraftApp, f: impl Fn(&CharStyle) -> T) -> Option { shared_styles(app, |_, _, style, _| Some(f(style))) } @@ -162,12 +163,8 @@ fn shared_styles( None => each_paragraph_run(t, Some((a, b)), |para, style, inline| acc.add(f(t, para, style, inline))), } } else { - for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - n.walk(&mut |c| { - if let NodeKind::Text(t) = &c.kind { - each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline))); - } - }); + for t in selected_story_texts(app) { + each_paragraph_run(t, None, |para, style, inline| acc.add(f(t, para, style, inline))); } } acc.value().flatten() @@ -243,9 +240,12 @@ pub(crate) fn format(app: &mut VectorcraftApp, p: Value) { /// [`format`] that returns the command's result: `text.setRangeStyle` on the range the Type tool /// has selected (the whole text when nothing is selected) while it edits text, else -/// `text.setFormat` on the selected text objects. +/// `text.setFormat` on the selected stories ([`selected_stories`]). pub(crate) fn run_format(app: &mut VectorcraftApp, mut p: Value) -> Result { - let Some((id, a, b)) = text_editing(app) else { return app.run("text.setFormat", p) }; + let Some((id, a, b)) = text_editing(app) else { + target_selected_stories(app, &mut p); + return app.run("text.setFormat", p); + }; if let Some(first) = text_story(app).and_then(|story| story.first().copied()) { return story_style(app, first, p); } @@ -283,9 +283,11 @@ pub(crate) fn shared_warichu(app: &VectorcraftApp) -> SharedWarichu { } /// Apply character attributes: to the selected range while the Type tool edits text (the whole -/// object when nothing is selected), else to the selected text objects. -fn char_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { +/// object when nothing is selected), else to every frame of the selected stories +/// ([`selected_stories`]). +fn char_cmd(app: &mut VectorcraftApp, cmd: &str, mut p: Value) { if text_editing(app).is_none() { + target_selected_stories(app, &mut p); app.run(cmd, p).ok(); return; } @@ -546,6 +548,55 @@ pub(crate) fn text_story(app: &VectorcraftApp) -> Option> { (ids.len() >= 2).then_some(ids) } +/// The type objects the panels act on with the Selection tool: the selected type objects and those +/// in selected groups, each with every frame of its threaded story (locked and hidden frames +/// included), each once, the frames of a story in thread order. +pub(crate) fn selected_stories(app: &VectorcraftApp) -> Vec { + let Some(st) = app.session.active() else { return Vec::new() }; + let mut frames = Vec::new(); + let mut taken = HashSet::new(); + for selected in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + selected.walk(&mut |child| { + if matches!(child.kind, NodeKind::Text(_)) && !taken.contains(&child.id) { + for id in st.doc.story_of(child.id) { + if taken.insert(id) { + frames.push(id); + } + } + } + }); + } + frames +} + +/// The type objects of [`selected_stories`]. +pub(crate) fn selected_story_texts(app: &VectorcraftApp) -> Vec<&vectorcraft_doc::TextObject> { + let Some(st) = app.session.active() else { return Vec::new() }; + selected_stories(app) + .into_iter() + .filter_map(|id| match st.doc.node(id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => Some(t.as_ref()), + _ => None, + }) + .collect() +} + +/// Give the command parameters `p` the `ids` of [`selected_stories`], so a command run with the +/// Selection tool sets every frame of each selected story. `p` stays as it is when it names its +/// targets, with no type selected, and while an object of a symbol instance's art is selected, +/// which the commands refuse. +pub(crate) fn target_selected_stories(app: &VectorcraftApp, p: &mut Value) { + if p.get("ids").is_some() || p.get("id").is_some() || app.session.active().is_none_or(|st| st.selection.symbol_part.is_some()) { + return; + } + let ids = selected_stories(app); + if let Some(params) = p.as_object_mut() + && !ids.is_empty() + { + params.insert("ids".into(), json!(ids.iter().map(|id| id.0).collect::>())); + } +} + /// `text.setRangeStyle` with `p` on all the text of every frame of the story that `first` is a /// frame of, as one undo step. fn story_style(app: &mut VectorcraftApp, first: NodeId, mut p: Value) -> Result { @@ -910,7 +961,8 @@ fn anti_alias_method(app: &mut VectorcraftApp, ui: &mut Ui, width: f32) { } /// The anti-aliasing method of the type objects the panel acts on: the one the Type tool edits, -/// else the selected type (groups' included). `None` where they differ, or with no type. +/// else every frame of the selected stories ([`selected_stories`]). `None` where they differ, or +/// with no type. fn shared_anti_alias(app: &VectorcraftApp) -> Option { let st = app.session.active()?; let mut acc = Shared::None; @@ -921,12 +973,8 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option { } } None => { - for n in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - n.walk(&mut |c| { - if let NodeKind::Text(t) = &c.kind { - acc.add(t.anti_alias); - } - }); + for t in selected_story_texts(app) { + acc.add(t.anti_alias); } } } @@ -934,12 +982,14 @@ fn shared_anti_alias(app: &VectorcraftApp) -> Option { } /// Set the anti-aliasing method `m` of the frame the Type tool edits, whatever part of its text -/// is selected, else the selected type objects (`text.setFormat {antiAlias}`). +/// is selected, else every frame of the selected stories (`text.setFormat {antiAlias}`). fn set_anti_alias(app: &mut VectorcraftApp, m: TypeAntiAlias) { let mut p = json!({"antiAlias": m.id()}); if let Some((id, _, _)) = text_editing(app) { end_typing(app); p["ids"] = json!([id.0]); + } else { + target_selected_stories(app, &mut p); } app.run("text.setFormat", p).ok(); } @@ -1160,7 +1210,11 @@ fn axes_cmd(app: &mut VectorcraftApp, axes: Value, phase: widgets::Live) { } super::live_run(app, "Character", "text.setRangeStyle", q, phase); } - None => super::live_run(app, "Character", "text.setFormat", json!({ "fontAxes": axes }), phase), + None => { + let mut p = json!({ "fontAxes": axes }); + target_selected_stories(app, &mut p); + super::live_run(app, "Character", "text.setFormat", p, phase); + } } } diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index 7a2a96efb2..56472c59b0 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -1741,3 +1741,141 @@ fn story_changes_take_in_locked_and_hidden_frames() { assert_eq!(story_sizes(&app, id).iter().filter(|s| (**s - 9.0).abs() > 1e-9).count(), 0, "frame {id} takes the size"); } } + +/// [`three_frame_story`] with its second frame locked and its third hidden, a type object of its own +/// below it, and the first frame selected with the Selection tool → the app, the frames and the +/// other type object. +fn story_selected_in_its_first_frame() -> (VectorcraftApp, [u64; 3], u64) { + let (mut app, a, b, c) = three_frame_story(60); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + 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(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(text_editing(&app), None); + (app, [a, b, c], other) +} + +fn undo_steps(app: &VectorcraftApp) -> usize { + app.session.active().unwrap().history.undo.len() +} + +/// With the Selection tool, a selected frame of a threaded story stands for the whole story: the +/// panel's changes reach its locked and hidden frames, each in one undo step, and leave other type +/// alone. +#[test] +fn selection_tool_changes_reach_every_frame_of_the_story() { + let (mut app, frames, other) = story_selected_in_its_first_frame(); + assert_eq!(selected_stories(&app), frames.map(NodeId).to_vec()); + + let steps = undo_steps(&app); + style(&mut app, json!({"size": 9})); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in frames { + assert!(story_sizes(&app, id).iter().all(|s| (*s - 9.0).abs() < 1e-9), "frame {id} takes the size"); + } + assert!(story_sizes(&app, other).iter().all(|s| (*s - 12.0).abs() < 1e-9), "other type keeps its size"); + app.run("edit.undo", json!({})).unwrap(); + for id in frames { + assert!(story_sizes(&app, id).iter().all(|s| (*s - 12.0).abs() < 1e-9), "undo gives frame {id} its size back"); + } + + format(&mut app, json!({"underline": true})); + for id in frames { + assert!(story_frame(&app, id).runs.iter().all(|r| r.style.underline), "frame {id} is underlined"); + } + assert!(story_frame(&app, other).runs.iter().all(|r| !r.style.underline)); + + // Tate-chu-yoko Settings and Warichu Settings set their values through `run_format`. + run_format(&mut app, json!({"tcyUpDown": 2})).unwrap(); + for id in frames { + assert!(story_frame(&app, id).runs.iter().all(|r| (r.style.tcy_up_down - 2.0).abs() < 1e-9), "frame {id} takes Up/Down"); + } + assert!(story_frame(&app, other).runs.iter().all(|r| r.style.tcy_up_down.abs() < 1e-9)); +} + +/// With the Selection tool the anti-aliasing method goes to every frame of the selected story. +#[test] +fn selection_tool_anti_aliasing_reaches_every_frame_of_the_story() { + let (mut app, frames, other) = story_selected_in_its_first_frame(); + set_anti_alias(&mut app, TypeAntiAlias::Strong); + for id in frames { + assert_eq!(story_frame(&app, id).anti_alias, TypeAntiAlias::Strong, "frame {id}"); + } + assert_eq!(story_frame(&app, other).anti_alias, TypeAntiAlias::Sharp); + assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Strong)); +} + +/// With the Selection tool a Variable Font slider sets the axis on every frame of the selected +/// story, in one undo step. +#[test] +fn selection_tool_font_axes_reach_every_frame_of_the_story() { + use vectorcraft_text::test_fonts::{TWO_AXIS_FAMILY, two_axis_font}; + vectorcraft_text::FontDb::global().add_font(two_axis_font().unwrap()); + let (mut app, frames, _) = story_selected_in_its_first_frame(); + app.run("text.setStyle", json!({"ids": frames, "font": TWO_AXIS_FAMILY})).unwrap(); + app.run("select.set", json!({"ids": [frames[0]]})).unwrap(); + let steps = undo_steps(&app); + axes_cmd(&mut app, json!({"wght": 700}), widgets::Live::Released); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in frames { + let t = story_frame(&app, id); + assert!(t.runs.iter().all(|r| r.style.font_axes.get("wght") == Some(&700.0)), "frame {id}: {:?}", t.runs[0].style.font_axes); + } +} + +/// With the Selection tool a font family picked in the font menu, and its preview, go to every +/// frame of the selected story, in one undo step. +#[test] +fn selection_tool_font_family_reaches_every_frame_of_the_story() { + let (mut app, frames, other) = story_selected_in_its_first_frame(); + let family = |app: &VectorcraftApp, id: u64| story_frame(app, id).runs.iter().map(|r| r.style.font_family.clone()).collect::>(); + let before = family(&app, other); + let ctx = egui::Context::default(); + crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Preview("Source Serif 4".into(), None)); + for id in frames { + assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "the preview shows on frame {id}"); + } + let steps = undo_steps(&app); + crate::font_menu::apply(&mut app, &ctx, crate::font_menu::FontPick::Chosen("Source Serif 4".into(), None)); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in frames { + assert!(family(&app, id).iter().all(|f| f == "Source Serif 4"), "frame {id} takes the family"); + } + assert_eq!(family(&app, other), before, "other type keeps its family"); +} + +/// The fields show a value only when every frame of the selected story has it: a hidden frame that +/// differs leaves them blank. +#[test] +fn selection_tool_fields_read_every_frame_of_the_story() { + let (mut app, [a, b, c], _) = story_selected_in_its_first_frame(); + assert_eq!(shared(&app, |s| s.size), Some(12.0)); + assert_eq!(shared_anti_alias(&app), Some(TypeAntiAlias::Sharp)); + app.run("text.setStyle", json!({"ids": [c], "size": 20})).unwrap(); + app.run("text.setFormat", json!({"ids": [b], "antiAlias": TypeAntiAlias::Crisp.id()})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(shared(&app, |s| s.size), None, "the hidden frame's size differs"); + assert_eq!(shared_anti_alias(&app), None, "the locked frame's anti-aliasing differs"); +} + +/// Two frames of one story, or a frame in a selected group, count once; an unthreaded type +/// object is a story of its own. +#[test] +fn selected_stories_list_each_frame_once() { + let (mut app, [a, b, c], other) = story_selected_in_its_first_frame(); + app.run("object.unlockAll", json!({})).unwrap(); + app.run("select.set", json!({"ids": [b, a]})).unwrap(); + assert_eq!(selected_stories(&app), [a, b, c].map(NodeId).to_vec(), "thread order, each once"); + app.run("select.set", json!({"ids": [other, a]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let mut listed = selected_stories(&app); + listed.sort_by_key(|id| id.0); + let mut expected = [a, b, c, other].map(NodeId).to_vec(); + expected.sort_by_key(|id| id.0); + assert_eq!(listed, expected); + app.run("select.set", json!({"ids": []})).unwrap(); + assert!(selected_stories(&app).is_empty()); +} diff --git a/crates/ui-egui/src/panels/paragraph.rs b/crates/ui-egui/src/panels/paragraph.rs index ab53932f80..e02d81e14c 100644 --- a/crates/ui-egui/src/panels/paragraph.rs +++ b/crates/ui-egui/src/panels/paragraph.rs @@ -28,8 +28,9 @@ pub const ALIGNMENTS: [(Justify, &str, &str, &str); 7] = [ (Justify::JustifyAll, "dc-para-justify-all", "Justify all lines", "justifyAll"), ]; -/// Paragraph attributes apply to the selected text objects' paragraphs or, while the Type tool -/// edits text, to the paragraphs its selection (or caret) touches (ending its typing session). +/// Paragraph attributes apply to the paragraphs of every frame of the selected stories or, while +/// the Type tool edits text, to the paragraphs its selection (or caret) touches (ending its typing +/// session). fn format(app: &mut VectorcraftApp, p: Value) { para_cmd(app, "text.setFormat", p); } @@ -75,8 +76,16 @@ fn para_cmd(app: &mut VectorcraftApp, cmd: &str, p: Value) { run_on_paragraphs(app, cmd, p).ok(); } -/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. -pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Value) -> Result { +/// Run `cmd` on the paragraphs the Paragraph panel acts on ([`format`]) and return its result. With +/// the Selection tool these are the paragraphs of every frame of the selected stories +/// ([`super::character::selected_stories`]). +pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, p: Value) -> Result { + run_on(app, cmd, p, true) +} + +/// [`run_on_paragraphs`], with the Selection tool on the selected type objects alone unless +/// `stories`. +fn run_on(app: &mut VectorcraftApp, cmd: &str, mut p: Value, stories: bool) -> Result { if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); match super::character::text_story(app) { @@ -88,6 +97,8 @@ pub(crate) fn run_on_paragraphs(app: &mut VectorcraftApp, cmd: &str, mut p: Valu p["end"] = json!(b); } } + } else if stories { + super::character::target_selected_stories(app, &mut p); } app.run(cmd, p) } @@ -208,14 +219,10 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) { } return; } - for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - node.walk(&mut |c| { - if let NodeKind::Text(t) = &c.kind { - for i in 0..t.paragraph_count() { - f(t.para_at(i)); - } - } - }); + for t in super::character::selected_story_texts(app) { + for i in 0..t.paragraph_count() { + f(t.para_at(i)); + } } } @@ -328,18 +335,8 @@ fn selected_paragraph_direction(app: &VectorcraftApp, fallback: &ParaStyle) -> O { return if collect_text_direction(text, Some((a, b)), &mut shared) { shared.or(default) } else { None }; } - fn collect(node: &vectorcraft_doc::Node, shared: &mut Option) -> bool { - if let NodeKind::Text(text) = &node.kind { - return collect_text_direction(text, None, shared); - } - node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared))) - } - for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - if !collect(node, &mut shared) { - return None; - } - } - shared.or(default) + let texts = super::character::selected_story_texts(app); + if texts.into_iter().all(|t| collect_text_direction(t, None, &mut shared)) { shared.or(default) } else { None } } /// A hyphenation option shared by every paragraph in scope, or `None` when they differ. @@ -365,18 +362,8 @@ fn selected_alignment(app: &VectorcraftApp, fallback: &ParaStyle) -> Option) -> bool { - if let NodeKind::Text(text) = &node.kind { - return collect_text_alignment(text, None, shared); - } - node.children().is_none_or(|children| children.iter().all(|child| collect(child, shared))) - } - for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { - if !collect(node, &mut shared) { - return None; - } - } - shared.or(default) + let texts = super::character::selected_story_texts(app); + if texts.into_iter().all(|t| collect_text_alignment(t, None, &mut shared)) { shared.or(default) } else { None } } /// The seven alignment buttons shared by Paragraph and Properties; no button is selected @@ -492,7 +479,7 @@ pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) - /// the Type tool's anchor and caret past the characters inserted at or before them). pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result { text_style(app).ok_or("select some type first")?; - run_on_paragraphs(app, "text.convertListToText", json!({})) + run_on(app, "text.convertListToText", json!({}), false) } /// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs diff --git a/crates/ui-egui/src/panels/paragraph/tests.rs b/crates/ui-egui/src/panels/paragraph/tests.rs index 50c17001db..180f0b1fa1 100644 --- a/crates/ui-egui/src/panels/paragraph/tests.rs +++ b/crates/ui-egui/src/panels/paragraph/tests.rs @@ -1035,3 +1035,58 @@ fn reset_panel_leaves_an_undo_group_open_in_another_document() { app.run("edit.undoGroup.end", json!({})).unwrap(); assert_eq!(steps(app.session.active().unwrap()), a_steps, "the later edit is part of the Batch step"); } + +/// With the Selection tool a selected frame of a threaded story stands for the whole story: the +/// Paragraph panel's changes and Reset Panel reach its locked and hidden frames, each in one undo +/// step, other type keeps its paragraphs, and the alignment buttons read every frame. +#[test] +fn selection_tool_paragraph_changes_reach_every_frame_of_the_story() { + 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.\n".repeat(12); + 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(); + let other = app.run("text.create", json!({"x": 10, "y": 250, "text": "Other", "size": 12})).unwrap()["id"].as_u64().unwrap(); + 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(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let paragraphs = |app: &VectorcraftApp, id: u64| match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (0..t.paragraph_count()).map(|i| t.para_at(i).clone()).collect::>(), + _ => panic!("not text"), + }; + let undo_steps = |app: &VectorcraftApp| app.session.active().unwrap().history.undo.len(); + for id in [a, b, c] { + assert!(!paragraphs(&app, id).is_empty(), "frame {id} holds text"); + } + + let steps = undo_steps(&app); + format(&mut app, json!({"spaceBefore": 4})); + assert_eq!(undo_steps(&app), steps + 1, "one undo step"); + for id in [a, b, c] { + assert!(paragraphs(&app, id).iter().all(|p| (p.space_before - 4.0).abs() < 1e-9), "frame {id} takes the space"); + } + assert!(paragraphs(&app, other).iter().all(|p| p.space_before.abs() < 1e-9), "other type keeps its paragraphs"); + assert_eq!(shared_para_field(&app, |p| p.space_before), Some(4.0)); + + align(&mut app, "center").unwrap(); + for id in [a, b, c] { + assert!(paragraphs(&app, id).iter().all(|p| p.justify == Justify::Center), "frame {id} is centered"); + } + assert!(paragraphs(&app, other).iter().all(|p| p.justify != Justify::Center)); + assert_eq!(selected_alignment(&app, &ParaStyle::default()), Some(Justify::Center)); + // A hidden frame aligned otherwise leaves no alignment button on. + app.run("text.setStyle", json!({"ids": [c], "justify": "right"})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert_eq!(selected_alignment(&app, &ParaStyle::default()), None); + + let steps = undo_steps(&app); + reset_panel(&mut app); + assert_eq!(undo_steps(&app), steps + 1, "Reset Panel is one undo step"); + for id in [a, b, c] { + assert!(paragraphs(&app, id).iter().all(|p| p.space_before.abs() < 1e-9 && p.justify == Justify::Auto), "frame {id} is reset"); + } +} diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index bc1f9352c3..00814646c5 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -419,7 +419,9 @@ fn stops_json(stops: &[TabStop]) -> Value { ) } -/// Run `text.tabs.set` with `stops` on the text the panel acts on. Returns false when it fails. +/// Run `text.tabs.set` with `stops` on the text the panel acts on: with the Selection tool, every +/// frame of the selected stories ([`super::character::selected_stories`]). Returns false when it +/// fails. fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool { if let Some((id, a, b)) = super::character::text_editing(app) { super::character::end_typing(app); @@ -430,7 +432,9 @@ fn apply(app: &mut VectorcraftApp, stops: &[TabStop]) -> bool { }; app.run("text.tabs.set", p).is_ok() } else { - app.run("text.tabs.set", json!({"stops": stops_json(stops)})).is_ok() + let mut p = json!({"stops": stops_json(stops)}); + super::character::target_selected_stories(app, &mut p); + app.run("text.tabs.set", p).is_ok() } } @@ -727,6 +731,20 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { resp.on_hover_text(tl!("Click to add a tab stop, drag to move it, drag it off the ruler to delete it")); } +/// Whether a paragraph of the whole story Clear All Tabs acts on has tab stops: after Select All in +/// threaded text, every frame of the edited story; with the Selection tool, every frame of the +/// selected stories ([`super::character::selected_stories`]). +fn story_has_tabs(app: &VectorcraftApp) -> bool { + let has_tabs = |t: &TextObject| (0..t.paragraph_count()).any(|i| !t.para_at(i).tabs.is_empty()); + match (super::character::text_editing(app), super::character::text_story(app), app.session.active()) { + (Some(_), Some(story), Some(st)) => { + story.iter().any(|id| matches!(st.doc.node(*id).map(|n| &n.kind), Some(NodeKind::Text(t)) if has_tabs(t))) + } + (Some(_), _, _) => false, + (None, _, _) => super::character::selected_story_texts(app).into_iter().any(has_tabs), + } +} + pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { let Some((_, para, _)) = current_para(app) else { ui.add_enabled(false, egui::Button::new(tl!("Select text")).frame(false)); @@ -742,7 +760,7 @@ pub fn menu(app: &mut VectorcraftApp, ui: &mut Ui) { if sel >= stops.len() { sel = stops.len().saturating_sub(1); } - if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty(), false) + if menu_item(ui, tl!("Clear All Tabs"), !stops.is_empty() || story_has_tabs(app), false) && let Err(e) = super::paragraph::run_on_paragraphs(app, "text.tabs.clear", json!({})) { app.status(e); diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 6472b1017b..e260db70ab 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -642,6 +642,43 @@ fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject { } } +/// With the Selection tool, tab stops set in the panel go to every paragraph of every frame of a +/// selected threaded story, its locked and hidden frames included. +#[test] +fn selection_tool_tab_stops_reach_every_frame_of_the_story() { + let (mut app, frames) = split_story_app(); + app.run("select.set", json!({"ids": [frames[1]]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [frames[2]]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [frames[0]]})).unwrap(); + let stop = TabStop { position: 36.0, align: Default::default(), leader: String::new(), align_on: '.' }; + assert!(apply(&mut app, std::slice::from_ref(&stop))); + for id in frames { + let t = text_of(&app, id); + assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs == [stop.clone()]), "frame {id} takes the stop"); + } +} + +/// With the Selection tool, Clear All Tabs is on when only another frame of the selected story has +/// tab stops, and clears them there. +#[test] +fn clear_all_tabs_is_on_when_another_frame_of_the_story_has_stops() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("text.tabs.set", json!({"ids": [c], "stops": [{"position": 36}]})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + assert!(current(&app).unwrap().1.is_empty(), "the selected frame has no stops"); + assert!(story_has_tabs(&app)); + super::super::paragraph::run_on_paragraphs(&mut app, "text.tabs.clear", json!({})).unwrap(); + for id in [a, b, c] { + let t = text_of(&app, id); + assert!((0..t.paragraph_count()).all(|i| t.para_at(i).tabs.is_empty()), "frame {id} has no stops"); + } + assert!(!story_has_tabs(&app)); +} + /// Each paragraph of the story of `frames` by its first word, with its left indent and first-line /// position, counted once; each piece of a paragraph split between frames has the same values. fn story_paragraphs(app: &VectorcraftApp, frames: &[u64]) -> Vec<(String, f64, f64)> { diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 0159419097..592b36ba42 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -998,6 +998,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - 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`). + - With the Selection tool, a Character or Paragraph panel change to a selected frame of threaded text changes every frame of its story, locked and hidden frames included, as one undo step. The anti-aliasing method, Variable Font axes, the Tabs panel's tab stops and both panels' Reset Panel change the whole story too, and the panels show a value only when every frame of the story has it. A story selected through several of its frames or through a group is changed once, and other type keeps its formatting (VectorCraft's own behavior; `panels::character::tests::selection_tool_changes_reach_every_frame_of_the_story`, `selection_tool_fields_read_every_frame_of_the_story`, `selected_stories_list_each_frame_once`, `panels::paragraph::tests::selection_tool_paragraph_changes_reach_every_frame_of_the_story`, `panels::tabs::tests::selection_tool_tab_stops_reach_every_frame_of_the_story`). - 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.