Type: the Numbered List button numbers paragraphs 1, 2, 3

Squashed from #3960 onto main.
This commit is contained in:
dotCooCoo
2026-10-24 19:02:08 +07:00
committed by bflatastic
parent 0635357eb9
commit bc041e15ef
47 changed files with 1075 additions and 95 deletions
File diff suppressed because one or more lines are too long
+2
View File
@@ -900,6 +900,7 @@ fn create_in_path(s: &mut Session, p: &Value) -> Result<Value> {
optical_margin: false,
system_layout: false,
fractional_widths: true,
list_count: 0,
cached_bounds: None,
cached_baselines: Vec::new(),
};
@@ -1148,6 +1149,7 @@ fn headline_tracking(t: &TextObject, target: f64) -> Option<f64> {
optical_margin: false,
system_layout: t.system_layout,
fractional_widths: t.fractional_widths,
list_count: 0,
cached_bounds: None,
cached_baselines: Vec::new(),
};
+212 -1
View File
@@ -153,11 +153,17 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
next.runs = part.runs;
next.set_paragraph_styles(part.paras);
let optical = optical.unwrap_or(t.optical_margin);
if next.runs != t.runs || next.para != t.para || next.paras != t.paras || optical != t.optical_margin {
if next.runs != t.runs
|| next.para != t.para
|| next.paras != t.paras
|| optical != t.optical_margin
|| part.list_count != t.list_count
{
t.runs = next.runs;
t.para = next.para;
t.paras = next.paras;
t.optical_margin = optical;
t.list_count = part.list_count;
refresh_bounds(t);
}
}
@@ -169,6 +175,36 @@ pub(crate) fn reflow(before: &Document, doc: &mut Document) {
doc.text_threads = kept;
}
/// Type that is not a frame of a thread is a story of its own: its numbered list paragraphs count
/// from 1. Each such type object with another [`TextObject::list_count`] (a copy of a frame, a
/// frame taken out of its thread, a frame converted to point type, which [`reflow`] takes out of
/// its thread) gets 0 and is laid out again.
pub(crate) fn restart_unthreaded_counts(doc: &mut std::sync::Arc<Document>) {
let mut stale = vec![];
doc.walk(|n| {
if let NodeKind::Text(t) = &n.kind
&& t.list_count != 0
{
stale.push(n.id);
}
});
if stale.is_empty() {
return;
}
let threaded: std::collections::HashSet<NodeId> = doc.text_threads.iter().flatten().copied().filter(|id| text_of(doc, *id).is_some()).collect();
stale.retain(|id| !threaded.contains(id));
if stale.is_empty() {
return;
}
let doc = std::sync::Arc::make_mut(doc);
for id in stale {
if let Some(NodeKind::Text(t)) = doc.node_mut(id).map(|n| &mut n.kind) {
t.list_count = 0;
refresh_bounds(t);
}
}
}
/// The Optical Margin Alignment setting that every frame of a thread with the frames `live` takes:
/// that of the first frame whose setting changed between `before` and `doc`, else the first frame's.
fn optical_margin(before: &Document, doc: &Document, live: &[NodeId]) -> Option<bool> {
@@ -478,6 +514,7 @@ fn release(s: &mut Session, _: &Value) -> Result<Value> {
vectorcraft_text::edit::normalize(&mut story);
t.runs = story;
t.set_paragraph_styles(paras);
refresh_bounds(t);
}
let i = d.text_threads.iter().position(|t| *t == thread).unwrap_or(0);
d.text_threads[i] = keep;
@@ -1130,4 +1167,178 @@ mod tests {
assert_eq!((optical(&s, a), optical(&s, b)), ((first, first), (first, first)), "{name}, first frame {first}");
}
}
/// One count runs through a thread: the second frame's numbered paragraphs go on from the
/// first frame's, also after an edit, a save and a reopen. A pasted or duplicated copy of a
/// frame, a frame converted to point type, the frames of a thread that Remove Threading breaks
/// and the frame a Release Selection leaves alone count from 1.
#[test]
fn numbered_list_paragraphs_count_on_through_a_thread() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
let numbers = |s: &Session, id: u64| {
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
let all = |from: usize| (from..=10).map(|n| format!("{n}.")).collect::<Vec<_>>();
let first = numbers(&s, a);
assert!((2..10).contains(&first.len()), "both frames hold items: {first:?}");
assert_eq!([first.clone(), numbers(&s, b)].concat(), all(1));
// Typing a paragraph into the first frame numbers it and moves the rest on.
s.execute("text.editRange", &json!({"id": a, "start": 0, "end": 0, "insert": "Item 0\n"})).unwrap();
assert_eq!([numbers(&s, a), numbers(&s, b)].concat(), [vec!["1.".to_string()], all(2), vec!["11.".to_string()]].concat());
s.execute("edit.undo", &json!({})).unwrap();
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(b)).map(|n| &n.kind) else { panic!("text") };
let after: Vec<String> = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), back).markers.iter().map(|m| m.text.clone()).collect();
assert_eq!(after, all(first.len() + 1), "reopened");
// A pasted copy of the second frame is in no thread.
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let copy = s.doc().unwrap().selection.objects.first().copied().unwrap();
assert_ne!(copy, NodeId(b));
assert_eq!(numbers(&s, copy.0).first().map(String::as_str), Some("1."));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let duplicate = s.execute("edit.duplicate", &json!({"dx": 10})).unwrap()["ids"][0].as_u64().unwrap();
assert_eq!(numbers(&s, duplicate).first().map(String::as_str), Some("1."), "a duplicate");
let layer = s.doc().unwrap().doc.layers[0].id.0;
let moved = s.execute("layer.move", &json!({"ids": [b], "target": layer, "copy": true})).unwrap()["ids"][0].as_u64().unwrap();
assert_eq!(numbers(&s, moved).first().map(String::as_str), Some("1."), "a copy dragged in the Layers panel");
assert_eq!(numbers(&s, b), all(first.len() + 1), "the frame itself");
// Convert To Point Type takes the second frame out of the thread with its text.
s.execute("type.convertToPointType", &json!({"ids": [b]})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."), "the frame converted to point type");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
// Remove Threading: each frame is a story of its own.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.remove", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(numbers(&s, b).first().map(String::as_str), Some("1."));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(numbers(&s, b), all(first.len() + 1), "undo");
// Release Selection of the first frame: the second one holds the whole story, from 1.
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(numbers(&s, b), all(1), "the frame left holds the story");
}
/// New Symbol from a numbered thread keeps each frame's count in the symbol's art, so the art
/// looks as it did; Break Link puts the frames in no thread, and each counts from 1.
#[test]
fn a_symbol_made_of_a_numbered_thread_keeps_its_numbers() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
let count = |t: &TextObject| t.list_count;
let carried = text_of(&s.doc().unwrap().doc, NodeId(b)).map(count).unwrap();
assert!(carried > 0);
let instance = new_id(s.execute("symbol.new", &json!({"name": "Numbered"})).unwrap());
let counts = |n: &Node| {
let mut counts = vec![];
n.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
counts.push(t.list_count);
}
});
counts.sort_unstable();
counts
};
let doc = &s.doc().unwrap().doc;
assert_eq!(counts(&doc.symbols.iter().find(|x| x.name == "Numbered").unwrap().art), [0, carried]);
// Break Link puts the frames on the artboard in no thread: each counts from 1.
s.execute("select.set", &json!({"ids": [instance]})).unwrap();
s.execute("symbol.breakLink", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
assert_eq!(doc.layers.iter().flat_map(|l| counts(l)).collect::<Vec<_>>(), [0, 0]);
}
/// A frame of a numbered thread pasted while an opacity mask is edited numbers from 1 in the
/// mask art too.
#[test]
fn a_frame_pasted_into_an_edited_opacity_mask_numbers_from_1() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": items, "area": {"width": 150, "height": 60}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 150, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "list": "number"})).unwrap();
assert!(text_of(&s.doc().unwrap().doc, NodeId(b)).is_some_and(|t| t.list_count > 0));
s.execute("select.set", &json!({"ids": [b]})).unwrap();
s.execute("edit.copy", &json!({})).unwrap();
let obj = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 400, "height": 280})).unwrap());
let m = new_id(s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 100, "height": 200})).unwrap());
s.execute("select.set", &json!({"ids": [obj, m]})).unwrap();
s.execute("transparency.makeOpacityMask", &json!({})).unwrap();
s.execute("transparency.editOpacityMask", &json!({})).unwrap();
s.execute("edit.paste", &json!({})).unwrap();
let doc = &s.doc().unwrap().doc;
let mut counts = vec![];
doc.node(NodeId(obj)).unwrap().extras().mask.as_ref().unwrap().art.walk(&mut |n| {
if let NodeKind::Text(t) = &n.kind {
counts.push(t.list_count);
}
});
assert_eq!(counts, [0], "the pasted frame in the mask art");
}
/// A duplicate of a frame that carried a count of 99 or more numbers from 1 and has the layout
/// bounds of what it draws: "01." leaves the text at the left indent, where a three-digit
/// number pushed it on to the next tab stop and the paragraph took more lines.
#[test]
fn a_duplicated_frame_numbers_from_1_with_its_own_bounds() {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 600, "height": 300})).unwrap();
let story = format!("{}Itemized lists paragraph text", "x\n".repeat(110));
let a = new_id(s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "area": {"width": 150, "height": 1500}})).unwrap());
let b = new_id(s.execute("shape.rectangle", &json!({"x": 200, "y": 10, "width": 100, "height": 300})).unwrap());
s.execute("select.set", &json!({"ids": [a, b]})).unwrap();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("text.setFormat", &json!({"ids": [a, b], "number": "twoDigits"})).unwrap();
s.execute("select.set", &json!({"ids": [b]})).unwrap();
let copy = s.execute("edit.duplicate", &json!({"dx": 200})).unwrap()["ids"][0].as_u64().unwrap();
let laid = |s: &Session, id: u64| {
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(id)) else { panic!("area type") };
let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);
(lay.markers[0].text.clone(), lay.lines.len(), lay.bounds, t.cached_bounds.unwrap())
};
let (number, lines, _, _) = laid(&s, b);
let (copy_number, copy_lines, bounds, cached) = laid(&s, copy);
assert!(number.len() == 4 && number.starts_with('1'), "a three-digit number: {number}");
assert_eq!(copy_number, "01.");
assert!(copy_lines < lines, "{copy_lines} {lines}");
assert!((cached.y1 - bounds.y1).abs() < 1e-6, "the copy's bounds: {cached:?} {bounds:?}");
}
/// Release Selection that leaves one frame of a thread gives that frame the whole story and
/// the layout bounds of its new text.
#[test]
fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() {
let (mut s, a, b) = setup();
s.execute("text.thread.create", &json!({})).unwrap();
s.execute("select.set", &json!({"ids": [a]})).unwrap();
s.execute("text.thread.releaseSelection", &json!({})).unwrap();
assert!(threads(&s).is_empty());
assert_eq!(text(&s, b), TEXT.repeat(4));
let Some(t) = text_of(&s.doc().unwrap().doc, NodeId(b)) else { panic!("area type") };
let bounds = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t).bounds;
let cached = t.cached_bounds.unwrap();
assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}");
}
}
+73
View File
@@ -1537,6 +1537,79 @@ mod area_tests {
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
}
/// Numbered lists: `list: "number"` numbers the paragraphs 1, 2, 3 and `number` picks the
/// style; a list switched between bullets and numbers keeps its bullet and its indents; an
/// unknown value is an error; the list is saved, reopened and carried by paragraph styles.
#[test]
fn numbered_lists_are_set_per_paragraph_saved_and_styled() {
use vectorcraft_doc::text::{Bullet, NumberStyle, ParaList};
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": 10, "text": "One\nTwo\nThree", "area": {"width": 300, "height": 200}})).unwrap()["id"]
.as_u64()
.unwrap();
let text = |s: &Session| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {
NodeKind::Text(t) => (**t).clone(),
_ => panic!("text"),
};
let list = |s: &Session, i: usize| text(s).para_at(i).list;
let numbers = |s: &Session| {
vectorcraft_text::layout(vectorcraft_text::FontDb::global(), &text(s)).markers.iter().map(|m| m.text.clone()).collect::<Vec<_>>()
};
s.execute("select.set", &json!({"ids": [id]})).unwrap();
s.execute("text.setFormat", &json!({"list": "number"})).unwrap();
let arabic = Some(ParaList::numbered(NumberStyle::Arabic));
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (arabic, arabic, arabic));
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
assert_eq!(numbers(&s), ["1.", "2.", "3."]);
// `number` picks the style, `list: "number"` keeps it, and the count runs on through both.
s.execute("text.setFormat", &json!({"number": "upperRoman", "start": 0, "end": 0})).unwrap();
s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 0})).unwrap();
let roman = Some(ParaList::numbered(NumberStyle::UpperRoman));
assert_eq!(list(&s, 0), roman);
assert_eq!(numbers(&s), ["I.", "2.", "3."]);
// A bullet makes it bulleted, a number style numbered again, and `list: "bullet"` brings
// back the bullet it had; the indents stay as they are.
s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();
assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square)));
s.execute("text.setFormat", &json!({"number": "lowerLetters", "start": 0, "end": 0})).unwrap();
let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters) });
assert_eq!(list(&s, 0), letters);
assert_eq!(numbers(&s), ["a.", "2.", "3."]);
assert_eq!((text(&s).para_at(0).left_indent, text(&s).para_at(0).first_line_indent), (18.0, -18.0));
for bad in [json!({"number": "roman"}), json!({"number": 1}), json!({"list": "numbered"}), json!({"number": true})] {
assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");
}
assert_eq!((list(&s, 0), list(&s, 1)), (letters, arabic), "an unknown value changes nothing");
assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "number": "lowerLetters"}));
let reopened = vectorcraft_format::load(&vectorcraft_format::save(&s.doc().unwrap().doc, false)).unwrap();
let Some(NodeKind::Text(back)) = reopened.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };
assert_eq!((back.para_at(0).list, back.para_at(1).list, back.para_at(2).list), (letters, arabic, arabic));
// Return at the end of a numbered item starts the next number.
s.execute("text.editRange", &json!({"id": id, "start": 13, "end": 13, "insert": "\nFour"})).unwrap();
assert_eq!(numbers(&s), ["a.", "2.", "3.", "4."]);
s.execute("edit.undo", &json!({})).unwrap();
// "none" turns the list off; the next numbered paragraph starts again at 1.
s.execute("text.setFormat", &json!({"number": "none", "start": 4, "end": 4})).unwrap();
assert_eq!(list(&s, 1), None);
assert_eq!(numbers(&s), ["a.", "1."]);
// A style that holds a numbered list sets it, a style captured from a numbered paragraph
// carries its list, and one captured from a paragraph without a list turns it off.
s.execute("paraStyle.new", &json!({"name": "Roman", "attrs": {"list": {"number": "upperRoman"}}})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Roman", "id": id})).unwrap();
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (roman, roman, roman));
assert_eq!(numbers(&s), ["I.", "II.", "III."]);
let plain = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Plain"})).unwrap()["id"].as_u64().unwrap();
s.execute("paraStyle.new", &json!({"name": "Plain", "id": plain})).unwrap();
s.execute("paraStyle.new", &json!({"name": "Captured", "id": id})).unwrap();
s.execute("paraStyle.apply", &json!({"name": "Captured", "id": plain})).unwrap();
let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(plain)).map(|n| &n.kind) else { panic!("text") };
assert_eq!(t.para_at(0).list, roman);
s.execute("paraStyle.apply", &json!({"name": "Plain", "id": id})).unwrap();
assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None));
}
/// Type › Create Outlines makes a path of a list item's bullet as well as of its characters.
#[test]
fn create_outlines_makes_a_path_of_the_bullet() {