diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index 57a99e433..7e58a218a 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -4,9 +4,11 @@ //! same distance. A stop placed or dragged on the ruler lands on the nearest whole point. With Snap //! to Unit on, or Shift held, it lands on the ruler's nearest tick. +use std::collections::HashSet; + use egui::{Sense, Stroke, Ui, vec2}; use serde_json::{Value, json}; -use vectorcraft_doc::{Node, NodeKind, ParaStyle, TabAlign, TabStop, TextKind}; +use vectorcraft_doc::{Document, Node, NodeId, NodeKind, ParaStyle, TabAlign, TabStop, TextKind, TextObject}; use super::{first_selected, pstate, set_pstate}; use crate::VectorcraftApp; @@ -64,40 +66,95 @@ struct IndentTarget { level: f64, } +fn indent_target(id: NodeId, text: &TextObject, i: usize, start: usize) -> IndentTarget { + let para = text.para_at(i); + let left = para.laid_out_left_indent(); + IndentTarget { id: id.0, start, left, first_line: para.first_line_indent, level: left - para.left_indent } +} + fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &mut Vec) { let NodeKind::Text(text) = &node.kind else { return }; let plain = text.plain_text(); let mut starts = vec![0]; starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); - targets.extend(range.filter_map(|i| { - let para = text.para_at(i); - let left = para.laid_out_left_indent(); - Some(IndentTarget { id: node.id.0, start: *starts.get(i)?, left, first_line: para.first_line_indent, level: left - para.left_indent }) - })); + targets.extend(range.filter_map(|i| Some(indent_target(node.id, text, i, *starts.get(i)?)))); } -/// Paragraphs touched by the Type selection (every paragraph of every frame after Select All in -/// threaded text), or all paragraphs of selected text objects. +/// One target for each paragraph of `story` (its frames in thread order), in story order. Where +/// one frame ends and the next begins, the last paragraph of the one and the first of the next are +/// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece +/// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with +/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again. +/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty +/// paragraph takes its style from its last piece when the story flows again, so it is targeted by +/// that piece when `selected` takes its frame, and otherwise not. +fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec) { + /// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`. + struct Piece<'a> { + id: NodeId, + text: &'a TextObject, + index: usize, + start: usize, + has_text: bool, + } + // Each story paragraph's pieces, in thread order. + let mut paragraphs: Vec>> = vec![]; + for frame in story.iter().filter_map(|id| doc.node(*id)) { + let NodeKind::Text(text) = &frame.kind else { continue }; + let plain = text.plain_text(); + let mut start = 0; + for (index, part) in plain.split('\n').enumerate() { + let piece = Piece { id: frame.id, text, index, start, has_text: !part.is_empty() }; + start += part.len() + 1; + match paragraphs.last_mut() { + Some(last) if index == 0 => last.push(piece), + _ => paragraphs.push(vec![piece]), + } + } + } + for pieces in ¶graphs { + let pick = if pieces.iter().any(|p| p.has_text) { + pieces.iter().rev().find(|p| p.has_text && selected(p.id)) + } else { + pieces.last().filter(|p| selected(p.id)) + }; + if let Some(p) = pick { + targets.push(indent_target(p.id, p.text, p.index, p.start)); + } + } +} + +/// Paragraphs touched by the Type selection (every paragraph of the story after Select All in +/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story +/// is listed once, in story order ([`append_story_targets`]). 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 (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); - } - } + append_story_targets(&st.doc, &story, |_| true, &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() { + // The selected text objects and the text in selected groups, each once. + 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 let NodeKind::Text(text) = &child.kind { - append_indent_targets(child, 0..text.paragraph_count(), &mut targets); + if matches!(child.kind, NodeKind::Text(_)) && taken.insert(child.id) { + frames.push(child.id); } }); } + let mut done = HashSet::new(); + for id in frames { + if done.contains(&id) { + continue; + } + let story = st.doc.story_of(id); + done.extend(story.iter().copied()); + append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets); + } } targets } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index bc71e3408..9e96866ab 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -538,6 +538,139 @@ fn indent_markers_after_select_all_follow_text_that_reflows_into_the_next_frame( } } +/// A story of twelve paragraphs that wrap, threaded through three frames: the first two frames each +/// end inside a paragraph, which runs on into the next frame. → the app and the frames in thread +/// order. +fn split_story_app() -> (VectorcraftApp, [u64; 3]) { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 1000})).unwrap(); + // Each paragraph takes more lines than a short frame holds. + let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3); + let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap(); + let c = app.run("text.thread.add", json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap(); + for id in [b, c] { + assert!(text_of(&app, id).first_para_continues, "frame {id} starts inside a paragraph: {:?}", text_of(&app, id).plain_text()); + } + (app, [a, b, c]) +} + +fn text_of(app: &VectorcraftApp, id: u64) -> vectorcraft_doc::TextObject { + match &app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(id)).unwrap().kind { + NodeKind::Text(t) => (**t).clone(), + _ => panic!("not text"), + } +} + +/// 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)> { + let mut out: Vec<(String, f64, f64)> = vec![]; + for (k, id) in frames.iter().enumerate() { + let t = text_of(app, *id); + for (i, text) in t.plain_text().split('\n').enumerate() { + let p = t.para_at(i); + let values = (p.left_indent, p.left_indent + p.first_line_indent); + if i == 0 && k > 0 && t.first_para_continues { + let last = out.last().unwrap(); + assert_eq!((last.1, last.2), values, "both pieces of {} agree", last.0); + } else if !text.is_empty() { + out.push((text.split(' ').next().unwrap_or_default().to_string(), values.0, values.1)); + } + } + } + out +} + +/// Where each target is in the story: its frame's place in `frames` and its start there. +fn story_positions(targets: &[IndentTarget], frames: &[u64]) -> Vec<(usize, usize)> { + targets.iter().map(|p| (frames.iter().position(|f| *f == p.id).unwrap(), p.start)).collect() +} + +/// With the Selection tool on every frame of a threaded story, selected last frame first, the +/// indent markers list each paragraph of the story once, in the story's order, and a marker drag +/// over mixed indents sets every paragraph in one undo step, the two split paragraphs included. +#[test] +fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [c, b, a]})).unwrap(); + assert_eq!(app.session.active().unwrap().selection.objects.iter().map(|id| id.0).collect::>(), vec![c, b, a]); + let before = story_paragraphs(&app, &[a, b, c]); + assert_eq!(before.len(), 12, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + let positions = story_positions(&targets, &[a, b, c]); + assert_eq!(positions.len(), 12, "one target for each paragraph of the story: {positions:?}"); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "the targets follow the story: {positions:?}"); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let after = story_paragraphs(&app, &[a, b, c]); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0); + } +} + +/// After Select All in threaded text, a paragraph split between two frames is one target. +#[test] +fn indent_markers_after_select_all_list_a_split_paragraph_once() { + let (mut app, [a, b, c]) = split_story_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 ids = [a, b, c].map(vectorcraft_doc::NodeId); + assert_eq!(super::super::character::text_story(&app), Some(ids.to_vec())); + let (node, _, _) = current_para(&app).unwrap(); + let positions = story_positions(&indent_targets(&app, &node), &[a, b, c]); + assert_eq!(positions.len(), 12, "{positions:?}"); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); +} + +/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in +/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that +/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is +/// listed with the story's frames, and an empty text object keeps its one paragraph. +#[test] +fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { + let (mut app, [a, b, c]) = split_story_app(); + let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count(); + let targets_of = |app: &mut VectorcraftApp, ids: Vec| { + app.run("select.set", json!({"ids": ids})).unwrap(); + let (node, _, _) = current_para(app).unwrap(); + story_positions(&indent_targets(app, &node), &[a, b, c]) + }; + // The continuing frame alone: its first paragraph is the one the frame before ends inside. + let only_b = targets_of(&mut app, vec![b]); + assert_eq!(only_b.len(), count(&app, b), "{only_b:?}"); + assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}"); + let only_a = targets_of(&mut app, vec![a]); + assert_eq!(only_a.len(), count(&app, a), "{only_a:?}"); + // Two frames that share a paragraph list it once, by the later frame's piece. + let both = targets_of(&mut app, vec![c, a, b]); + assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}"); + assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}"); + // The first and last frames of the story, and a grouped empty text object besides. + let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [empty, c]})).unwrap(); + let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); + app.run("select.set", json!({"ids": [group, a]})).unwrap(); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone(); + let targets = indent_targets(&app, &node); + let ids: Vec = targets.iter().map(|p| p.id).collect(); + assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}"); + let story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); + let positions = story_positions(&story, &[a, b, c]); + assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}"); + assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); +} + #[test] fn lower_marker_bounds_cover_each_touched_first_line() { let targets = [ diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 05712ce0a..cdbaa4276 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -642,6 +642,7 @@ since 2026-10-01. - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. +- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands.