Browse / github.com/storytold/vectorcraft / patch
Type: Bullets and Numbering… sets the marker, its alignment and the indents with a live preview
Type: Bullets and Numbering… sets the marker, its alignment and the indents with a live preview ## What and why VectorCraft had no Bullets and Numbering dialog, and a list's marker always started where the first line starts. In the reference app, More Options in the Bullets and Numbered List dropdowns opens that dialog: "select the Bullets or Numbers tab, and then select the desired bullet character or number style to preview the effect in the text", and its Position group has the Alignment dropdown, Left Indent and First Line Indent ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-te
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #4110, open)
- Base
e2a8280134a7c3c9b4e117ee668b9b3e7eb3e315- Size
- +1255 −63 in 39 files
- Review
- pending
- Shared
- 2026-10-28
- Id
2c6ec49dd5e7e15578335f06
How it went
No other operator has reported using it yet.
Diff
raw · crates/cad/src/scene.rs crates/cad/src/tests.rs crates/doc/src/text.rs crates/engine/src/cmd/panelcmds.rs crates/engine/src/cmd/typecmd.rs crates/svg/tests/it/text_layout.rs crates/text/src/layout.rs crates/text/src/lib.rs crates/text/src/tests.rs crates/ui-egui/src/dialogs/bullets_numbering.rs crates/ui-egui/src/dialogs/mod.rs crates/ui-egui/src/i18n/bg.tsv crates/ui-egui/src/i18n/cs.tsv crates/ui-egui/src/i18n/da.tsv crates/ui-egui/src/i18n/de.tsv crates/ui-egui/src/i18n/el.tsv crates/ui-egui/src/i18n/es.tsv crates/ui-egui/src/i18n/fi.tsv crates/ui-egui/src/i18n/fr.tsv crates/ui-egui/src/i18n/hu.tsv crates/ui-egui/src/i18n/id.tsv crates/ui-egui/src/i18n/it.tsv crates/ui-egui/src/i18n/ja.tsv crates/ui-egui/src/i18n/ka.tsv crates/ui-egui/src/i18n/ko.tsv crates/ui-egui/src/i18n/nl.tsv crates/ui-egui/src/i18n/pl.tsv crates/ui-egui/src/i18n/pt-br.tsv crates/ui-egui/src/i18n/ru.tsv crates/ui-egui/src/i18n/sv.tsv crates/ui-egui/src/i18n/tr.tsv crates/ui-egui/src/i18n/uk.tsv crates/ui-egui/src/i18n/vi.tsv crates/ui-egui/src/i18n/zh-hans.tsv crates/ui-egui/src/i18n/zh-hant.tsv crates/ui-egui/src/menus.rs crates/ui-egui/src/panels/paragraph.rs docs/mcp.md docs/target-app-parity.md
@@ -909,10 +909,10 @@ impl<'a> Scene<'a> { let Some(&first) = run.first() else { continue }; let lo = run.iter().map(|g| g.byte).min().unwrap_or(first.byte); let hi = run.iter().map(|g| g.byte + g.len).max().unwrap_or(lo);- // A right-to-left paragraph's marker is drawn with its tab first and its number's- // period at the number's left; the TEXT entity holds the characters in that order.+ // A right-to-left paragraph's marker is drawn with its number's period at the+ // number's left; the TEXT entity holds the characters in that order. let marker = chunk.first().and_then(|(_, _, m)| *m).map(|m| match m.text.strip_suffix('.') {- Some(number) if m.tab == m.glyphs.start => format!(".{number}"),+ Some(number) if layout.glyphs.get(m.tab).is_some_and(|g| g.rtl) => format!(".{number}"), _ => m.text.clone(), }); let Some(s) = marker.as_deref().or_else(|| plain.get(lo..hi).map(str::trim_end)).filter(|s| !s.trim().is_empty()) else { continue };@@ -494,15 +494,15 @@ fn a_bullet_is_text_of_its_own_before_its_paragraph() { /// A numbered list paragraph's number and period are a TEXT entity of their own where they are /// drawn, in the order they are drawn: in a right-to-left paragraph the period comes first, at the-/// number's left.+/// number's left, with the marker aligned left or right. #[test] fn a_number_is_text_of_its_own_before_its_paragraph() {- use vectorcraft_doc::text::{NumberStyle, ParaList};- let numbered = |text: &str, count: u32| {+ use vectorcraft_doc::text::{ListAlign, NumberStyle, ParaList};+ let numbered_aligned = |text: &str, count: u32, align: ListAlign| { let mut d = text_doc(text, 20.0); let id = d.layers[0].children().unwrap()[0].id; if let Some(NodeKind::Text(t)) = d.node_mut(id).map(|n| &mut n.kind) {- t.para.list = Some(ParaList::numbered(NumberStyle::Arabic));+ t.para.list = Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); t.para.left_indent = 30.0; t.para.first_line_indent = -30.0; t.list_count = count;@@ -510,10 +510,17 @@ fn a_number_is_text_of_its_own_before_its_paragraph() { let out = export(&d, &DxfOptions { version: DxfVersion::R2018, preserve: Preserve::Editability, ..opts(&d) }).unwrap(); entities(&out).iter().map(|e| (get(e, 1).map(str::to_string), all(e, 10)[0])).collect::<Vec<_>>() };+ let numbered = |text: &str, count: u32| numbered_aligned(text, count, ListAlign::Left); assert_eq!(numbered("Item", 11), [(Some("12.".to_string()), 10.0), (Some("Item".to_string()), 40.0)]); let rtl = numbered("שלום", 11); assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]); assert!(rtl[1].1 > rtl[0].1, "the number right of the text: {rtl:?}");+ // Aligned right, the number keeps its order and moves right, against the text.+ let right = numbered_aligned("Item", 11, ListAlign::Right);+ assert_eq!((right[0].0.as_deref(), right[1].clone()), (Some("12."), (Some("Item".to_string()), 40.0)));+ assert!(right[0].1 > 10.0 && right[0].1 < 40.0, "{right:?}");+ let rtl = numbered_aligned("שלום", 11, ListAlign::Right);+ assert_eq!(rtl.iter().map(|(s, _)| s.clone().unwrap_or_default()).collect::<Vec<_>>(), ["שלום", ".12"]); } /// Area type that Shrink Text to Fit scales down is written at the height it draws at.@@ -601,11 +601,25 @@ fn roman(mut n: u32) -> String { s } +/// Where a list's marker stands before the first line's text ([`ParaList::align`]): the Bullets+/// and Numbering dialog's Alignment.+#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]+#[serde(rename_all = "camelCase")]+pub enum ListAlign {+ /// The marker starts where the first line starts: at the left indent plus the first-line+ /// indent.+ #[default]+ Left,+ /// The marker ends where the first line's text starts, against the text.+ Right,+}+ /// The list a paragraph is in (Paragraph panel › Bullets and Numbered List). The layout draws the /// list's marker before the paragraph's first line, in the font, size and color of the paragraph's /// first character without its baseline shift, rotation, superscript or subscript, tracking, manual /// kerning, tate-chu-yoko, underline and strikethrough ([`Self::marker_style`]), and the text after-/// it starts at the next tab stop, the left indent counting as a stop. The marker is not a+/// it starts at the next tab stop, the left indent counting as a stop. The marker stands at the+/// first line's start, or against the text with [`ListAlign::Right`]. The marker is not a /// character of the text, and an empty paragraph has none. A saved list reads the fields it leaves /// out as their defaults, and a build ignores the fields it doesn't know. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]@@ -618,17 +632,26 @@ pub struct ParaList { /// numbered list. #[serde(skip_serializing_if = "Option::is_none")] pub number: Option<NumberStyle>,+ /// Where the marker stands before the first line's text. The file saves it only when it is+ /// [`ListAlign::Right`].+ #[serde(skip_serializing_if = "crate::skip::is_default")]+ pub align: ListAlign, } impl ParaList { /// A bulleted list with `bullet`. pub fn bulleted(bullet: Bullet) -> Self {- Self { bullet, number: None }+ Self { bullet, ..Self::default() } } /// A numbered list with `style`. pub fn numbered(style: NumberStyle) -> Self {- Self { bullet: Bullet::default(), number: Some(style) }+ Self { number: Some(style), ..Self::default() }+ }++ /// This list with its marker at `align`.+ pub fn aligned(self, align: ListAlign) -> Self {+ Self { align, ..self } } /// The characters of the marker the layout draws before the paragraph: the bullet, or for a@@ -2352,6 +2375,20 @@ mod tests { ); } + /// A list's marker alignment: Left by default and not saved, Right saved as `"align": "right"`;+ /// a list saved without it reads as Left.+ #[test]+ fn a_list_saves_its_marker_alignment_only_when_it_is_right() {+ let right = ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right);+ assert_eq!(ParaList::bulleted(Bullet::Dot).align, ListAlign::Left);+ assert_eq!(serde_json::to_value(right).unwrap(), serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"}));+ assert_eq!(serde_json::to_value(ParaList::bulleted(Bullet::Dash)).unwrap(), serde_json::json!({"bullet": "dash"}));+ let read = |v: serde_json::Value| serde_json::from_value::<ParaList>(v).unwrap();+ assert_eq!(read(serde_json::json!({"bullet": "dot", "number": "arabic", "align": "right"})), right);+ assert_eq!(read(serde_json::json!({"bullet": "square"})).align, ListAlign::Left);+ assert!(serde_json::from_value::<ParaList>(serde_json::json!({"align": "center"})).is_err());+ }+ /// Typed list detection: "1.", "1)", "A." and "A)" and a space at the start of a paragraph /// start a numbered list, and "*", "#", ">" and "+" and a space start a bulleted list with the /// filled dot. Other text, the same characters later in a paragraph or without the space, and@@ -43,7 +43,7 @@ pub fn specs() -> Vec<CommandSpec> { "Character / Paragraph", [], None,- "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)",+ "{ids?|id?, kerning?: 1/1000 em|\"auto\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg, underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), leftIndent?, rightIndent?, firstLineIndent?, spaceBefore?, spaceAfter?: pt (±1296), list?: \"none\"|\"bullet\"|\"number\", bullet?: \"none\"|\"dot\"|\"dash\"|\"circle\"|\"square\", number?: \"none\"|\"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\", listAlign?: \"left\"|\"right\" (Paragraph panel › Bullets and Numbered List: each paragraph of a list starts with its marker, drawn in the font, size and color of the paragraph's first character, and its text starts at the next tab stop after the marker, the left indent counting as a stop; a bulleted list's marker is its bullet, and a numbered list's is the paragraph's number and a period: 1, 2, 3; A to Z, then AA, BB; a to z, then aa, bb; I, II, III; i, ii, iii; 01 to 99, then 100; 001 to 999, then 1000; letters and roman numerals past 3999 in Arabic digits; the numbers count the story's numbered paragraphs, its threaded frames included, from 1 after a paragraph in no list, and an empty or bulleted paragraph is not counted; list \"bullet\" keeps a list's bullet, else a dot, and list \"number\" keeps a numbered list's style, else arabic; without list, a number style makes a numbered list and a bullet makes a bulleted one; \"none\" in any of the three turns the list off; a paragraph that joins a list with left and first-line indents of 0 gets 18 and -18 unless the call sets an indent; a horizontal right-to-left list paragraph has its indents mirrored, measured from the right; listAlign \"right\" (Bullets and Numbering…'s Alignment) puts the marker against the text, and \"left\" (the default) puts it at the first line's start, the text starting at the same place with either; it sets the paragraphs that are in a list or that the call puts in one, and a list keeps it when it changes between bullets and numbers), hyphenate?: bool, wordsLongerThan?: letters 1..25 (5), afterFirst?, beforeLast?: letters 1..15 (3), hyphenLimit?: lines 0..25 (0: no limit), hyphenationZone?: pt 0..1296 (0; Single-line Composer only), hyphenateCapitalized?: bool (true) (Paragraph panel menu › Hyphenation…: only words longer than wordsLongerThan break, keeping afterFirst letters before the hyphen and beforeLast after it; at most hyphenLimit lines in a row end with a hyphen; a line whose text already ends within hyphenationZone of its right edge breaks before the next word instead of hyphenating it; the counts are rounded, and every value is clamped to its range), wordSpacing?: % 0..1000 (80, 100, 133), letterSpacing?: % of a word space -100..500 (0, 0, 0), glyphScaling?: % 50..200 (100, 100, 100): each [min, desired, max] or {min?, desired?, max?} (Paragraph panel menu › Justification…: Desired applies to every line, Minimum and Maximum bound what justified lines may use; values are clamped to their range, and Minimum ≤ Desired ≤ Maximum must hold afterwards or nothing changes), autoLeading?: % of the size 0..500 (120; the leading of characters whose leading is Auto), singleWordJustification?: \"fullJustify\"|\"alignLeft\"|\"alignCenter\"|\"alignRight\" (a justified line holding one word), mojikumi?: \"none\"|\"lineEndHalf\" (Japanese punctuation spacing), kinsoku?: \"none\"|\"hard\"|\"soft\" (Kinsoku Set: Soft lets 々, ー and small kana start a line), direction?: \"auto\"|\"leftToRight\"|\"rightToLeft\" (paragraph direction; auto: from each paragraph's first strong character), leadingModel?: \"romanBaseline\"|\"emBoxTop\" (leading measured baseline to baseline, or em box top to top), charAlign?: \"romanBaseline\"|\"emBoxTop\"|\"emBoxCenter\"|\"emBoxBottom\"|\"icfTop\"|\"icfBottom\" (where characters smaller than the largest on their line line up with it), proportionalMetrics?: bool (full-width glyphs on the font's proportional widths: `palt` in horizontal type, `vpal` in vertical type), tateChuYoko?: bool (Character panel menu › Tate-chu-yoko, with Show East Asian Options: in vertical type those characters stand side by side across one em of the column; kept on the characters and ignored in horizontal type), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), charDirection?: \"default\"|\"leftToRight\"|\"rightToLeft\" (Character panel menu › Character Direction: the characters are set left to right or right to left inside their paragraph, as a Unicode bidi embedding; default: as the bidi algorithm sets them), digits?: \"default\"|\"arabic\"|\"hindi\"|\"farsi\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed), fontAxes?: {\"wght\": 650, …} (Character panel › Variable Font, as text.setRangeStyle: each axis named set in its units and clamped to its range, null back to the style's own setting), burasagari?: \"none\"|\"standard\"|\"forced\" (Paragraph panel menu › Burasagari None/Regular/Force: an East Asian comma or full stop ending an area type line hangs outside it: when it doesn't fit, or always; new type: \"standard\"), romanHangingPunctuation?: bool (Paragraph panel menu › Roman Hanging Punctuation: quotation marks, hyphens, full stops and commas starting or ending a line hang outside the margin, asterisks, tildes, ellipses, dashes, colons and semicolons by half; off by default), kashida?: \"none\"|\"short\"|\"medium\"|\"long\"|\"stylistic\" (Paragraph panel › Insert Kashidas: justified lines of horizontal area type lengthen each Arabic word with a kashida between its last pair of joined letters, at most a quarter, half or whole em long, or whole tatweels up to an em for stylistic, before they widen their word spaces; none by default), composer?: \"singleLine\"|\"everyLine\" (line breaking, Every-line by default), start?: byte, end?: byte} (baselineShift is in document points and hScale includes the object transform; groups include their text descendants; with a range: the character attributes style that range and the paragraph attributes apply to the paragraphs it touches; without: all the text)", has_doc, set_format ),@@ -344,6 +344,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { "list", "bullet", "number",+ "listAlign", "hyphenate", "wordsLongerThan", "afterFirst",@@ -454,6 +455,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { &["none", "arabic", "upperLetters", "lowerLetters", "upperRoman", "lowerRoman", "twoDigits", "threeDigits"], )? .map(|n| NUMBERS.iter().find(|(k, _)| *k == n).map(|&(_, style)| style));+ // …and `listAlign` where the marker stands before the text, in every list paragraph.+ let list_align = super::object::choice(p, C, "listAlign", &["left", "right"])?+ .map(|a| if a == "right" { vectorcraft_doc::text::ListAlign::Right } else { vectorcraft_doc::text::ListAlign::Left }); let indents_given = num("leftIndent").is_some() || num("firstLineIndent").is_some(); let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?);@@ -556,9 +560,11 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { let had = para.list; // A numbered list for list "number", or without `list` for a number style. let numbered = list == Some("number") || (list.is_none() && number.is_some());+ // The list's other settings (the marker's alignment) stay as they were. para.list = on.then(|| vectorcraft_doc::text::ParaList { bullet: bullet.flatten().or(had.map(|l| l.bullet)).unwrap_or_default(), number: numbered.then(|| number.flatten().or(had.and_then(|l| l.number)).unwrap_or_default()),+ ..had.unwrap_or_default() }); // A paragraph that joins a list with no left or first-line indent gets a // hanging indent, unless the call sets an indent.@@ -567,6 +573,9 @@ fn set_format(s: &mut Session, p: &Value) -> Result<Value> { para.first_line_indent = -vectorcraft_doc::text::LIST_HANGING_INDENT; } }+ if let (Some(align), Some(l)) = (list_align, para.list.as_mut()) {+ l.align = align;+ } if let Some(v) = flag("hyphenate") { para.hyphenate = v; }@@ -1621,7 +1621,7 @@ mod area_tests { 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) });+ let letters = Some(ParaList { bullet: Bullet::Square, number: Some(NumberStyle::LowerLetters), ..ParaList::default() }); 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));@@ -1657,6 +1657,74 @@ mod area_tests { assert_eq!((list(&s, 0), list(&s, 1), list(&s, 2)), (None, None, None)); } + /// Bullets and Numbering… › Alignment: `text.setFormat {listAlign}` sets where the marker+ /// stands in the list paragraphs it touches, in one undo step, and refuses unknown values;+ /// a paragraph in no list gets no list from it. A list keeps its alignment when it changes+ /// between bullets and numbers, and a call that puts a paragraph in a list gives it the+ /// alignment too. The alignment is saved only when it is right, opens with the file, and+ /// paragraph styles carry it both ways.+ #[test]+ fn list_markers_are_aligned_per_paragraph_saved_and_styled() {+ use vectorcraft_doc::text::{Bullet, ListAlign, 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 align = |s: &Session| (0..3).map(|i| list(s, i).map(|l| l.align)).collect::<Vec<_>>();+ let (left, right) = (Some(ListAlign::Left), Some(ListAlign::Right));+ s.execute("select.set", &json!({"ids": [id]})).unwrap();+ s.execute("text.setFormat", &json!({"list": "number", "start": 0, "end": 4})).unwrap();+ assert_eq!(align(&s), [left, left, None]);+ let undo = s.doc().unwrap().history.undo.len();+ s.execute("text.setFormat", &json!({"listAlign": "right", "start": 0, "end": 0})).unwrap();+ assert_eq!(align(&s), [right, left, None]);+ assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1, "one undo step");+ s.execute("edit.undo", &json!({})).unwrap();+ assert_eq!(align(&s), [left, left, None]);+ s.execute("text.setFormat", &json!({"listAlign": "right"})).unwrap();+ assert_eq!(align(&s), [right, right, None], "the paragraph in no list stays out of one");+ for bad in [json!({"listAlign": "center"}), json!({"listAlign": "Right"}), json!({"listAlign": 1}), json!({"listAlign": true})] {+ assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}");+ }+ assert_eq!(align(&s), [right, right, None], "an unknown value changes nothing");+ // Bullets and numbers keep it; a call that makes a list sets it there too.+ s.execute("text.setFormat", &json!({"bullet": "square", "start": 0, "end": 0})).unwrap();+ assert_eq!(list(&s, 0), Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right)));+ s.execute("text.setFormat", &json!({"list": "bullet", "listAlign": "right", "start": 8, "end": 8})).unwrap();+ assert_eq!(list(&s, 2), Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right)));+ s.execute("text.setFormat", &json!({"listAlign": "left", "start": 8, "end": 8})).unwrap();+ assert_eq!(serde_json::to_value(text(&s).para_at(0)).unwrap()["list"], json!({"bullet": "square", "align": "right"}));+ assert_eq!(serde_json::to_value(text(&s).para_at(2)).unwrap()["list"], json!({"bullet": "dot"}));+ 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),+ (list(&s, 0), Some(ParaList::numbered(NumberStyle::Arabic).aligned(ListAlign::Right)))+ );+ // A list turned off and on again starts aligned left.+ s.execute("text.setFormat", &json!({"list": "none", "start": 4, "end": 4})).unwrap();+ s.execute("text.setFormat", &json!({"list": "number", "start": 4, "end": 4})).unwrap();+ assert_eq!(align(&s)[1], left);+ // A style captured from a list aligned right gives other lists that alignment, and one+ // captured from a list aligned left takes it back.+ let other = s.execute("text.create", &json!({"x": 10, "y": 300, "text": "Other"})).unwrap()["id"].as_u64().unwrap();+ s.execute("text.setFormat", &json!({"ids": [other], "list": "number"})).unwrap();+ s.execute("paraStyle.new", &json!({"name": "Aligned left", "id": other})).unwrap();+ s.execute("paraStyle.new", &json!({"name": "Aligned right", "id": id})).unwrap();+ s.execute("paraStyle.apply", &json!({"name": "Aligned right", "id": other})).unwrap();+ let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(other)).map(|n| &n.kind) else { panic!("text") };+ assert_eq!(t.para_at(0).list.map(|l| l.align), right);+ s.execute("paraStyle.apply", &json!({"name": "Aligned left", "id": id})).unwrap();+ assert_eq!(align(&s), [left, left, left]);+ }+ /// The glyphs the layout of type `id` draws, each with its glyph, where it is drawn and the /// transform it is drawn with (in thousandths), from the top line down and left to right. fn drawn_glyphs(s: &Session, id: u64) -> Vec<(u32, i64, i64, [i64; 6])> {@@ -1835,6 +1903,57 @@ mod area_tests { assert_eq!(s.doc().unwrap().history.undo.len(), steps); } + /// Convert to Text of a list whose markers are aligned right writes the same characters as for+ /// a list aligned left, the marker and then a tab, and gives the paragraphs the same tab stops:+ /// the text after the tab stays where it was, and the marker moves to the first line's start,+ /// where a list aligned left draws it. Numbers narrower than the hanging indent (their tab went+ /// to the left indent) and wider (their tab went to the next default stop), and bullets.+ #[test]+ fn convert_to_text_of_markers_aligned_right_keeps_the_text_in_place() {+ let mut s = Session::new();+ s.execute("file.new", &json!({"width": 800, "height": 800})).unwrap();+ let text_glyphs = |s: &Session, id: u64| {+ let Some(NodeKind::Text(t)) = s.doc().unwrap().doc.node(NodeId(id)).map(|n| &n.kind) else { panic!("text") };+ let lay = vectorcraft_text::layout(vectorcraft_text::FontDb::global(), t);+ let milli = |v: f64| (v * 1000.0).round() as i64;+ lay.glyphs+ .iter()+ .filter(|g| g.len > 0 && !g.outline.elements().is_empty())+ .map(|g| (g.gid, milli(g.origin.x), milli(g.origin.y)))+ .collect::<Vec<_>>()+ };+ let plain = |s: &Session, id: u64| match &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind {+ NodeKind::Text(t) => t.plain_text(),+ _ => panic!("text"),+ };+ let items = (1..=10).map(|i| format!("Item {i}")).collect::<Vec<_>>().join("\n");+ for (list, converted) in [+ ("number", (1..=10).map(|i| format!("{i}.\tItem {i}")).collect::<Vec<_>>().join("\n")),+ ("bullet", (1..=10).map(|i| format!("\u{2022}\tItem {i}")).collect::<Vec<_>>().join("\n")),+ ] {+ let make = |s: &mut Session, align: &str| {+ let id = s+ .execute("text.create", &json!({"x": 300, "y": 10, "size": 20, "text": items, "area": {"width": 300, "height": 600}}))+ .unwrap()["id"]+ .as_u64()+ .unwrap();+ s.execute("text.setFormat", &json!({"ids": [id], "list": list, "listAlign": align})).unwrap();+ id+ };+ let (left, right) = (make(&mut s, "left"), make(&mut s, "right"));+ let drawn_left = drawn_glyphs(&s, left);+ let text_before = text_glyphs(&s, right);+ assert_eq!(text_before, text_glyphs(&s, left), "{list}: the text starts at the same place with either alignment");+ assert_ne!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers aligned right are drawn elsewhere");+ s.execute("text.convertListToText", &json!({"ids": [left, right]})).unwrap();+ assert_eq!((plain(&s, left), plain(&s, right)), (converted.clone(), converted), "{list}: the marker, then a tab");+ let after = text_glyphs(&s, right);+ assert!(text_before.iter().all(|g| after.contains(g)), "{list}: the text stays where it was");+ assert_eq!(drawn_glyphs(&s, right), drawn_left, "{list}: the markers start where a list aligned left draws them");+ assert_eq!(drawn_glyphs(&s, left), drawn_left, "{list}: aligned left, every glyph stays");+ }+ }+ /// A right-to-left list paragraph converted to text starts with the number, its period and a /// tab, which are read first: the line draws its glyphs in the order it drew them with the /// marker, the number at the right with its period at its left. Letters and roman numerals@@ -400,6 +400,17 @@ fn numbers_are_written_as_their_characters_where_they_are_drawn() { assert!(at(">I.\t<") < at("alpha") && at("alpha") < at(">II.\t<") && at(">II.\t<") < at("zeta") && at("zeta") < at(">III.\t<"), "{svg}"); assert!(!svg.contains(">-<"), "{svg}"); assert_drawn_in_place(&d);+ // Aligned right: the gap comes before each number, which a viewer draws where the canvas does,+ // against the text.+ let mut t = TextObject::point(Point::new(20.0, 20.0), "alpha beta gamma delta epsilon\nzeta\neta", style(12.0));+ t.kind = TextKind::Area { frame: shapes::rectangle(Rect::new(0.0, 0.0, 120.0, 160.0)) };+ t.para.list = Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(vectorcraft_doc::text::ListAlign::Right));+ t.para.left_indent = 18.0;+ t.para.first_line_indent = -18.0;+ let d = doc(t);+ let svg = export(&d, &ExportOptions::default());+ assert!(svg.contains(">III.<") && !svg.contains(">III.\t<") && !svg.contains(">-<"), "{svg}");+ assert_drawn_in_place(&d); let mut line = BezPath::new(); line.move_to((0.0, 50.0)); line.line_to((250.0, 50.0));@@ -4,7 +4,7 @@ use std::ops::Range; use kurbo::{Affine, BezPath, ParamCurve, PathEl, Point, Rect, Shape, Vec2}; use unicode_bidi::{BidiInfo, Level};-use vectorcraft_doc::text::Kashida;+use vectorcraft_doc::text::{Kashida, ListAlign}; use vectorcraft_doc::{ Burasagari, CharStyle, HyphenationOptions, InlineArt, Justify, Kinsoku, LeadingModel, Mojikumi, ParaDirection, ParaStyle, PathAlign, PathEffect, RubyAlign, RubyPosition, SingleWordJustify, TextKind, TextObject,@@ -121,11 +121,14 @@ impl Ctx<'_> { /// marker). The marker's characters are set in /// [`vectorcraft_doc::text::ParaList::marker_style`], and a tab after them takes the text to /// the next tab stop past them on the first line, the left indent counting as a stop when no- /// explicit stop comes before it. In a horizontal right-to-left paragraph, whose indents are- /// mirrored ([`line_indents`]), these are measured from the right, and a number reads left to- /// right with its period at its left. The tab keeps that width however the line is aligned or- /// justified. Each of these glyphs stands for no character of the text: length 0, at the- /// paragraph's first byte, in its first character's run, at the paragraph's embedding level.+ /// explicit stop comes before it. With the list's [`ListAlign::Right`] that tab goes before+ /// them instead, with the same width: the marker ends where the text starts, and the text+ /// starts where it does with [`ListAlign::Left`]. In a horizontal right-to-left paragraph,+ /// whose indents are mirrored ([`line_indents`]), these are measured from the right, and a+ /// number reads left to right with its period at its left. The tab keeps that width however+ /// the line is aligned or justified. Each of these glyphs stands for no character of the+ /// text: length 0, at the paragraph's first byte, in its first character's run, at the+ /// paragraph's embedding level. fn prepend_marker(&self, pr: &Range<usize>, para: &ParaStyle, text: Option<&str>, rtl: bool, sg: &mut Vec<SGlyph>) -> usize { let Some(text) = text.filter(|_| para.list.is_some() && !pr.is_empty()) else { return 0 }; let st = vectorcraft_doc::text::ParaList::marker_style(self.style_at(pr.start));@@ -158,7 +161,11 @@ impl Ctx<'_> { let to_indent = para.left_indent - pen; let stop_before_indent = para.tabs.iter().any(|t| t.position > pen + EPS && t.position < para.left_indent - EPS); tab.adv = if to_indent > EPS && !stop_before_indent { to_indent } else { tab_advance(¶.tabs, 0.0, pen, sg).0 };- marker.push(tab);+ if para.list.is_some_and(|l| l.align == ListAlign::Right) {+ marker.insert(0, tab);+ } else {+ marker.push(tab);+ } let n = marker.len(); sg.splice(0..0, marker); n@@ -1396,7 +1403,7 @@ fn tab_advance<'t>(tabs: &'t [vectorcraft_doc::TabStop], origin: f64, x: f64, re /// The width of glyphs `from..to` of `sg` set on a line whose content starts `x_start` from the /// tab origin: tabs of the text take what [`tab_advance`] gives them, not the width of their-/// glyph. The tab after a list marker (length 0) keeps its own width.+/// glyph. The tab of a list marker (length 0) keeps its own width. fn set_width(sg: &[SGlyph], from: usize, to: usize, tabs: &[vectorcraft_doc::TabStop], x_start: f64) -> f64 { let mut x = x_start; for (j, g) in sg.iter().enumerate().take(to).skip(from) {@@ -1514,7 +1521,8 @@ fn break_line( } } }- let cluster_width: f64 = if gl.ch == '\t' {+ // A list marker's glyphs, its tab included, keep their own widths.+ let cluster_width: f64 = if gl.ch == '\t' && gl.len > 0 { tab_advance(tabs, 0.0, x_start + x, g.get(cluster_end..).unwrap_or_default()).0 } else { g[j..cluster_end].iter().map(|glyph| glyph.adv).sum()@@ -1546,14 +1554,14 @@ fn break_line( if j >= g.len() { return (g.len(), false); }- // Hyphenate the word that overflows.- let word_start = last_break.map_or(i, |b| b.0);+ // Hyphenate the word that overflows. A list marker starting the line is not part of a word.+ let word_start = last_break.map_or(first, |b| b.0); let in_zone = |zone: f64| { let mut te = word_start;- while te > i && g[te - 1].is_space() {+ while te > first && g[te - 1].is_space() { te -= 1; }- te > i && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS+ te > first && zone > 0.0 && width - g[i..te].iter().map(|g| g.adv).sum::<f64>() <= zone + EPS }; if let Some((opts, zone)) = hyphenate && g[j].is_letter()@@ -1919,7 +1927,9 @@ fn candidates(text: &str, g: &[SGlyph], hyphenate: Option<&HyphenationOptions>, let mut v = vec![]; let mut ws = 0; for (j, gl) in g.iter().enumerate() {- let end_of_word = gl.is_space() || gl.break_after();+ // A list marker (the glyphs of length 0 here) ends before the first word.+ let marker_end = gl.len == 0 && g.get(j + 1).is_some_and(|n| n.len > 0);+ let end_of_word = gl.is_space() || gl.break_after() || marker_end; if end_of_word { if let Some(opts) = hyphenate && j > ws@@ -1974,7 +1984,7 @@ fn compose_para( let may_hang = para.burasagari != Burasagari::None && sg.iter().any(hangs); // A tab's width depends on where it falls in its line, which the composer's fixed glyph widths // can't follow: such a paragraph is broken line by line ([`break_line`] measures its tabs).- // The tab after a list marker always starts the first line and has a fixed width.+ // The tab of a list marker is on the first line, with the marker, and has a fixed width. let has_tab = sg.iter().any(|g| g.ch == '\t' && g.len > 0); if composer != Composer::EveryLine || pen.regions.is_none() || sg.len() < 2 || sg.len() > MAX_COMPOSE_GLYPHS || may_hang || has_tab { return None;@@ -2422,7 +2432,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio let mut scale = 1.0; let mut leader = ""; if g.ch == '\t' {- // The tab after a list marker keeps the width `prepend_marker` gave it.+ // The tab of a list marker keeps the width `prepend_marker` gave it. if g.len > 0 { (adv, leader) = tab_advance(¶.tabs, tab_origin, x - tab_shift, &sg[j + 1..trimmed.max(j + 1)]); }@@ -2460,7 +2470,7 @@ fn flow(cx: &mut Ctx<'_>, paras: &[Range<usize>], t: &TextObject, regions: Optio if j < marker { let at = cx.out.glyphs.len(); marker_glyphs = Some(marker_glyphs.map_or(at..at + 1, |r: Range<usize>| r.start.min(at)..r.end.max(at + 1)));- if j + 1 == marker {+ if g.ch == '\t' { marker_tab = at; } }@@ -243,7 +243,8 @@ pub struct ListMarker { /// takes the text to the next tab stop. pub glyphs: std::ops::Range<usize>, /// The tab's glyph, which draws nothing: the last of [`Self::glyphs`], or the first in a- /// right-to-left paragraph.+ /// right-to-left paragraph. A marker aligned right+ /// ([`vectorcraft_doc::text::ListAlign::Right`]) has it the other way round. pub tab: usize, /// The marker's characters ("•"). pub text: String,@@ -1067,6 +1067,114 @@ fn a_right_to_left_numbered_paragraph_has_its_period_left_of_its_number() { assert!((last.origin.x + last.advance - 200.0).abs() < 1e-6, "the number ends the line at the right: {last:?}"); } +/// Bullets and Numbering › Alignment: Left puts a list paragraph's marker at the first line's+/// start, and Right puts it against the text, which starts at the same place with either. Numbers+/// of different widths aligned right end at the same place, the caret before the first character+/// stands at the text, and the lines the paragraph wraps to start at the left indent. A number+/// wider than the room before the left indent takes the text to the next tab stop and ends there.+/// In a right-to-left paragraph, a marker aligned right starts where the text ends.+#[test]+fn a_list_marker_aligned_right_ends_where_the_text_starts() {+ use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};+ // Each marker's characters' left and right edges, and where its line's text starts.+ let spans = |t: &TextObject| -> Vec<(f64, f64, f64)> {+ let lay = layout(db(), t);+ let mut out = vec![];+ for m in &lay.markers {+ let chars: Vec<usize> = m.glyphs.clone().filter(|&i| i != m.tab).collect();+ let left = chars.iter().map(|&i| lay.glyphs[i].origin.x).fold(f64::MAX, f64::min);+ let right = chars.iter().map(|&i| lay.glyphs[i].origin.x + lay.glyphs[i].advance).fold(f64::MIN, f64::max);+ let line = &lay.lines[lay.glyphs[m.tab].line];+ let text = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();+ out.push((left, right, text.origin.x));+ }+ out+ };+ let numbered = |align: ListAlign, count: u32| {+ let mut t = listed("One\nTwo", &[Some(ParaList::numbered(NumberStyle::Arabic).aligned(align)); 2]);+ t.list_count = count;+ t+ };+ // "9." and "10.": aligned left, both start at the first-line indent (0); aligned right, both end+ // at the left indent (18), where the text starts either way.+ let left = spans(&numbered(ListAlign::Left, 8));+ let right = spans(&numbered(ListAlign::Right, 8));+ assert_eq!((left.len(), right.len()), (2, 2));+ for (k, ((l0, r0, text0), (l, r, text))) in left.iter().zip(&right).enumerate() {+ assert!(l0.abs() < 1e-6 && (text0 - 18.0).abs() < 1e-6, "item {k} aligned left: {l0} {text0}");+ assert!((r - 18.0).abs() < 1e-6 && (text - text0).abs() < 1e-6, "item {k} aligned right ends at the text: {r} {text}");+ assert!(((r - l) - (r0 - l0)).abs() < 1e-6, "item {k} keeps its width");+ }+ assert!(right[1].0 < right[0].0, "the wider number starts further left: {right:?}");+ let t = numbered(ListAlign::Right, 0);+ let lay = layout(db(), &t);+ let m = &lay.markers[0];+ assert_eq!((m.tab, lay.marker_text(m.tab), lay.marker_text(m.glyphs.start + 1)), (m.glyphs.start, Some("\t"), Some("1.")));+ let (top, _) = caret_position(&lay, 0);+ assert!((top.x - 18.0).abs() < 1e-6, "the caret before the first character: {top:?}");+ // A wrapped paragraph's other lines start at the left indent.+ let long = listed(LOREM, &[Some(ParaList::bulleted(Bullet::Dot).aligned(ListAlign::Right))]);+ let lay = layout(db(), &long);+ assert!(lay.lines.len() > 2, "the paragraph wraps");+ for (i, line) in lay.lines.iter().enumerate() {+ let first = lay.glyphs[line.glyph_start..line.glyph_end].iter().find(|g| g.len > 0).unwrap();+ assert!((first.origin.x - 18.0).abs() < 1e-6, "line {i} starts at {}", first.origin.x);+ }+ let bullet = &spans(&long)[0];+ assert!((bullet.1 - 18.0).abs() < 1e-6 && bullet.0 > 0.0, "{bullet:?}");+ // "1000." is wider than the 18 pt before the left indent: the text and the number's end go to+ // the next default stop.+ let mut wide = listed("One", &[Some(ParaList::numbered(NumberStyle::ThreeDigits).aligned(ListAlign::Right))]);+ wide.list_count = 999;+ let (_, end, text) = spans(&wide)[0];+ assert!((text - 36.0).abs() < 1e-6 && (end - 36.0).abs() < 1e-6, "{end} {text}");+ // Right to left: aligned left the bullet ends the line at the right; aligned right it starts+ // where the text ends, and the text ends where it does aligned left.+ let rtl = |align: ListAlign| {+ let mut t = listed("שלום עולם", &[Some(ParaList::bulleted(Bullet::Dot).aligned(align))]);+ t.edit_paras(None, |p| p.justify = Justify::Auto);+ let lay = layout(db(), &t);+ let m = &lay.markers[0];+ let bullet = m.glyphs.clone().find(|&i| i != m.tab).map(|i| (lay.glyphs[i].origin.x, lay.glyphs[i].origin.x + lay.glyphs[i].advance));+ let text_right = lay.glyphs.iter().filter(|g| g.len > 0).map(|g| g.origin.x + g.advance).fold(0.0, f64::max);+ (bullet.unwrap(), text_right)+ };+ let ((_, end), text_left_aligned) = rtl(ListAlign::Left);+ assert!((end - 200.0).abs() < 1e-6, "{end}");+ let ((start, _), text_right_aligned) = rtl(ListAlign::Right);+ assert!((start - text_right_aligned).abs() < 1e-6 && (text_right_aligned - text_left_aligned).abs() < 1e-6, "{start} {text_right_aligned}");+}++/// A list paragraph breaks its lines where the same paragraph without a marker, its text at the+/// same indent, breaks them: with either marker alignment, a bullet or a letter, and either+/// composer, the marker and its gap count at their own width and the first word hyphenates.+#[test]+fn a_list_paragraph_breaks_its_lines_as_its_text_alone_does() {+ use vectorcraft_doc::text::{Bullet, ListAlign, NumberStyle, ParaList};+ let text = "Extraordinarily comprehensive typographical considerations notwithstanding everything.";+ // Each line's end, and whether it ends with a hyphen.+ let breaks = |list: Option<ParaList>, composer: vectorcraft_doc::Composer| {+ let mut t = area(text, style(14.0), Rect::new(0.0, 0.0, 98.0, 600.0), Justify::Left);+ t.para.left_indent = 18.0;+ t.para.first_line_indent = if list.is_some() { -18.0 } else { 0.0 };+ t.para.hyphenate = true;+ t.para.composer = composer;+ t.para.list = list;+ let lay = layout(db(), &t);+ let hyphen = |k: usize| lay.glyphs[k].len == 0 && lay.marker_of(k).is_none();+ lay.lines.iter().map(|l| (l.end, (l.glyph_start..l.glyph_end).any(hyphen))).collect::<Vec<_>>()+ };+ for composer in [vectorcraft_doc::Composer::SingleLine, vectorcraft_doc::Composer::EveryLine] {+ let alone = breaks(None, composer);+ assert!(alone.first().is_some_and(|&(_, hyphen)| hyphen), "the first word hyphenates: {alone:?}");+ for list in [ParaList::bulleted(Bullet::Dot), ParaList::numbered(NumberStyle::UpperLetters)] {+ for align in [ListAlign::Left, ListAlign::Right] {+ assert_eq!(breaks(Some(list.aligned(align)), composer), alone, "{composer:?} {list:?} {align:?}");+ }+ }+ }+}+ /// Are the Japanese craft-fonts faces built in? Tests of Japanese glyphs skip (and say so) when /// they aren't: the bundled fonts have none, and [`db`] reads no system fonts. fn japanese_fonts() -> bool {new file mode 100644@@ -0,0 +1,620 @@+//! Type › Bullets and Numbering › Bullets and Numbering…, and More Options at the end of the+//! Paragraph panel's Bullets and Numbered List presets (or `ui.bulletsAndNumbering {tab?}`): a+//! Bullets tab and a Numbers tab with the presets, and the Position group (Alignment, Left Indent+//! and First Line Indent) of the paragraphs the Paragraph panel acts on. The dialog opens on the+//! values the Paragraph panel shows, with no preset picked and a field blank where the paragraphs+//! differ. Each change shows on the type while the dialog is open. OK runs `text.setFormat` with+//! the values that changed, and with the indents it shows when a preset puts the paragraphs in a+//! list, as one undo step; Cancel puts the type back as it was. A preset picked for paragraphs+//! that are in no list and have no indents fills in the 18 pt and -18 pt indents `text.setFormat`+//! gives them, and None takes them out again.+//!+//! Fields: `tab` (`bullets` or `numbers`), `list` (`none`, `bullet` or `number`: the kind of the+//! preset picked), `bullet` (`dot`, `dash`, `circle` or `square`), `number` (`arabic`,+//! `upperLetters`, `lowerLetters`, `upperRoman`, `lowerRoman`, `twoDigits` or `threeDigits`),+//! `listAlign` (`left` or `right`), `leftIndent` and `firstLineIndent` (pt), as `text.setFormat`+//! takes them, with `__initial` (the values it opened with) and `__targets` (the type and range it+//! acts on).+//!+//! Behavior reference (public documentation):+//! <https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html>.++use serde_json::{Map, Value, json};+use vectorcraft_doc::text::{LIST_HANGING_INDENT, NumberStyle, ParaList};++use super::swatch_options::label;+use super::{DialogSpec, form};+use crate::state::Dialog;+use crate::theme::Tokens;+use crate::{VectorcraftApp, widgets};++/// The dialog kind of Bullets and Numbering.+pub const KIND: &str = "bulletsAndNumbering";++/// The field holding the values the dialog opened with.+const INITIAL: &str = "__initial";+/// The field holding the `ids`, `start` and `end` the dialog's `text.setFormat` acts on.+const TARGETS: &str = "__targets";+/// Set while the indent fields hold the hanging indent a preset filled in ([`pick`]).+const HANGING: &str = "__hanging";+const CMD: &str = "text.setFormat";++/// The `tab` values, in the order the tabs are shown.+const TABS: [&str; 2] = ["bullets", "numbers"];+/// The `listAlign` values, in the order Alignment lists them.+const ALIGNS: [&str; 2] = ["left", "right"];+/// The side of a preset button (points).+const PRESET: f32 = 34.0;+/// The keys a preset button with the keyboard focus keeps from egui's focus moves.+const ARROWS: egui::EventFilter = egui::EventFilter { tab: false, horizontal_arrows: true, vertical_arrows: false, escape: false };++/// The fields `ui.dialog.set` can set while the dialog does not hold them: the dialog leaves a+/// field out where the paragraphs differ, and holds no `bullet` or `number` for paragraphs in no+/// list or in another kind of list.+const INPUTS: [&str; 6] = ["list", "bullet", "number", "listAlign", "leftIndent", "firstLineIndent"];++pub(super) const SPEC: DialogSpec = DialogSpec {+ heading: |_| tl!("Bullets and Numbering").into(),+ body,+ confirm,+ preview: true,+ min_width: 380.0,+ inputs: |_| &INPUTS,+ ..DialogSpec::FORM+};++/// The kind of list `list` is, as the `list` field names it.+fn kind(list: Option<ParaList>) -> &'static str {+ match list {+ None => "none",+ Some(l) if l.number.is_some() => "number",+ Some(_) => "bullet",+ }+}++/// Open Bullets and Numbering on the paragraph attributes the Paragraph panel shows, on tab `tab`+/// (`bullets` or `numbers`; without it, Numbers when the paragraphs are a numbered list).+pub fn open(app: &mut VectorcraftApp, p: &Value) -> Result<Value, String> {+ use crate::panels::paragraph::{shared_para, shared_para_field};+ let key = "tab";+ let tab = match p.get(key) {+ None | Some(Value::Null) => None,+ Some(v) => Some(v.as_str().filter(|t| TABS.contains(t)).ok_or_else(|| format!("`{key}` must be one of {}", TABS.join(", ")))?),+ };+ crate::panels::character::text_style(app).ok_or("select some type first")?;+ // While typing, the dialog acts on the paragraphs of the text being edited.+ let mut targets = json!({});+ if let Some((id, a, b)) = crate::panels::character::text_editing(app) {+ crate::panels::character::end_typing(app);+ targets = json!({"ids": [id.0], "start": a, "end": b});+ }+ let mut values = Map::new();+ let list = shared_para(app, |p| kind(p.list));+ if let Some(k) = list {+ values.insert("list".into(), json!(k));+ }+ if let Some(Some(b)) = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)) {+ values.insert("bullet".into(), json!(b));+ }+ if let Some(Some(n)) = shared_para(app, |p| p.list.and_then(|l| l.number)) {+ values.insert("number".into(), json!(n));+ }+ // A paragraph in no list counts as aligned left, the alignment a list starts with.+ if let Some(a) = shared_para(app, |p| p.list.map(|l| l.align).unwrap_or_default()) {+ values.insert("listAlign".into(), json!(a));+ }+ if let Some(v) = shared_para_field(app, |p| p.left_indent) {+ values.insert("leftIndent".into(), json!(v));+ }+ if let Some(v) = shared_para_field(app, |p| p.first_line_indent) {+ values.insert("firstLineIndent".into(), json!(v));+ }+ let tab = tab.unwrap_or(if list == Some("number") { "numbers" } else { "bullets" });+ let mut fields = Value::Object(values.clone());+ fields[INITIAL] = Value::Object(values);+ fields[TARGETS] = targets;+ fields["tab"] = json!(tab);+ app.ui.dialog = Some(Dialog::new(KIND, fields));+ Ok(Value::Null)+}++fn body(app: &mut VectorcraftApp, ui: &mut egui::Ui, d: &mut Dialog) -> bool {+ let unit = app.session.general_unit();+ let numbers = d.str("tab") == "numbers";+ if let Some(tab) = widgets::tab_bar(ui, &[tl!("Bullets"), tl!("Numbers")], usize::from(numbers)).and_then(|i| TABS.get(i)) {+ d.fields.insert("tab".into(), json!(tab));+ }+ ui.add_space(8.0);+ if d.str("tab") == "numbers" {+ number_tab(ui, d);+ } else {+ bullet_tab(ui, d);+ }+ ui.add_space(10.0);+ position(ui, d, unit);+ // The type shows the dialog's values; with nothing changed it is as it was.+ let p = params(d);+ if changes(&p) {+ form::preview_now(app, d, tl!("Bullets and Numbering"), CMD, Value::Object(p));+ } else if d.fields.remove(form::PREVIEWED).is_some() {+ let _ = app.session.cancel_interaction();+ }+ false+}++/// The Bullets tab: the bullet presets in a row, None first, the one the dialog applies selected.+fn bullet_tab(ui: &mut egui::Ui, d: &mut Dialog) {+ let presets = crate::panels::paragraph::bullet_presets();+ let shown = d.fields.get("bullet").and_then(Value::as_str);+ let current = match d.fields.get("list").and_then(Value::as_str) {+ Some("none") => Some(0),+ Some("bullet") => presets.iter().position(|(_, key, _)| shown == Some(*key)),+ _ => None,+ };+ label(ui, tl!("Bullet Character:"));+ let names = presets.map(|(name, _, _)| name);+ let picked = preset_row(ui, "bullets", &names, current, |painter, rect, i, color| {+ crate::panels::paragraph::paint_bullet(painter, rect.center(), presets.get(i).and_then(|p| p.2), color);+ });+ match picked.and_then(|i| presets.get(i)) {+ Some((_, _, None)) => pick(d, "none", None),+ Some((_, key, Some(_))) => pick(d, "bullet", Some(*key)),+ None => {}+ }+}++/// The Numbers tab: None and the number styles in a row, each shown by its first two numbers, the+/// one the dialog applies selected.+fn number_tab(ui: &mut egui::Ui, d: &mut Dialog) {+ let shown = d.fields.get("number").and_then(|v| serde_json::from_value::<NumberStyle>(v.clone()).ok());+ let current = match d.fields.get("list").and_then(Value::as_str) {+ Some("none") => Some(0),+ Some("number") => shown.and_then(|s| NumberStyle::ALL.iter().position(|&n| n == s)).map(|i| i + 1),+ _ => None,+ };+ label(ui, tl!("Number Style:"));+ // Screen readers name each style by its first three numbers, as the Numbered List presets do.+ let samples: Vec<String> = NumberStyle::ALL.iter().map(|&s| crate::panels::paragraph::number_sample(s, 3)).collect();+ let names: Vec<&str> = std::iter::once(tl!("None")).chain(samples.iter().map(String::as_str)).collect();+ let picked = preset_row(ui, "numbers", &names, current, |painter, rect, i, color| match i.checked_sub(1).and_then(|k| NumberStyle::ALL.get(k)) {+ None => crate::panels::paragraph::paint_bullet(painter, rect.center(), None, color),+ Some(&style) => {+ let first = format!("{} {}", style.format(1), style.format(2));+ painter.text(rect.center(), egui::Align2::CENTER_CENTER, first, egui::FontId::proportional(11.0), color);+ }+ });+ match picked {+ Some(0) => pick(d, "none", None),+ Some(i) => {+ if let Some(key) = NumberStyle::ALL.get(i - 1).and_then(|s| serde_json::to_value(s).ok()).and_then(|v| v.as_str().map(str::to_string)) {+ pick(d, "number", Some(&key));+ }+ }+ None => {}+ }+}++/// A row of preset buttons named `names`, `current` selected, each drawn by `paint` (painter,+/// button, index, color). ← and → move the choice along the row from the button with the keyboard+/// focus, and the focus with it. Returns the button clicked or moved to.+fn preset_row(+ ui: &mut egui::Ui,+ salt: &str,+ names: &[&str],+ current: Option<usize>,+ paint: impl Fn(&egui::Painter, egui::Rect, usize, egui::Color32),+) -> Option<usize> {+ let t = Tokens::get(ui.ctx());+ let ids: Vec<egui::Id> = (0..names.len()).map(|i| egui::Id::unique(("bullets-numbering-preset", salt, i))).collect();+ let mut picked = None;+ let mut focused = None;+ ui.horizontal(|ui| {+ ui.spacing_mut().item_spacing.x = 4.0;+ for (i, (&name, &id)) in names.iter().zip(&ids).enumerate() {+ let (rect, _) = ui.allocate_exact_size(egui::vec2(PRESET, PRESET), egui::Sense::hover());+ let resp = ui.interact(rect, id, egui::Sense::click());+ let selected = current == Some(i);+ let fill = if selected {+ t.row_selected+ } else if resp.hovered() {+ t.hover+ } else {+ t.input+ };+ ui.painter().rect_filled(rect, egui::CornerRadius::same(3), fill);+ let (width, border) = if selected || resp.has_focus() { (1.5, t.accent) } else { (1.0, t.input_border) };+ ui.painter().rect_stroke(rect, egui::CornerRadius::same(3), egui::Stroke::new(width, border), egui::StrokeKind::Inside);+ paint(ui.painter(), rect, i, t.text_strong);+ resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), selected, name));+ if resp.has_focus() {+ focused = Some(i);+ // ← and → move the choice, not egui's focus.+ ui.memory_mut(|m| m.set_focus_lock_filter(id, ARROWS));+ }+ if resp.on_hover_text(name).clicked() {+ picked = Some(i);+ }+ }+ });+ if let Some(i) = focused {+ let (back, on) = ui.input_mut(|inp| {+ (inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowLeft), inp.consume_key(egui::Modifiers::NONE, egui::Key::ArrowRight))+ });+ let to = if back {+ i.checked_sub(1)+ } else if on {+ Some(i + 1).filter(|&j| j < names.len())+ } else {+ None+ };+ if back || on {+ // egui moves the focus with an arrow pressed on a button that has just taken the+ // focus, before its focus-lock filter applies: the row moves it instead.+ ui.memory_mut(|m| m.move_focus(egui::FocusDirection::None));+ }+ if let Some(j) = to {+ ui.memory_mut(|m| m.request_focus(ids[j]));+ picked = Some(j);+ }+ }+ picked+}++/// Pick a preset of kind `list` (`none`, `bullet` or `number`) with key `key` (for `bullet` or+/// `number`). For paragraphs that were in no list and had no indents, the indent fields take the+/// hanging indent `text.setFormat` gives a paragraph that joins a list; None puts them back while+/// they still hold it.+fn pick(d: &mut Dialog, list: &str, key: Option<&str>) {+ d.fields.insert("list".into(), json!(list));+ if let Some(key) = key {+ d.fields.insert(list.into(), json!(key));+ }+ let initial = d.fields.get(INITIAL).cloned().unwrap_or_default();+ let was = |k: &str| initial.get(k).and_then(Value::as_f64);+ let now = |d: &Dialog| (d.fields.get("leftIndent").and_then(Value::as_f64), d.fields.get("firstLineIndent").and_then(Value::as_f64));+ let hanging = (Some(LIST_HANGING_INDENT), Some(-LIST_HANGING_INDENT));+ if list != "none" {+ let unlisted = initial.get("list").and_then(Value::as_str) == Some("none");+ if unlisted && (was("leftIndent"), was("firstLineIndent")) == (Some(0.0), Some(0.0)) && now(d) == (Some(0.0), Some(0.0)) {+ d.fields.insert("leftIndent".into(), json!(LIST_HANGING_INDENT));+ d.fields.insert("firstLineIndent".into(), json!(-LIST_HANGING_INDENT));+ d.fields.insert(HANGING.into(), json!(true));+ }+ } else if d.fields.remove(HANGING).is_some() && now(d) == hanging {+ d.fields.insert("leftIndent".into(), json!(0.0));+ d.fields.insert("firstLineIndent".into(), json!(0.0));+ }+}++/// The Position group: Alignment, Left Indent and First Line Indent.+fn position(ui: &mut egui::Ui, d: &mut Dialog, unit: vectorcraft_doc::Unit) {+ widgets::section_header(ui, tl!("Position"));+ egui::Grid::new("bullets-numbering-position").num_columns(2).spacing([10.0, 8.0]).show(ui, |ui| {+ label(ui, tl!("Alignment:"));+ let names = [tl!("Left"), tl!("Right")];+ let current = d.fields.get("listAlign").and_then(Value::as_str).and_then(|a| ALIGNS.iter().position(|x| *x == a));+ // Already translated: shown as they are.+ if let Some(i) = widgets::dropdown_names(ui, ("bullets-numbering", "listAlign"), current.map_or("", |i| names[i]), &names, 100.0)+ && let Some(a) = ALIGNS.get(i)+ {+ d.fields.insert("listAlign".into(), json!(a));+ }+ ui.end_row();+ label(ui, tl!("Left Indent:"));+ form::length(ui, d, "leftIndent", unit, 90.0);+ ui.end_row();+ label(ui, tl!("First Line Indent:"));+ form::length(ui, d, "firstLineIndent", unit, 90.0);+ ui.end_row();+ });+}++/// `text.setFormat` parameters: the fields that differ from the values the dialog opened with,+/// on the dialog's type and range. With a preset that puts the paragraphs in a list, the indents+/// the dialog shows go too, so that the hanging indent `text.setFormat` gives a paragraph joining a+/// list does not replace them.+fn params(d: &Dialog) -> Map<String, Value> {+ let initial = d.fields.get(INITIAL).and_then(Value::as_object);+ let was = |key: &str| initial.and_then(|m| m.get(key)).and_then(Value::as_str);+ let field = |key: &str| d.fields.get(key).and_then(Value::as_str);+ let mut p = d.fields.get(TARGETS).and_then(Value::as_object).cloned().unwrap_or_default();+ match field("list") {+ Some("none") if was("list") != Some("none") => {+ p.insert("list".into(), json!("none"));+ }+ Some(kind @ ("bullet" | "number")) => match field(kind) {+ Some(key) if was("list") != Some(kind) || was(kind) != Some(key) => {+ p.insert(kind.into(), json!(key));+ }+ None if was("list") != Some(kind) => {+ p.insert("list".into(), json!(kind));+ }+ _ => {}+ },+ _ => {}+ }+ if let Some(a) = field("listAlign")+ && was("listAlign") != Some(a)+ {+ p.insert("listAlign".into(), json!(a));+ }+ let listing = ["bullet", "number"].iter().any(|k| p.contains_key(*k)) || p.get("list").is_some_and(|l| l.as_str() != Some("none"));+ for key in ["leftIndent", "firstLineIndent"] {+ if !d.fields.contains_key(key) {+ continue;+ }+ // A length typed with its unit ("6 mm") is sent in points.+ let v = d.f64(key, 0.0);+ if listing || initial.and_then(|m| m.get(key)).and_then(Value::as_f64) != Some(v) {+ p.insert(key.into(), json!(v));+ }+ }+ p+}++/// Does `p` change anything (more than the targets)?+fn changes(p: &Map<String, Value>) -> bool {+ p.keys().any(|k| !matches!(k.as_str(), "ids" | "start" | "end"))+}++fn confirm(app: &mut VectorcraftApp, d: &Dialog) -> Result<Value, String> {+ let p = params(d);+ if !changes(&p) {+ if app.session.in_interaction() {+ let _ = app.session.cancel_interaction();+ }+ app.ui.dialog = None;+ return Ok(Value::Null);+ }+ form::commit_preview(app, CMD, Value::Object(p))+}++#[cfg(test)]+mod tests {+ use super::*;+ use vectorcraft_doc::text::{Bullet, ListAlign};+ use vectorcraft_engine::Session;+ use vectorcraft_engine::doc::{NodeId, NodeKind, ParaStyle};++ /// A selected area type object with two paragraphs.+ fn app() -> (VectorcraftApp, u64) {+ let mut app = VectorcraftApp::new(Session::new(), Default::default());+ app.run("file.new", json!({"width": 400, "height": 400})).unwrap();+ let id = app.run("text.create", json!({"x": 10, "y": 10, "text": "One\nTwo", "area": {"width": 200, "height": 100}})).unwrap()["id"]+ .as_u64()+ .unwrap();+ app.run("select.set", json!({"ids": [id]})).unwrap();+ (app, id)+ }++ fn paras(app: &VectorcraftApp, id: u64) -> (ParaStyle, ParaStyle) {+ match &app.session.active().unwrap().doc.node(NodeId(id)).unwrap().kind {+ NodeKind::Text(t) => (t.para_at(0).clone(), t.para_at(1).clone()),+ _ => panic!("not text"),+ }+ }++ /// The first paragraph's list and indents.+ fn first(app: &VectorcraftApp, id: u64) -> (Option<ParaList>, f64, f64) {+ let p = paras(app, id).0;+ (p.list, p.left_indent, p.first_line_indent)+ }++ fn frame(app: &mut VectorcraftApp) {+ let ctx = egui::Context::default();+ crate::theme::install_fonts(&ctx);+ let mut out = ctx.run_ui(egui::RawInput::default(), |ui| super::super::show(app, ui.ctx()));+ out.textures_delta.clear();+ }++ fn set(app: &mut VectorcraftApp, key: &str, v: Value) {+ app.ui.dialog.as_mut().unwrap().fields.insert(key.into(), v);+ }++ fn pick_preset(app: &mut VectorcraftApp, list: &str, key: Option<&str>) {+ pick(app.ui.dialog.as_mut().unwrap(), list, key);+ }++ /// `ui.bulletsAndNumbering` opens on the selected type's values; a preset, Alignment and the+ /// indents show on the type at once, and OK keeps them as one `text.setFormat`, which one Undo+ /// takes back.+ #[test]+ fn ok_applies_the_marker_alignment_and_indents_as_one_undo_step() {+ let (mut app, id) = app();+ let undo = app.session.active().unwrap().history.undo.len();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ let d = app.ui.dialog.clone().expect("Bullets and Numbering opened");+ assert_eq!((d.kind.as_str(), d.str("tab"), d.str("list"), d.str("listAlign")), (KIND, "bullets".into(), "none".into(), "left".into()));+ assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0));+ pick_preset(&mut app, "bullet", Some("square"));+ set(&mut app, "listAlign", json!("right"));+ set(&mut app, "leftIndent", json!(24));+ set(&mut app, "firstLineIndent", json!(-12));+ frame(&mut app);+ assert!(app.session.in_interaction(), "the preview runs");+ let want = Some(ParaList::bulleted(Bullet::Square).aligned(ListAlign::Right));+ assert_eq!(first(&app, id), (want, 24.0, -12.0), "previewed on the type");+ super::super::confirm(&mut app).unwrap();+ assert!(app.ui.dialog.is_none() && !app.session.in_interaction());+ assert_eq!((first(&app, id), paras(&app, id).1.list), ((want, 24.0, -12.0), want));+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1, "one undo step");+ app.run("edit.undo", json!({})).unwrap();+ assert_eq!(first(&app, id), (None, 0.0, 0.0));+ // The Numbers tab's presets make a numbered list, keeping the alignment the dialog shows.+ app.run("text.setFormat", json!({"list": "bullet", "listAlign": "right"})).unwrap();+ app.run("ui.bulletsAndNumbering", json!({"tab": "numbers"})).unwrap();+ assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers");+ pick_preset(&mut app, "number", Some("upperRoman"));+ super::super::confirm(&mut app).unwrap();+ assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman).aligned(ListAlign::Right)));+ // OK with nothing changed adds no undo step, and an unknown tab is an error.+ let undo = app.session.active().unwrap().history.undo.len();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ assert_eq!(app.ui.dialog.as_ref().unwrap().str("tab"), "numbers", "a numbered list opens on Numbers");+ frame(&mut app);+ super::super::confirm(&mut app).unwrap();+ assert_eq!(app.session.active().unwrap().history.undo.len(), undo);+ assert!(app.run("ui.bulletsAndNumbering", json!({"tab": "levels"})).is_err());+ assert!(app.ui.dialog.is_none());+ app.run("select.set", json!({"ids": []})).unwrap();+ assert!(app.run("ui.bulletsAndNumbering", json!({})).is_err(), "no type selected");+ }++ /// Cancel puts back what the preview changed: the list, its alignment and the indents.+ #[test]+ fn cancel_puts_the_type_back_as_it_was() {+ let (mut app, id) = app();+ app.run("text.setFormat", json!({"number": "arabic"})).unwrap();+ let before = first(&app, id);+ assert_eq!(before, (Some(ParaList::numbered(NumberStyle::Arabic)), 18.0, -18.0));+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ pick_preset(&mut app, "number", Some("lowerLetters"));+ set(&mut app, "listAlign", json!("right"));+ set(&mut app, "leftIndent", json!(30));+ frame(&mut app);+ assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::LowerLetters).aligned(ListAlign::Right)), 30.0, -18.0));+ // Changed back in the dialog, the type is as it was.+ set(&mut app, "listAlign", json!("left"));+ set(&mut app, "leftIndent", json!(18));+ pick_preset(&mut app, "number", Some("arabic"));+ frame(&mut app);+ assert!(!app.session.in_interaction(), "no preview while nothing changes");+ assert_eq!(first(&app, id), before);+ pick_preset(&mut app, "none", None);+ frame(&mut app);+ assert_eq!(first(&app, id).0, None, "None previews the list off");+ super::super::cancel(&mut app);+ assert!(app.ui.dialog.is_none());+ assert_eq!(first(&app, id), before);+ }++ /// A preset picked for paragraphs in no list and without indents fills in the 18 pt and+ /// -18 pt hanging indent, and None takes it out again; indents that are set stay, and so do+ /// indents set back to 0 with the preset.+ #[test]+ fn a_preset_fills_in_the_hanging_indent_a_new_list_gets() {+ let (mut app, id) = app();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ pick_preset(&mut app, "bullet", Some("dot"));+ let d = app.ui.dialog.clone().unwrap();+ assert_eq!((d.f64("leftIndent", 0.0), d.f64("firstLineIndent", 0.0)), (18.0, -18.0));+ pick_preset(&mut app, "number", Some("arabic"));+ pick_preset(&mut app, "none", None);+ let d = app.ui.dialog.clone().unwrap();+ assert_eq!((d.f64("leftIndent", 1.0), d.f64("firstLineIndent", 1.0)), (0.0, 0.0));+ assert!(!changes(¶ms(&d)));+ super::super::cancel(&mut app);+ app.run("text.setFormat", json!({"leftIndent": 10})).unwrap();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ pick_preset(&mut app, "bullet", Some("dash"));+ assert_eq!(app.ui.dialog.as_ref().unwrap().f64("leftIndent", 0.0), 10.0);+ super::super::confirm(&mut app).unwrap();+ assert_eq!(first(&app, id), (Some(ParaList::bulleted(Bullet::Dash)), 10.0, 0.0));+ // Indents set back to 0 with the preset are kept: the hanging indent does not replace them.+ let (mut app, id) = self::app();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ pick_preset(&mut app, "number", Some("arabic"));+ set(&mut app, "leftIndent", json!(0));+ set(&mut app, "firstLineIndent", json!(0));+ frame(&mut app);+ assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0), "previewed as shown");+ super::super::confirm(&mut app).unwrap();+ assert_eq!(first(&app, id), (Some(ParaList::numbered(NumberStyle::Arabic)), 0.0, 0.0));+ }++ /// Paragraphs that differ leave the preset unpicked and the fields blank, and OK sends only+ /// what was changed: each paragraph keeps its own indents and list.+ #[test]+ fn differing_paragraphs_show_blank_fields_and_keep_what_is_left_alone() {+ let (mut app, id) = app();+ app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "start": 0, "end": 0})).unwrap();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ let d = app.ui.dialog.clone().unwrap();+ for key in ["list", "bullet", "leftIndent"] {+ assert!(!d.fields.contains_key(key), "{key} is blank");+ }+ assert_eq!((d.str("listAlign"), d.f64("firstLineIndent", 1.0)), ("left".into(), 0.0));+ set(&mut app, "listAlign", json!("right"));+ super::super::confirm(&mut app).unwrap();+ let (one, two) = paras(&app, id);+ assert_eq!((one.list, one.left_indent), (Some(ParaList::bulleted(Bullet::Circle).aligned(ListAlign::Right)), 30.0));+ assert_eq!((two.list, two.left_indent), (None, 0.0), "the paragraph in no list stays out of one");+ }++ /// `ui.dialog.set` sets the fields the dialog does not hold: a bullet or a number for type in+ /// no list, and the fields left blank where the paragraphs differ.+ #[test]+ fn the_control_channel_sets_fields_the_dialog_does_not_hold() {+ let ctx = egui::Context::default();+ let (mut app, id) = app();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ for key in ["bullet", "number"] {+ assert!(!app.ui.dialog.as_ref().unwrap().fields.contains_key(key), "{key} is not held");+ }+ let fields = json!([{"field": "list", "value": "number"}, {"field": "number", "value": "upperRoman"}]);+ crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields}));+ super::super::confirm(&mut app).unwrap();+ assert_eq!(first(&app, id).0, Some(ParaList::numbered(NumberStyle::UpperRoman)));+ app.run("text.setFormat", json!({"ids": [id], "bullet": "circle", "leftIndent": 30, "firstLineIndent": -6, "start": 0, "end": 0})).unwrap();+ app.run("text.setFormat", json!({"ids": [id], "listAlign": "right", "start": 0, "end": 0})).unwrap();+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ let held = app.ui.dialog.as_ref().unwrap().fields.clone();+ for key in INPUTS {+ assert!(!held.contains_key(key), "{key} is blank");+ }+ let fields = json!([+ {"field": "list", "value": "bullet"},+ {"field": "bullet", "value": "square"},+ {"field": "listAlign", "value": "left"},+ {"field": "leftIndent", "value": 24},+ {"field": "firstLineIndent", "value": -12},+ ]);+ crate::tests_synthetic::control(&mut app, &ctx, "ui.dialog.set", json!({"fields": fields}));+ super::super::confirm(&mut app).unwrap();+ let (one, two) = paras(&app, id);+ let square = Some(ParaList::bulleted(Bullet::Square));+ assert_eq!((one.list, one.left_indent, one.first_line_indent), (square, 24.0, -12.0));+ assert_eq!((two.list, two.left_indent, two.first_line_indent), (square, 24.0, -12.0));+ }++ /// The presets are radio buttons named for screen readers, the one the dialog applies chosen;+ /// a click picks one, and ← and → move the choice from the focused one.+ #[test]+ fn the_presets_are_named_radio_buttons_that_the_arrow_keys_move_along() {+ use egui::accesskit::{Role, Toggled};+ let names = ["None", "Filled Dot", "Dash"];+ let tree = crate::widgets::accessibility_tests::tree(|ui| {+ let _ = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {});+ });+ assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Filled Dot"), (Role::RadioButton, Some(Toggled::True)));+ assert_eq!(crate::widgets::accessibility_tests::role_and_state(&tree, "Dash"), (Role::RadioButton, Some(Toggled::False)));+ let ctx = egui::Context::default();+ crate::theme::install_fonts(&ctx);+ let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0));+ let origin = std::cell::Cell::new(egui::Pos2::ZERO);+ let run = |events: Vec<egui::Event>| {+ let input = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };+ let mut picked = None;+ let mut out = ctx.run_ui(input, |ui| {+ origin.set(ui.cursor().min);+ picked = preset_row(ui, "test", &names, Some(1), |_, _, _, _| {});+ });+ out.textures_delta.clear();+ picked+ };+ let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() };+ assert_eq!(run(vec![]), None);+ assert_eq!(run(vec![key(egui::Key::ArrowRight)]), None, "no preset has the focus");+ ctx.memory_mut(|m| m.request_focus(egui::Id::unique(("bullets-numbering-preset", "test", 1usize))));+ run(vec![]);+ assert_eq!(run(vec![key(egui::Key::ArrowRight)]), Some(2));+ assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(1), "the focus moved to Dash");+ assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), Some(0));+ assert_eq!(run(vec![key(egui::Key::ArrowLeft)]), None, "None is the first");+ // The middle of the third button, two buttons and their 4 pt gaps along.+ let at = origin.get() + egui::vec2(PRESET * 2.5 + 8.0, PRESET * 0.5);+ let click = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };+ run(vec![egui::Event::PointerMoved(at)]);+ assert_eq!(run(vec![click(true), click(false)]), Some(2));+ }+}@@ -13,6 +13,7 @@ pub mod artboard_options; pub mod blend_options; mod brush_art_options; pub mod brush_options;+pub mod bullets_numbering; pub mod color_balance; pub mod color_guide_options; mod color_picker;@@ -310,6 +311,7 @@ registry! { Corners: [corners::KIND] => corners::SPEC, Hyphenation: [hyphenation::KIND] => hyphenation::SPEC, Justification: [justification::KIND] => justification::SPEC,+ BulletsAndNumbering: [bullets_numbering::KIND] => bullets_numbering::SPEC, SavePdf: [save_pdf::KIND] => save_pdf::SPEC, SvgOptions: [svg_options::KIND] => svg_options::SPEC, JavaScriptFiles: [javascript_files::KIND] => javascript_files::SPEC,@@ -343,6 +343,12 @@ font-technology Variable Променливи Apply Bullets Прилагане на булети Numbered List Номериран списък Apply Numbers Прилагане на номериране+ Bullets and Numbering… Булети и номериране…+ Numbers Номера+ Bullet Character: Знак за булет:+ Number Style: Стил на номерата:+ Left Indent: Ляв отстъп:+ First Line Indent: Отстъп на първия ред: Convert to Text Конвертиране в текст Filled Dot Плътна точка Dash Тире@@ -327,6 +327,12 @@ Apply Bullets Použít odrážky Numbered List Číslovaný seznam Apply Numbers Použít číslování+ Bullets and Numbering… Odrážky a číslování…+ Numbers Čísla+ Bullet Character: Znak odrážky:+ Number Style: Styl číslování:+ Left Indent: Odsazení zleva:+ First Line Indent: Odsazení prvního řádku: Convert to Text Převést na text Filled Dot Plná tečka Dash Pomlčka@@ -289,6 +289,12 @@ Apply Bullets Anvend kugletegn Numbered List Nummereret liste Apply Numbers Anvend nummerering+ Bullets and Numbering… Kugletegn og nummerering…+ Numbers Tal+ Bullet Character: Kugletegn:+ Number Style: Talformat:+ Left Indent: Venstre indrykning:+ First Line Indent: Indrykning af første linje: Convert to Text Konvertér til tekst Filled Dot Udfyldt prik Dash Tankestreg@@ -331,6 +331,12 @@ font-technology Variable Variabel Apply Bullets Aufzählungszeichen anwenden Numbered List Nummerierte Liste Apply Numbers Nummerierung anwenden+ Bullets and Numbering… Aufzählungszeichen und Nummerierung…+ Numbers Nummern+ Bullet Character: Aufzählungszeichen:+ Number Style: Nummernformat:+ Left Indent: Einzug links:+ First Line Indent: Einzug erste Zeile: Convert to Text In Text umwandeln Filled Dot Gefüllter Punkt Dash Strich@@ -331,6 +331,12 @@ font-technology Variable Μεταβλητές Apply Bullets Εφαρμογή κουκκίδων Numbered List Αριθμημένη λίστα Apply Numbers Εφαρμογή αρίθμησης+ Bullets and Numbering… Κουκκίδες και αρίθμηση…+ Numbers Αριθμοί+ Bullet Character: Χαρακτήρας κουκκίδας:+ Number Style: Στυλ αρίθμησης:+ Left Indent: Αριστερή εσοχή:+ First Line Indent: Εσοχή πρώτης γραμμής: Convert to Text Μετατροπή σε κείμενο Filled Dot Γεμάτη κουκκίδα Dash Παύλα@@ -356,6 +356,12 @@ font-technology Variable Variables Apply Bullets Aplicar viñetas Numbered List Lista numerada Apply Numbers Aplicar numeración+ Bullets and Numbering… Viñetas y numeración…+ Numbers Números+ Bullet Character: Carácter de viñeta:+ Number Style: Estilo de número:+ Left Indent: Sangría izquierda:+ First Line Indent: Sangría de primera línea: Convert to Text Convertir en texto Filled Dot Punto relleno Dash Guion@@ -324,6 +324,12 @@ Apply Bullets Käytä luettelomerkkejä Numbered List Numeroitu luettelo Apply Numbers Käytä numerointia+ Bullets and Numbering… Luettelomerkit ja numerointi…+ Numbers Numerot+ Bullet Character: Luettelomerkki:+ Number Style: Numerotyyli:+ Left Indent: Vasen sisennys:+ First Line Indent: Ensimmäisen rivin sisennys: Convert to Text Muunna tekstiksi Filled Dot Täytetty piste Dash Ajatusviiva@@ -357,6 +357,12 @@ font-technology Variable Variables Apply Bullets Appliquer les puces Numbered List Liste numérotée Apply Numbers Appliquer la numérotation+ Bullets and Numbering… Puces et numérotation…+ Numbers Numéros+ Bullet Character: Caractère de puce :+ Number Style: Style de numéro :+ Left Indent: Retrait gauche :+ First Line Indent: Retrait de première ligne : Convert to Text Convertir en texte Filled Dot Point plein Dash Tiret@@ -492,6 +492,12 @@ font-technology Variable Változó Apply Bullets Felsorolásjelek alkalmazása Numbered List Számozott lista Apply Numbers Számozás alkalmazása+ Bullets and Numbering… Felsorolásjelek és számozás…+ Numbers Számok+ Bullet Character: Felsorolásjel:+ Number Style: Számozás stílusa:+ Left Indent: Bal behúzás:+ First Line Indent: Első sor behúzása: Convert to Text Átalakítás szöveggé Filled Dot Kitöltött pont Dash Gondolatjel@@ -289,6 +289,12 @@ Apply Bullets Terapkan poin Numbered List Daftar bernomor Apply Numbers Terapkan penomoran+ Bullets and Numbering… Poin dan penomoran…+ Numbers Nomor+ Bullet Character: Karakter poin:+ Number Style: Gaya nomor:+ Left Indent: Indentasi kiri:+ First Line Indent: Indentasi baris pertama: Convert to Text Konversi ke teks Filled Dot Titik penuh Dash Tanda pisah@@ -356,6 +356,12 @@ font-technology Variable Variabili Apply Bullets Applica punti elenco Numbered List Elenco numerato Apply Numbers Applica numerazione+ Bullets and Numbering… Punti elenco e numerazione…+ Numbers Numeri+ Bullet Character: Carattere punto elenco:+ Number Style: Stile numero:+ Left Indent: Rientro sinistro:+ First Line Indent: Rientro prima riga: Convert to Text Converti in testo Filled Dot Punto pieno Dash Trattino@@ -317,6 +317,12 @@ font-technology Variable バリアブル Apply Bullets 箇条書きを適用 Numbered List 番号付きリスト Apply Numbers 番号付けを適用+ Bullets and Numbering… 箇条書きと番号付け…+ Numbers 番号+ Bullet Character: 箇条書き記号:+ Number Style: 番号の形式:+ Left Indent: 左インデント:+ First Line Indent: 1 行目インデント: Convert to Text テキストに変換 Filled Dot 黒丸 Dash ダッシュ@@ -248,6 +248,12 @@ Apply Bullets ბურთულების გამოყენება Numbered List დანომრილი სია Apply Numbers დანომრვის გამოყენება+ Bullets and Numbering… ბურთულები და დანომრვა…+ Numbers ნომრები+ Bullet Character: ბურთულის სიმბოლო:+ Number Style: ნომრის სტილი:+ Left Indent: მარცხენა შეწევა:+ First Line Indent: პირველი სტრიქონის შეწევა: Convert to Text ტექსტად გარდაქმნა Filled Dot სავსე წერტილი Dash ტირე@@ -313,6 +313,12 @@ font-technology Variable 가변 Apply Bullets 글머리 기호 적용 Numbered List 번호 매기기 목록 Apply Numbers 번호 매기기 적용+ Bullets and Numbering… 글머리 기호 및 번호 매기기…+ Numbers 번호+ Bullet Character: 글머리 기호 문자:+ Number Style: 번호 스타일:+ Left Indent: 왼쪽 들여쓰기:+ First Line Indent: 첫 줄 들여쓰기: Convert to Text 텍스트로 변환 Filled Dot 채운 점 Dash 대시@@ -303,6 +303,12 @@ Apply Bullets Opsommingstekens toepassen Numbered List Genummerde lijst Apply Numbers Nummering toepassen+ Bullets and Numbering… Opsommingstekens en nummering…+ Numbers Nummers+ Bullet Character: Opsommingsteken:+ Number Style: Nummerstijl:+ Left Indent: Links inspringen:+ First Line Indent: Eerste regel inspringen: Convert to Text Converteren naar tekst Filled Dot Gevulde stip Dash Streepje@@ -288,6 +288,12 @@ Apply Bullets Zastosuj punktory Numbered List Lista numerowana Apply Numbers Zastosuj numerację+ Bullets and Numbering… Punktory i numeracja…+ Numbers Numery+ Bullet Character: Znak punktora:+ Number Style: Styl numeracji:+ Left Indent: Wcięcie z lewej:+ First Line Indent: Wcięcie pierwszego wiersza: Convert to Text Konwertuj na tekst Filled Dot Pełna kropka Dash Myślnik@@ -326,6 +326,12 @@ Apply Bullets Aplicar Marcadores Numbered List Lista Numerada Apply Numbers Aplicar Numeração+ Bullets and Numbering… Marcadores e Numeração…+ Numbers Números+ Bullet Character: Caractere do Marcador:+ Number Style: Estilo de Número:+ Left Indent: Recuo Esquerdo:+ First Line Indent: Recuo da Primeira Linha: Convert to Text Converter em Texto Filled Dot Ponto Preenchido Dash Travessão@@ -354,6 +354,12 @@ font-technology Variable Вариативные Apply Bullets Применить маркеры Numbered List Нумерованный список Apply Numbers Применить нумерацию+ Bullets and Numbering… Маркеры и нумерация…+ Numbers Номера+ Bullet Character: Символ маркера:+ Number Style: Стиль нумерации:+ Left Indent: Отступ слева:+ First Line Indent: Отступ первой строки: Convert to Text Преобразовать в текст Filled Dot Закрашенная точка Dash Тире@@ -345,6 +345,12 @@ font-technology Variable Variabla Apply Bullets Använd punkter Numbered List Numrerad lista Apply Numbers Använd numrering+ Bullets and Numbering… Punkter och numrering…+ Numbers Nummer+ Bullet Character: Punkttecken:+ Number Style: Nummerformat:+ Left Indent: Vänsterindrag:+ First Line Indent: Indrag för första raden: Convert to Text Konvertera till text Filled Dot Fylld punkt Dash Tankstreck@@ -340,6 +340,12 @@ font-technology Variable Değişken Apply Bullets Madde İşaretlerini Uygula Numbered List Numaralı Liste Apply Numbers Numaralandırmayı Uygula+ Bullets and Numbering… Madde İşaretleri ve Numaralandırma…+ Numbers Numaralar+ Bullet Character: Madde İşareti Karakteri:+ Number Style: Numara Stili:+ Left Indent: Sol Girinti:+ First Line Indent: İlk Satır Girintisi: Convert to Text Metne Dönüştür Filled Dot Dolu Nokta Dash Tire@@ -350,6 +350,12 @@ font-technology Variable Варіативні Apply Bullets Застосувати маркери Numbered List Нумерований список Apply Numbers Застосувати нумерацію+ Bullets and Numbering… Маркери та нумерація…+ Numbers Номери+ Bullet Character: Символ маркера:+ Number Style: Стиль нумерації:+ Left Indent: Відступ зліва:+ First Line Indent: Відступ першого рядка: Convert to Text Перетворити на текст Filled Dot Заповнена крапка Dash Тире@@ -2367,7 +2373,7 @@ diagnostics ui інтерфейс Portrait Книжкова Power Saving (integrated) Енергозбереження (вбудована) Portuguese Португальська- Position Становище+ Position Положення Postcard Листівка PostScript: PostScript: PPI PPI@@ -410,6 +410,12 @@ font-technology Variable Biến thiên Apply Bullets Áp dụng dấu đầu dòng Numbered List Danh sách đánh số Apply Numbers Áp dụng đánh số+ Bullets and Numbering… Dấu đầu dòng và đánh số…+ Numbers Số+ Bullet Character: Ký tự đầu dòng:+ Number Style: Kiểu đánh số:+ Left Indent: Lề trái:+ First Line Indent: Thụt lề dòng đầu: Convert to Text Chuyển thành văn bản Filled Dot Chấm đặc Dash Gạch ngang@@ -305,6 +305,12 @@ Apply Bullets 应用项目符号 Numbered List 编号列表 Apply Numbers 应用编号+ Bullets and Numbering… 项目符号和编号…+ Numbers 编号+ Bullet Character: 项目符号字符:+ Number Style: 编号样式:+ Left Indent: 左缩进:+ First Line Indent: 首行缩进: Convert to Text 转换为文本 Filled Dot 实心圆点 Dash 短划线@@ -318,6 +318,12 @@ font-technology Variable 可變 Apply Bullets 套用項目符號 Numbered List 編號清單 Apply Numbers 套用編號+ Bullets and Numbering… 項目符號與編號…+ Numbers 編號+ Bullet Character: 項目符號字元:+ Number Style: 編號樣式:+ Left Indent: 左縮排:+ First Line Indent: 首行縮排: Convert to Text 轉換為文字 Filled Dot 實心圓點 Dash 短破折號@@ -252,6 +252,12 @@ pub const UI_COMMANDS: &[(&str, &str, &str, &str)] = &[ "", "{number?: \"arabic\"|\"upperLetters\"|\"lowerLetters\"|\"upperRoman\"|\"lowerRoman\"|\"twoDigits\"|\"threeDigits\"|\"none\"} Type › Bullets and Numbering › Apply Numbers: the selected type's paragraphs (or those the Type tool's selection touches) become a numbered list in that number style (text.setFormat {number}); without it, a numbered list keeps its style and other paragraphs are numbered 1, 2, 3 (text.setFormat {list: \"number\"}); \"none\" turns the list off", ),+ (+ "ui.bulletsAndNumbering",+ "Bullets and Numbering…",+ "",+ "{tab?: \"bullets\"|\"numbers\"} open Bullets and Numbering (Type › Bullets and Numbering › Bullets and Numbering…, and More Options in the Paragraph panel's Bullets and Numbered List presets) on the paragraphs the Paragraph panel acts on, filled in with the values it shows, on that tab (without it, Numbers when the paragraphs are a numbered list, else Bullets) (dialog `bulletsAndNumbering`: tab (bullets|numbers), list (none|bullet|number: the kind of the preset picked), bullet (dot|dash|circle|square), number (arabic|upperLetters|lowerLetters|upperRoman|lowerRoman|twoDigits|threeDigits), listAlign (left|right), leftIndent, firstLineIndent (pt)): the type shows the dialog's values while it is open, OK runs text.setFormat with the values that changed (and the indents it shows when a preset puts the paragraphs in a list), as one undo step, and Cancel puts the type back; a preset picked for paragraphs in no list whose indents are 0 fills in 18 and -18 pt; an error with no type selected or an unknown tab",+ ), ( "type.convertListToText", "Convert to Text",@@ -1471,6 +1477,7 @@ pub fn run_ui_command(app: &mut VectorcraftApp, id: &str, p: &Value) -> Option<R } "type.applyBullets" => crate::panels::paragraph::apply_bullets(app, p), "type.applyNumbers" => crate::panels::paragraph::apply_numbers(app, p),+ "ui.bulletsAndNumbering" => crate::dialogs::bullets_numbering::open(app, p), "type.convertListToText" => crate::panels::paragraph::convert_list_to_text(app), id if let Some(b) = crate::panels::align::button(id) => crate::panels::align::run_button(app, b), "view.gradientAnnotator" => flag(&mut app.ui.view.gradient_annotator),@@ -2579,7 +2586,9 @@ pub fn enabled(app: &VectorcraftApp, id: &str) -> bool { "file.save" => app.session.active().is_some_and(|d| d.path.is_none() || d.converted || d.is_dirty()), "type.bold" | "type.italic" => crate::panels::character::text_style(app).is_some(), id if crate::panels::paragraph::ALIGN_COMMANDS.iter().any(|c| c.0 == id) => crate::panels::character::text_style(app).is_some(),- "type.applyBullets" | "type.applyNumbers" | "type.convertListToText" => crate::panels::character::text_style(app).is_some(),+ "type.applyBullets" | "type.applyNumbers" | "ui.bulletsAndNumbering" | "type.convertListToText" => {+ crate::panels::character::text_style(app).is_some()+ } id if let Some(b) = crate::panels::align::button(id) => enabled(app, b.command()), "file.reveal" => app.services.reveal.is_some() && app.session.active().is_some_and(|d| d.path.is_some()), "file.cancelExport" | "file.exportInBackground" => app.background.jobs.iter().any(|j| j.progress.is_some() && !j.cancelled()),@@ -3082,6 +3091,7 @@ pub fn menu_tree_named(english_names: bool) -> Vec<(&'static str, Vec<Item>)> { c("Apply Bullets", "type.applyBullets"), c("Apply Numbers", "type.applyNumbers"), c("Convert to Text", "type.convertListToText"),+ c("Bullets and Numbering…", "ui.bulletsAndNumbering"), ], ), Sep,@@ -1,8 +1,9 @@ //! Paragraph panel: seven alignment buttons, Paragraph Direction (with the Indic options), the-//! Bullets and Numbered List buttons and their presets, indents, space before/after, Hyphenate,-//! Insert Kashidas (with the Indic options), Mojikumi Set and Kinsoku Set (with the East Asian-//! options); the panel menu turns Roman Hanging Punctuation on and off, opens Justification… and-//! Hyphenation… and picks the Single-line or Every-line Composer.+//! Bullets and Numbered List buttons and their presets (More Options opens Bullets and+//! Numbering…), indents, space before/after, Hyphenate, Insert Kashidas (with the Indic options),+//! Mojikumi Set and Kinsoku Set (with the East Asian options); the panel menu turns Roman Hanging+//! Punctuation on and off, opens Justification… and Hyphenation… and picks the Single-line or+//! Every-line Composer. use egui::{Ui, vec2}; use serde_json::{Value, json};@@ -169,7 +170,7 @@ fn visit_paragraphs(app: &VectorcraftApp, mut f: impl FnMut(&ParaStyle)) { } /// A distance shared by every paragraph in scope, or `None` when they differ (blank spin field).-fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option<f64> {+pub(crate) fn shared_para_field(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> f64) -> Option<f64> { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| {@@ -202,7 +203,7 @@ pub(crate) fn shared_single_word_justify(app: &VectorcraftApp) -> Option<SingleW } /// A paragraph attribute shared by every paragraph in scope, or `None` when they differ.-fn shared_para<T: PartialEq + Copy>(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option<T> {+pub(crate) fn shared_para<T: PartialEq + Copy>(app: &VectorcraftApp, field: impl Fn(&ParaStyle) -> T) -> Option<T> { let mut acc = Shared::None; let mut any = false; visit_paragraphs(app, |p| {@@ -358,6 +359,25 @@ pub(crate) fn apply_numbers(app: &mut VectorcraftApp, p: &Value) -> Result<Value run_on_paragraphs(app, "text.setFormat", params) } +/// The Bullets presets, in the order they are listed: (name, `text.setFormat`'s `bullet`, bullet;+/// None for the preset that turns the list off).+pub(crate) fn bullet_presets() -> [(&'static str, &'static str, Option<vectorcraft_doc::text::Bullet>); 5] {+ use vectorcraft_doc::text::Bullet;+ [+ (tl!("None"), "none", None),+ (tl!("Filled Dot"), "dot", Some(Bullet::Dot)),+ (tl!("Dash"), "dash", Some(Bullet::Dash)),+ (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)),+ (tl!("Filled Square"), "square", Some(Bullet::Square)),+ ]+}++/// The first `n` numbers of number style `style`, as the Numbered List presets show them ("1, 2,+/// 3").+pub(crate) fn number_sample(style: vectorcraft_doc::text::NumberStyle, n: u32) -> String {+ (1..=n).map(|k| style.format(k)).collect::<Vec<_>>().join(", ")+}+ /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`): the markers of the /// list paragraphs of the selected type, or of those the Type tool's selection touches, become /// characters of the text and the lists are turned off (`text.convertListToText`, which also moves@@ -370,9 +390,9 @@ pub(crate) fn convert_list_to_text(app: &mut VectorcraftApp) -> Result<Value, St /// The Bullets button and the arrow beside it (Paragraph panel). The button makes the paragraphs /// the panel acts on a bulleted list (`text.setFormat {list: "bullet"}`) and shows pressed while /// they all are one. The arrow lists the presets (`{bullet}`): None turns the list off, and the-/// one every paragraph has is checked.+/// one every paragraph has is checked. More Options after them opens Bullets and Numbering on its+/// Bullets tab. pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) {- use vectorcraft_doc::text::Bullet; let t = Tokens::get(ui.ctx()); let shown = shared_para(app, |p| p.list.filter(|l| l.number.is_none()).map(|l| l.bullet)); if widgets::icon_button(ui, "dc-para-bullets", tl!("Bullets"), matches!(shown, Some(Some(_))), size).clicked() {@@ -385,31 +405,28 @@ pub fn bullets_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Bullets"))); let arrow = arrow.on_hover_text(tl!("Bullets"));- let presets = [- (tl!("None"), "none", None),- (tl!("Filled Dot"), "dot", Some(Bullet::Dot)),- (tl!("Dash"), "dash", Some(Bullet::Dash)),- (tl!("Hollow Circle"), "circle", Some(Bullet::Circle)),- (tl!("Filled Square"), "square", Some(Bullet::Square)),- ];+ let mut more = false; let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None;- for (name, key, bullet) in presets {+ for (name, key, bullet) in bullet_presets() { if bullet_row(ui, name, bullet, shown == Some(bullet)) { picked = Some(key); } }+ more = more_options_row(ui); picked }); if let Some(key) = picked.and_then(|r| r.inner) { format(app, json!({ "bullet": key })); }+ if more {+ more_options(app, "bullets");+ } } /// A row of the Bullets presets: a check when `checked`, the bullet drawn (a slash for None), then /// its name. Whether it was clicked. fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bullet>, checked: bool) -> bool {- use vectorcraft_doc::text::Bullet; let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click()); if resp.hovered() {@@ -420,24 +437,53 @@ fn bullet_row(ui: &mut Ui, name: &str, bullet: Option<vectorcraft_doc::text::Bul if checked { painter.text(rect.left_center() + vec2(6.0, 0.0), egui::Align2::LEFT_CENTER, "✓", font.clone(), t.text_strong); }- let c = rect.left_center() + vec2(28.0, 0.0);- let stroke = egui::Stroke::new(1.5, t.text_strong);+ paint_bullet(painter, rect.left_center() + vec2(28.0, 0.0), bullet, t.text_strong);+ painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong);+ resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name));+ resp.clicked()+}++/// Draw `bullet` centered on `c` in `color` as the presets show it (a slash for None).+pub(crate) fn paint_bullet(painter: &egui::Painter, c: egui::Pos2, bullet: Option<vectorcraft_doc::text::Bullet>, color: egui::Color32) {+ use vectorcraft_doc::text::Bullet;+ let stroke = egui::Stroke::new(1.5, color); match bullet { None => painter.line_segment([c + vec2(-5.0, 5.0), c + vec2(5.0, -5.0)], stroke),- Some(Bullet::Dot) => painter.circle_filled(c, 3.0, t.text_strong),+ Some(Bullet::Dot) => painter.circle_filled(c, 3.0, color), Some(Bullet::Dash) => painter.line_segment([c - vec2(4.0, 0.0), c + vec2(4.0, 0.0)], stroke),- Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, t.text_strong)),- Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, t.text_strong),+ Some(Bullet::Circle) => painter.circle_stroke(c, 3.0, egui::Stroke::new(1.2, color)),+ Some(Bullet::Square) => painter.rect_filled(egui::Rect::from_center_size(c, vec2(6.0, 6.0)), 0.0, color), };- painter.text(rect.left_center() + vec2(42.0, 0.0), egui::Align2::LEFT_CENTER, name, font, t.text_strong);- resp.widget_info(|| egui::WidgetInfo::selected(egui::Role::RadioButton, ui.is_enabled(), checked, name));+}++/// More Options at the end of the Bullets and Numbered List presets, under a line. Whether it was+/// clicked.+fn more_options_row(ui: &mut Ui) -> bool {+ let t = Tokens::get(ui.ctx());+ ui.separator();+ let (rect, resp) = ui.allocate_exact_size(vec2(130.0, 22.0), egui::Sense::click());+ if resp.hovered() {+ ui.painter().rect_filled(rect, egui::CornerRadius::same(4), t.hover);+ }+ let name = tl!("More Options");+ ui.painter().text(rect.left_center() + vec2(22.0, 0.0), egui::Align2::LEFT_CENTER, name, egui::FontId::proportional(12.5), t.text_strong);+ resp.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), name)); resp.clicked() } +/// More Options: open Bullets and Numbering on `tab` (`ui.bulletsAndNumbering`), or show why it+/// can't open.+fn more_options(app: &mut VectorcraftApp, tab: &str) {+ if let Err(e) = app.run("ui.bulletsAndNumbering", json!({ "tab": tab })) {+ app.status(e);+ }+}+ /// The Numbered List button and the arrow beside it (Paragraph panel). The button makes the /// paragraphs the panel acts on a numbered list (`text.setFormat {list: "number"}`) and shows /// pressed while they all are one. The arrow lists the presets (`{number}`), each shown by its-/// first three numbers: None turns the list off, and the one every paragraph has is checked.+/// first three numbers: None turns the list off, and the one every paragraph has is checked. More+/// Options after them opens Bullets and Numbering on its Numbers tab. pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { use vectorcraft_doc::text::NumberStyle; let t = Tokens::get(ui.ctx());@@ -452,22 +498,26 @@ pub fn numbers_button(app: &mut VectorcraftApp, ui: &mut Ui, size: f32) { crate::icons::paint(ui, "chevron-down", egui::Rect::from_center_size(rect.center(), vec2(10.0, 10.0)), t.text_dim); arrow.widget_info(|| egui::WidgetInfo::labeled(egui::Role::Button, ui.is_enabled(), tl!("Numbered List"))); let arrow = arrow.on_hover_text(tl!("Numbered List"));+ let mut more = false; let picked = egui::Popup::menu(&arrow).show(|ui| { let mut picked = None; if bullet_row(ui, tl!("None"), None, shown == Some(None)) { picked = Some(None); } for style in NumberStyle::ALL {- let sample = (1..=3).map(|n| style.format(n)).collect::<Vec<_>>().join(", ");- if number_row(ui, &sample, shown == Some(Some(style))) {+ if number_row(ui, &number_sample(style, 3), shown == Some(Some(style))) { picked = Some(Some(style)); } }+ more = more_options_row(ui); picked }); if let Some(style) = picked.and_then(|r| r.inner) { format(app, json!({ "number": style.map_or(json!("none"), |s| json!(s)) })); }+ if more {+ more_options(app, "numbers");+ } } /// A row of the Numbered List presets: a check when `checked`, then the style's first numbers@@ -1182,6 +1232,43 @@ mod tests { assert_eq!(lists(&app), (None, None)); } + /// More Options at the end of the Bullets presets opens Bullets and Numbering on its Bullets+ /// tab, and at the end of the Numbered List presets on its Numbers tab. Type › Bullets and+ /// Numbering › Bullets and Numbering… opens it too, while type is selected.+ #[test]+ fn more_options_opens_bullets_and_numbering_on_its_tab() {+ crate::i18n::set_current(crate::i18n::Lang::EN);+ let (mut app, _) = editing_text("One\nTwo");+ select_range(&mut app, 0, 0);+ let ctx = egui::Context::default();+ crate::theme::install_fonts(&ctx);+ let numbers = std::cell::Cell::new(false);+ let row = std::cell::Cell::new(egui::Rect::NOTHING);+ let mut draw = |app: &mut VectorcraftApp, ui: &mut Ui| {+ let r = ui.horizontal(|ui| if numbers.get() { numbers_button(app, ui, 28.0) } else { bullets_button(app, ui, 28.0) });+ row.set(r.response.rect);+ };+ for (on_numbers, tab) in [(false, "bullets"), (true, "numbers")] {+ numbers.set(on_numbers);+ super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw);+ let arrow = row.get().min + vec2(28.0 + 7.0, 14.0);+ super::super::tests_appearance::click(&ctx, &mut app, arrow, &mut draw);+ let texts = super::super::tests_appearance::frame_events(&ctx, &mut app, vec![], &mut draw);+ let at = super::super::tests_appearance::text_rect(&texts, "More Options").center();+ super::super::tests_appearance::click(&ctx, &mut app, at, &mut draw);+ let d = app.ui.dialog.take().unwrap_or_else(|| panic!("More Options opens the dialog from the {tab} presets"));+ assert_eq!((d.kind.as_str(), d.str("tab")), ("bulletsAndNumbering", tab.to_string()));+ }+ let entry = crate::menus::menu_entries(&app).into_iter().find(|e| e.command.as_deref() == Some("ui.bulletsAndNumbering"));+ let entry = entry.expect("Type › Bullets and Numbering › Bullets and Numbering…");+ assert_eq!(+ (entry.path.join(" › "), entry.label.as_str(), entry.enabled),+ ("Type › Bullets and Numbering".into(), "Bullets and Numbering…", true)+ );+ app.run("ui.bulletsAndNumbering", json!({})).unwrap();+ assert_eq!(app.ui.dialog.as_ref().map(|d| d.kind.as_str()), Some("bulletsAndNumbering"));+ }+ /// Type › Bullets and Numbering › Convert to Text (`type.convertListToText`) acts on the /// paragraphs the Type tool's selection touches: their numbers become characters of the text /// and their list goes, while the other paragraphs stay in the list, and the Type tool's caret@@ -4522,6 +4522,16 @@ the text. Return at the end of a list item starts another item in the same list. set, and paragraph styles carry it: a style captured from a paragraph without a list turns the list off. Reset Panel turns it off. The change is one undo step. +`text.setFormat {listAlign: "left"|"right"}` (Bullets and Numbering › Alignment) sets where the marker stands before+the first line's text. With `"left"`, the default, the marker starts where the first line starts, at the left indent+plus the first-line indent. With `"right"` the marker ends where the text starts, against the text. The text starts at+the same place with either alignment, so numbers of different widths aligned right end at the same place, and a number+that reaches past the left indent ends at the next tab stop, where the text starts. In a right-to-left list paragraph a+marker aligned right starts where the text ends. `listAlign` sets the paragraphs that are in a list, and those that the+same call puts in one; a paragraph in no list stays out of one. A list keeps its alignment when it changes between+bullets and numbers, and a list that is turned off and on again starts aligned left. An unknown value is an error and+changes nothing. The list saves the alignment (`"align": "right"`) only when it is right.+ A numbered paragraph's number counts the numbered paragraphs of its story up to and including it: the count runs through consecutive list paragraphs, through the frames of threaded type in thread order, and starts again at 1 after a paragraph in no list. An empty paragraph has no number and is not counted, and a bulleted paragraph keeps its bullet and@@ -4549,12 +4559,27 @@ Type › Bullets and Numbering › Apply Bullets and the canvas context menu's B same menus runs `type.applyNumbers {number?}`. Without `number` it runs `text.setFormat {list: "number"}`, and with it it runs `text.setFormat {number}`. +Type › Bullets and Numbering › Bullets and Numbering…, and More Options at the end of the Paragraph panel's Bullets and+Numbered List presets, run `ui.bulletsAndNumbering {tab?: "bullets"|"numbers"}`. It opens the Bullets and Numbering+dialog (`bulletsAndNumbering`) on the paragraphs the Paragraph panel acts on, on that tab; without `tab` it opens on+Numbers when the paragraphs are a numbered list and on Bullets otherwise. The Bullets tab has the five bullet presets+and the Numbers tab has None and the seven number styles. The Position group has Alignment (Left or Right), Left Indent+and First Line Indent. The dialog opens with the values the Paragraph panel shows; where the paragraphs differ, no+preset is picked and the field is blank. Its fields are `tab`, `list` (`none`, `bullet` or `number`: the kind of the+preset picked), `bullet`, `number`, `listAlign`, `leftIndent` and `firstLineIndent` (points), and `ui.dialog.set` sets+them, including those the dialog opened blank or without. The type shows the dialog's values while it is open. A preset picked for paragraphs that are in no list and have+no indents fills in 18 pt and -18 pt, and None takes them out again while they are unchanged. OK runs `text.setFormat`+with the values that changed, as one undo step, and Cancel puts the type back as it was. When a preset puts the+paragraphs in a list, OK also sends the indents the dialog shows, and the paragraphs take them, 0 included. With no type selected, or+with an unknown `tab`, `ui.bulletsAndNumbering` is an error.+ Type › Bullets and Numbering › Convert to Text runs `type.convertListToText` on the selected type's paragraphs, or on those the Type tool's selection touches, and that runs `text.convertListToText {ids?, start?, end?}`. The marker of each list paragraph becomes characters at the start of the paragraph: the bullet, or the number and its period as the paragraph showed it, then a tab. They are set in the marker's font, size and color: the style of the paragraph's first-character without the attributes the marker leaves out. The paragraph leaves its list. When the marker's tab went to-the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that+character without the attributes the marker leaves out. The paragraph leaves its list. A marker aligned right+(`listAlign` `right`) is written the same way: its characters go to the first line's start, where a marker aligned left+is drawn, and the text after its tab stays where it was. When the marker's tab went to the left indent, the paragraph gets a left-aligned tab stop without a leader there, in place of any stop there, so that the text after the tab starts where it started after the marker. When the marker's tab went to a tab stop, the converted tab goes to the same place when that stop is left-aligned and no stop there or past it has a leader. When a stop there or past it has a leader or is not left-aligned, or a right, center or decimal stop lies between the marker@@ -4592,6 +4617,8 @@ the paragraph's text now starts; when the text before `at` starts no list, nothi ```json {"name":"run_command","arguments":{"command":"text.setFormat","params":{"bullet":"square"}}} {"name":"run_command","arguments":{"command":"text.setFormat","params":{"number":"upperRoman"}}}+{"name":"run_command","arguments":{"command":"text.setFormat","params":{"listAlign":"right"}}}+{"name":"invoke_menu","arguments":{"command":"ui.bulletsAndNumbering","params":{"tab":"numbers"}}} {"name":"run_command","arguments":{"command":"text.convertListToText","params":{}}} ``` @@ -787,9 +787,9 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur The Paragraph panel menu's Hyphenation… (`ui.hyphenation`, `text.setFormat {wordsLongerThan, afterFirst, beforeLast, hyphenLimit, hyphenationZone, hyphenateCapitalized}`) sets each paragraph's hyphenation limits: the shortest word that breaks, the fewest letters on each side of the hyphen, the most hyphenated lines in a row, a zone at the right edge where the Single-line Composer breaks before a word instead of hyphenating it, and whether capitalized words break. The defaults are words longer than 5 letters, 3 letters on each side, no limit or zone, and capitalized words hyphenated; paragraph styles and the native file keep the options. PDF export with type as text writes the hyphen that ends a line broken inside a word as a hyphen character where the line draws it, and the file opens with the hyphen in the line's text; kashidas are drawn as paths. The Paragraph panel's Insert Kashidas (with the Indic options; `text.setFormat {kashida}`) lengthens each Arabic word of a justified horizontal area type line with a kashida, a tatweel between its last pair of joined letters, before the word spaces widen: Short, Medium and Long kashidas are at most a quarter, half and a whole em long, Stylistic ones are whole tatweels up to an em, and None (the default) leaves the line as before; paragraph styles and the native file keep the setting.- The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and list detection while typing are not built yet.- The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. The Bullets and Numbering dialog, levels and Convert to Text are not built yet.+ The Paragraph panel's Bullets button, Type › Bullets and Numbering › Apply Bullets and the context menu's Apply Bullets (`text.setFormat {list, bullet}`, `type.applyBullets`) make paragraphs a bulleted list with the documented presets (none, filled dot, dash, hollow circle, filled square; [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): the bullet starts each paragraph's first line in its first character's font, size and color, the first line's text starts at the next tab stop after the bullet (the left indent counts as a stop), the lines it wraps to start at the left indent, a paragraph with no indents gets 18 pt and -18 pt, and Return continues the list; Reset Panel turns it off, and paragraph styles and the native file keep it. Levels are not built yet. The Paragraph panel's Numbered List button, Type › Bullets and Numbering › Apply Numbers and the context menu's Apply Numbers (`text.setFormat {list, number}`, `type.applyNumbers`) make paragraphs a numbered list with the documented presets (1, A, a, I, i, 01, 001, and None, which turns the list off; [Bullets and numbering](https://web.archive.org/web/20250807202418/https://helpx.adobe.com/illustrator/using/bullets-numbering.html), [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): each paragraph starts with its number and a period, counted through the list paragraphs of the story and its threaded frames, with the text and indents as in a bulleted list; letters go on from Z to AA, and roman numerals go on from III to IV, an empty paragraph is not numbered, and a paragraph in no list starts the count again at 1.+ Type › Bullets and Numbering › Bullets and Numbering… and More Options in the Bullets and Numbered List presets (`ui.bulletsAndNumbering`, `text.setFormat {listAlign}`) open the Bullets and Numbering dialog ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html)): a Bullets tab and a Numbers tab with the presets, and the Position group's Alignment, Left Indent and First Line Indent, shown on the type while the dialog is open; OK applies them as one undo step and Cancel puts the type back. Alignment Left starts the marker at the first line's start, and Right ends it where the text starts; paragraph styles and the native file keep the alignment. Type › Bullets and Numbering › Convert to Text (`type.convertListToText`, `text.convertListToText`) makes the bullets and numbers of the list paragraphs editable characters of their text ([Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html), [December 2022 release notes](https://web.archive.org/web/20260203094741/https://helpx.adobe.com/illustrator/using/whats-new/2023-1.html)): each marker becomes its characters and a tab at the start of its paragraph, in the marker's font, size and color, the list is turned off, and a left tab stop at the left indent keeps the text after the tab where it started; threaded type keeps the numbers its story counted. The Type tool starts a list from the characters [Create bulleted or numbered lists](https://web.archive.org/web/20260417034638/https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/create-bulleted-or-numbered-lists.html) names (`text.startList`): a space typed on its own after `1.`, `1)`, `A.` or `A)` at the start of a paragraph in no list makes the paragraph a numbered list in 1, 2, 3 or A, B, C, and a space typed after `*`, `#`, `>` or `+` makes it a bulleted list with the filled dot. The typed characters are removed, and one Undo puts them back as text with no list. The same characters later in a paragraph, and pasted list characters, start nothing. PDF export writes bullets and numbers as text, in the glyphs and at the places the canvas draws them, in area type, type on a path, right-to-left paragraphs and threaded frames, and a PDF viewer selects, copies and finds them; a marker glyph that stands for no character is drawn as a path.