Type: a Tabs indent drag over many paragraphs no longer takes time in the square of their number

When a Tabs panel indent marker gave the paragraphs of a threaded story
values of their own, the panel sent one text.setFormat for each
paragraph, and the story flowed through its frames again after every
one of them. text.setFormat now takes paragraphIndents, a list of
{id, start, leftIndent?, firstLineIndent?} entries, one per paragraph.
It checks every entry before it changes anything, rebuilds each type
object's paragraph styles once and makes the whole change one edit, so
the story flows again once. The markers send one such command inside
the same labeled command.batch, and a drag that changes no paragraph
runs nothing.

- With the Selection tool, a marker drag on any frame of a threaded
  story now sets every paragraph of the story, in its locked and hidden
  frames too.
- The single-value form of text.setFormat fails, naming the key, when a
  number such as leftIndent is given as a string, a switch or a list.
- A null value of any text.setFormat attribute leaves it as it is, as an
  omitted one does. A call whose attributes are all null fails with
  "nothing to change" and records no edit and no undo step.
- A text.setFormat call whose values are the ones the text already has
  returns its ids and records no edit, no undo step and no new revision.

Docs: docs/mcp.md, docs/target-app-parity.md.
This commit is contained in:
dotCooCoo
2026-11-12 10:39:17 -08:00
parent a15a4abc5c
commit f207ac3518
8 changed files with 656 additions and 93 deletions
File diff suppressed because one or more lines are too long
+161
View File
@@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
let cached = t.cached_bounds.unwrap();
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
}
/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside
/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames.
fn split_story() -> (Session, [u64; 3]) {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap();
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 = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap();
let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap();
s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap();
for id in [b, c] {
assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph");
}
(s, [a, b, c])
}
/// Each frame's text, paragraph styles and whether it continues a paragraph.
fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec<vectorcraft_doc::ParaStyle>, bool)> {
frames
.iter()
.map(|id| {
let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap();
(t.plain_text(), t.paragraph_styles(), t.first_para_continues)
})
.collect()
}
/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the
/// later frame. Each takes indents of its own, wide enough to move text between the frames.
fn indent_entries(s: &Session, frames: &[u64]) -> Vec<Value> {
let mut out = vec![];
for (k, id) in frames.iter().enumerate() {
let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap();
let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues);
let plain = t.plain_text();
let pieces: Vec<&str> = plain.split('\n').collect();
let mut start = 0;
for (i, piece) in pieces.iter().enumerate() {
if i + 1 < pieces.len() || !next_continues {
let n = out.len() as f64;
out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n}));
}
start += piece.len() + 1;
}
}
out
}
/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for
/// each paragraph would, the story's last paragraph first, with a paragraph split between frames
/// set once; the story flows again once, and Undo and Redo take the whole change as one step.
#[test]
fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() {
let (mut one, frames) = split_story();
let (mut each, _) = split_story();
let entries = indent_entries(&one, &frames);
assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}");
let commands: Vec<Value> = entries
.iter()
.rev()
.map(|e| {
json!({"command": "text.setFormat", "params": {
"ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"]
}})
})
.collect();
each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap();
let start = frames_of(&one, &frames);
let undo = one.doc().unwrap().history.undo.len();
let copied = COPIED.with(std::cell::Cell::get);
let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap();
assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once");
// The first frame holds only the start of the paragraph that runs on into the second.
assert_eq!(r, json!({"ids": frames[1..]}));
assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1);
let set = frames_of(&one, &frames);
assert_ne!(set, start);
assert_eq!(set, frames_of(&each, &frames));
one.execute("edit.undo", &json!({})).unwrap();
assert_eq!(frames_of(&one, &frames), start);
one.execute("edit.redo", &json!({})).unwrap();
assert_eq!(frames_of(&one, &frames), set);
}
/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again
/// once.
#[test]
fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap();
let story = vec!["x"; 5000].join("\n");
let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap();
let entries: Vec<Value> = [a, b]
.into_iter()
.flat_map(|id| {
let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap();
let count = t.paragraph_count();
(0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64}))
})
.collect();
assert!(entries.len() >= 5000, "{}", entries.len());
let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get));
s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap();
assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1));
let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap();
assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0));
}
/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and
/// a bad one is refused before anything changes.
#[test]
fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() {
let (mut s, [a, b, c]) = split_story();
let start = frames_of(&s, &[a, b, c]);
let undo = s.doc().unwrap().history.undo.len();
let copied = COPIED.with(std::cell::Cell::get);
let r =
s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]}));
assert_eq!(r.unwrap(), json!({"ids": [a, c]}));
assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change");
let paragraph = text(&s, c).find('\n').unwrap() + 1;
let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap();
let undo = s.doc().unwrap().history.undo.len();
let ok = json!({"id": c, "start": paragraph, "leftIndent": 30});
for bad in [
json!({"paragraphIndents": []}),
json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}),
json!({"paragraphIndents": [ok, 3]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}),
json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}),
json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}),
json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}),
json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}),
json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}),
json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}),
json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}),
json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}),
json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}),
] {
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}");
assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}");
}
// The same entry twice is one change.
s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap();
assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1);
assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0);
}
+124
View File
@@ -2156,3 +2156,127 @@ fn set_path_handle_reshapes_type_on_a_path() {
assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor");
assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type");
}
/// `text.setFormat` refuses a number or switch given as another type, naming the key, and
/// changes nothing; null leaves the attribute as it is.
#[test]
fn set_format_refuses_a_param_of_the_wrong_type() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len());
let before = state(&s);
for key in [
"baselineShift",
"hScale",
"vScale",
"rotation",
"leftIndent",
"rightIndent",
"firstLineIndent",
"spaceBefore",
"spaceAfter",
"wordsLongerThan",
"afterFirst",
"beforeLast",
"hyphenLimit",
"hyphenationZone",
] {
for wrong in [json!("30"), json!(true), json!([30])] {
let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string();
assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}");
}
}
for key in ["underline", "allCaps", "hyphenate", "noBreak"] {
let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string();
assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}");
}
let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string();
assert!(err.contains("`firstLineIndent` must be a number"), "{err}");
assert!(state(&s) == before, "a refused call changes nothing and records no undo step");
s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap();
let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0));
}
/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has
/// nothing to change, and records no edit, no undo step and no new revision.
#[test]
fn set_format_with_only_null_values_changes_nothing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.doc.clone(), st.history.undo.len(), st.revision)
};
let before = state(&s);
for params in [
json!({"leftIndent": null}),
json!({"leftIndent": null, "firstLineIndent": null, "underline": null}),
json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}),
json!({"mojikumi": null}),
json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}),
json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}),
json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}),
] {
let err = s.execute("text.setFormat", &params).unwrap_err().to_string();
assert!(err.contains("nothing to change"), "{params}: {err}");
assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision");
}
// A null beside a value leaves only its own attribute.
let para = |d: &vectorcraft_doc::Document| {
let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") };
(t.para_at(0).mojikumi, t.para_at(0).kinsoku)
};
let mojikumi = para(&before.0).0;
s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap();
assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft));
assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step");
}
/// `text.setFormat` with the values the text already has succeeds and names its objects, but
/// records no edit, no undo step and no new revision.
#[test]
fn set_format_with_the_current_values_records_nothing() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap();
let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap();
s.execute("select.set", &json!({"ids": [id]})).unwrap();
let state = |s: &Session| {
let st = s.doc().unwrap();
(st.doc.clone(), st.history.undo.len(), st.revision)
};
let before = state(&s);
let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") };
let hyphenate = t.para_at(0).hyphenate;
for params in [
json!({"leftIndent": 0, "firstLineIndent": 0}),
json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}),
json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}),
json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}),
] {
let out = s.execute("text.setFormat", &params).unwrap();
assert_eq!(out["ids"], json!([id]), "{params}");
assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision");
}
// A call that changes a value is one undo step; the same call again records nothing.
s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap();
let changed = state(&s);
assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision");
s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap();
assert!(state(&s) == changed, "the same value again records nothing");
s.execute("edit.undo", &json!({})).unwrap();
assert!(s.doc().unwrap().doc == before.0, "one undo restores the text");
// The anti-aliasing method is compared the same way.
s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap();
s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap();
let strong = state(&s);
assert!(strong.1 > before.1, "a new method is an undo step");
let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") };
assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong);
s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap();
assert!(state(&s) == strong, "the same method again records nothing");
}
+2 -2
View File
@@ -46,9 +46,9 @@ fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> {
variables_unlocked(s)
}
/// Ends a typing session as [`Session::edit`] would, before a helper below compares the document,
/// Ends a typing session as [`Session::edit`] would, before a command compares the document,
/// so the typing is its own undo step whether or not the command then changes anything.
fn settle_typing(s: &mut Session) -> Result<()> {
pub(super) fn settle_typing(s: &mut Session) -> Result<()> {
if !s.previewing && super::typing_in_progress(s) {
super::settle_interaction(s)?;
}
+66 -51
View File
@@ -84,11 +84,10 @@ fn append_indent_targets(node: &Node, range: std::ops::Range<usize>, targets: &m
/// 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>) {
/// text, the piece whose style the story keeps when it flows again. An empty paragraph takes its
/// style from its last piece when the story flows again, so it is targeted by that piece. Locked and
/// hidden frames are targeted like the others.
fn append_story_targets(doc: &Document, story: &[NodeId], 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,
@@ -113,11 +112,7 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node
}
}
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))
};
let pick = pieces.iter().rev().find(|p| p.has_text).or_else(|| pieces.last());
if let Some(p) = pick {
targets.push(indent_target(p.id, p.text, p.index, p.start));
}
@@ -125,13 +120,14 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node
}
/// 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`]).
/// threaded text), or all paragraphs of selected text objects. A selected frame of a threaded story
/// selects the whole story, its locked and hidden frames included. 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()) {
append_story_targets(&st.doc, &story, |_| true, &mut targets);
append_story_targets(&st.doc, &story, &mut targets);
} else if let NodeKind::Text(text) = &node.kind {
append_indent_targets(node, text.paragraphs_in(a, b), &mut targets);
}
@@ -153,7 +149,7 @@ fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec<IndentTarget> {
}
let story = st.doc.story_of(id);
done.extend(story.iter().copied());
append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets);
append_story_targets(&st.doc, &story, &mut targets);
}
}
targets
@@ -243,9 +239,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma
}
}
/// 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.
/// Apply an absolute ruler position through paragraph-format commands. When the paragraphs need
/// values of their own, one command sets each paragraph's ([`set_each_indent`]), and threaded text
/// flows again once.
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;
@@ -253,23 +249,10 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return };
if marker == IndentMarker::Upper {
if targets.iter().all(|p| p.left == para.left_indent) {
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) {
app.status(e);
}
run_on_targets(app, targets, json!({"firstLineIndent": target - para.left_indent}));
} else {
super::character::end_typing(app);
let commands: Vec<Value> = targets
.iter()
.rev()
.map(|p| {
json!({"command": "text.setFormat", "params": {
"ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left
}})
})
.collect();
if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) {
app.status(e);
}
let changes = targets.iter().map(|p| (p, None, Some(target - p.left))).collect();
set_each_indent(app, tl!("First-line Left Indent"), changes);
}
} else {
// The ruler shows each paragraph's left indent with its list level's added; the paragraph
@@ -284,26 +267,58 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para:
_ => None,
};
if let Some(params) = params {
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) {
app.status(e);
}
run_on_targets(app, targets, params);
return;
}
super::character::end_typing(app);
let commands: Vec<Value> = targets
.iter()
.rev()
.map(|p| {
let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level});
if !both {
params["firstLineIndent"] = json!(p.left + p.first_line - target);
}
json!({"command": "text.setFormat", "params": params})
})
.collect();
if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) {
app.status(e);
}
let changes = targets.iter().map(|p| (p, Some(target - p.level), (!both).then_some(p.left + p.first_line - target))).collect();
set_each_indent(app, tl!("Left Indent"), changes);
}
}
/// Run one `text.setFormat` with `params` on the paragraphs the Paragraph panel acts on while typing,
/// and with the Selection tool on every type object of `targets`, so a selected frame of a threaded
/// story sets the whole story ([`indent_targets`]).
fn run_on_targets(app: &mut VectorcraftApp, targets: &[IndentTarget], mut params: Value) {
if super::character::text_editing(app).is_none() {
let mut seen = HashSet::new();
params["ids"] = json!(targets.iter().map(|p| p.id).filter(|id| seen.insert(*id)).collect::<Vec<_>>());
}
if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) {
app.status(e);
}
}
/// Give each paragraph its own left and first-line indents (None: as it is) in one `text.setFormat`
/// with `paragraphIndents`, one undo step named `label`. Paragraphs that keep their indents are
/// left out, and when every paragraph keeps them nothing is run.
fn set_each_indent(app: &mut VectorcraftApp, label: &str, changes: Vec<(&IndentTarget, Option<f64>, Option<f64>)>) {
// As `text.setFormat` keeps a paragraph's indents, within 1296 pt either way.
let bound = |v: Option<f64>| v.map(|v| v.clamp(-1296.0, 1296.0));
let entries: Vec<Value> = changes
.into_iter()
.filter_map(|(p, left, first_line)| {
let (left, first_line) = (bound(left), bound(first_line));
let own_left = p.left - p.level;
if left.is_none_or(|v| v == own_left) && first_line.is_none_or(|v| v == p.first_line) {
return None;
}
let mut entry = json!({"id": p.id, "start": p.start});
if let Some(v) = left {
entry["leftIndent"] = json!(v);
}
if let Some(v) = first_line {
entry["firstLineIndent"] = json!(v);
}
Some(entry)
})
.collect();
if entries.is_empty() {
return;
}
super::character::end_typing(app);
let command = json!({"command": "text.setFormat", "params": {"paragraphIndents": entries}});
if let Err(e) = app.run("command.batch", json!({"label": label, "commands": [command]})) {
app.status(e);
}
}
+103 -22
View File
@@ -617,6 +617,52 @@ fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_p
}
}
/// A marker drag over many paragraphs of a threaded story with mixed indents sends one
/// `text.setFormat` for all of them, in one undo step, and a drag that changes no paragraph runs
/// nothing.
#[test]
fn indent_markers_over_many_threaded_paragraphs_send_one_format_command() {
let mut app = VectorcraftApp::new(Session::new(), Default::default());
app.run("file.new", json!({"width": 900, "height": 3000})).unwrap();
let story = (1..=128).map(|i| format!("p{i}")).collect::<Vec<_>>().join("\n");
let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 100}})).unwrap()["id"]
.as_u64()
.unwrap();
let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 2900]})).unwrap()["id"].as_u64().unwrap();
app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap();
app.run("select.set", json!({"ids": [a, b]})).unwrap();
let before = story_paragraphs(&app, &[a, b]);
assert_eq!(before.len(), 128, "{before:?}");
assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}");
let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len());
let drag = |app: &mut VectorcraftApp, marker, to| {
let (node, para, span) = current_para(app).unwrap();
let targets = indent_targets(app, &node);
assert_eq!(targets.len(), 128);
set_marker(app, marker, to, &para, span, &targets, false);
};
let (undo, journal) = steps(&app);
drag(&mut app, IndentMarker::Upper, 60.0);
assert_eq!(steps(&app), (undo + 1, journal + 1));
let (name, params) = app.session.journal.last().cloned().unwrap();
assert_eq!(name, "command.batch");
let commands = params["commands"].as_array().unwrap();
assert_eq!(commands.len(), 1, "one command for every paragraph");
assert_eq!(commands[0]["command"], "text.setFormat");
assert_eq!(commands[0]["params"]["paragraphIndents"].as_array().unwrap().len(), 128);
let after = story_paragraphs(&app, &[a, b]);
for (was, now) in before.iter().zip(&after) {
assert_eq!((&now.0, now.1, now.2), (&was.0, was.1, 60.0), "{after:?}");
}
// The same position again changes no paragraph.
drag(&mut app, IndentMarker::Upper, 60.0);
assert_eq!(steps(&app), (undo + 1, journal + 1), "nothing ran");
app.run("edit.undo", json!({})).unwrap();
assert_eq!(story_paragraphs(&app, &[a, b]), before);
app.run("edit.redo", json!({})).unwrap();
assert_eq!(story_paragraphs(&app, &[a, b]), after);
}
/// 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() {
@@ -633,12 +679,12 @@ fn indent_markers_after_select_all_list_a_split_paragraph_once() {
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.
/// With the Selection tool on any frame of a threaded story, the indent markers list every
/// paragraph of the story, each once in story order: a selected story is one text. 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() {
fn indent_markers_on_some_threaded_frames_list_the_whole_story_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>| {
@@ -646,29 +692,64 @@ fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() {
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 story = count(&app, a) + count(&app, b) + count(&app, c) - 2;
let all = targets_of(&mut app, vec![c, a, b]);
assert_eq!(all.len(), story, "two frames that share a paragraph list it once: {all:?}");
assert!(all.windows(2).all(|w| w[0] < w[1]), "{all:?}");
for frame in [a, b, c] {
assert_eq!(targets_of(&mut app, vec![frame]), all, "frame {frame} alone lists the whole story");
}
// A grouped empty text object besides the last frame of the story.
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();
app.run("select.set", json!({"ids": [group]})).unwrap();
let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).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:?}");
let in_story: Vec<IndentTarget> = targets.into_iter().filter(|p| p.id != empty).collect();
assert_eq!(story_positions(&in_story, &[a, b, c]), all);
}
/// A marker drag on one selected frame of a threaded story sets every paragraph of the story, in
/// its locked and hidden frames too, in one undo step, whether the paragraphs share their indents
/// or have their own.
#[test]
fn indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames() {
let (mut app, [a, b, c]) = split_story_app();
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();
// Every paragraph has the same indents.
let (node, para, span) = current_para(&app).unwrap();
let targets = indent_targets(&app, &node);
assert_eq!(targets.len(), 12);
let undo = app.session.active().unwrap().history.undo.len();
set_marker(&mut app, IndentMarker::Upper, 30.0, &para, span, &targets, false);
assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1);
let shared = story_paragraphs(&app, &[a, b, c]);
assert_eq!(shared.len(), 12, "{shared:?}");
assert!(shared.iter().all(|p| (p.1, p.2) == (0.0, 30.0)), "{shared:?}");
// The paragraphs of the hidden frame have a left indent of their own.
app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap();
app.run("select.set", json!({"ids": [a]})).unwrap();
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);
assert_eq!(targets.len(), 12);
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);
}
}
#[test]
+22
View File
@@ -8236,6 +8236,28 @@ stops, the command changes nothing and records no undo step. `text.tabs.set` rej
holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and
keeps the first of stops at one position.
`text.setFormat {paragraphIndents: [{id, start, leftIndent?, firstLineIndent?}…]}` gives each paragraph indents of its
own in one call: each entry names one paragraph of a type object by the byte `start` where the paragraph begins, and
sets its `leftIndent`, its `firstLineIndent` or both, in points from -1296 to 1296. The call takes no other key, and
at most 100000 entries. It checks every entry before it changes anything, and fails on an empty list, an unknown key, an
entry with neither indent, a value that is not a number in range, an `id` that is not a type object, a `start` where no
paragraph of that object begins, and two entries for one paragraph with other values (the same entry twice counts once).
The whole change is one edit: each type object's paragraph styles are rebuilt once, and a threaded story flows through
its frames again once, however many of its paragraphs change, where one `text.setFormat {ids, start, end}` for each
paragraph flows it again after every paragraph. When no paragraph's indents change, the command records no edit and no
undo step. It returns `{ids}`, the type objects the entries name. The Tabs panel's indent markers use it when the
paragraphs they move have indents of their own, inside a `command.batch` that names the undo step after the marker.
```json
{"name":"run_command","arguments":{"command":"text.setFormat","params":{"paragraphIndents":[{"id":12,"start":0,"firstLineIndent":36},{"id":12,"start":40,"leftIndent":24,"firstLineIndent":12}]}}}
```
The single-value form of `text.setFormat` fails, naming the key, when a number such as `leftIndent` or `baselineShift` is
given as a string, a switch or a list, or a switch such as `underline` as anything but true or false; it changes nothing
then. A null value of any attribute leaves that attribute as it is, as an omitted one does, so a call whose attributes
are all null fails with "nothing to change" and records no edit and no undo step. A call whose values are the ones the
text already has succeeds and returns its `ids`, but records no edit and no undo step.
`text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the
paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in
the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and
+1 -1
View File
@@ -657,7 +657,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).
- **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 {paragraphIndents}` with an entry for each paragraph of the story, whatever order the frames were selected in with the Selection tool, so the story flows through its frames again once rather than once for each paragraph, and a drag that changes no paragraph runs nothing; a paragraph that runs on from one frame into the next gets one entry, after Type tool Select All too. A selected story is one text: with the Selection tool, a marker drag on any of its frames sets every paragraph of the story, in its locked and hidden frames too. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_over_many_threaded_paragraphs_send_one_format_command`, `indent_markers_after_select_all_list_a_split_paragraph_once`, `indent_markers_on_some_threaded_frames_list_the_whole_story_once`, `indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames`, `cmd::threads::tests::paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would`, `paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once` and `paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing`, and `cmd::typecmd::area_tests::set_format_refuses_a_param_of_the_wrong_type`. 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.