Merge pull request #6719 from dotCooCoo/fix/tabs-indent-targets-story-order

Tabs: indent drags on threaded frames set each story paragraph once, a split paragraph included
This commit is contained in:
BFlat
2026-11-12 09:10:05 +07:00
committed by GitHub
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
@@ -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.