Tabs: indent drags on threaded frames follow the story's order and set a split paragraph once

With the Selection tool on several frames of one threaded story, the
Tabs panel listed the paragraphs to change in the order the frames were
selected, and listed each frame's paragraphs separately. A marker drag
over mixed indents then ran its per-paragraph steps out of story order,
so a step that made text reflow could move a later step's paragraph,
and a paragraph that runs on from one frame into the next got two
steps. After Type tool Select All the split paragraph also got two.

The panel now lists each paragraph of a story once, in story order,
by its last piece with text in a selected frame. 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 it.

Docs: docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-11 15:47:06 -08:00
parent 7f3a046b18
commit b33c004fb0
3 changed files with 206 additions and 15 deletions
+72 -15
View File
@@ -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<usize>, targets: &mut Vec<IndentTarget>) {
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<IndentTarget>) {
/// 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<Piece<'_>>> = 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 &paragraphs {
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<IndentTarget> {
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
}
+133
View File
@@ -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::<Vec<_>>().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<_>>(), 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, &para, 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::<Vec<_>>(), before.iter().map(|p| &p.0).collect::<Vec<_>>());
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<u64>| {
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<u64> = targets.iter().map(|p| p.id).collect();
assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}");
let story: Vec<IndentTarget> = 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 = [
+1
View File
@@ -640,6 +640,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.