diff --git a/crates/engine/src/cmd/panelcmds.rs b/crates/engine/src/cmd/panelcmds.rs index 83a0655284..f94494625b 100644 --- a/crates/engine/src/cmd/panelcmds.rs +++ b/crates/engine/src/cmd/panelcmds.rs @@ -67,7 +67,7 @@ pub fn specs() -> Vec { "Character / Paragraph", [], None, - "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), 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\", listLevel?: 1..9 (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; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), 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), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), 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), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), 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, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment)", + "{ids?|id?, kerning?: 1/1000 em|\"auto\"|\"optical\", baselineShift?: pt, hScale?: %, vScale?: %, rotation?: deg counter-clockwise (0: upright), underline?, strikethrough?, allCaps?, smallCaps?: bool, position?: \"normal\"|\"superscript\"|\"subscript\" (sizes from Document Setup), antiAlias?: \"none\"|\"sharp\"|\"crisp\"|\"strong\" (Character panel › Anti-aliasing Method, how the type's edges are drawn as pixels; it sets each targeted type object, with or without a range; each frame of a thread keeps its own method; graph labels keep it for every label of their kind when the graph is drawn again; new type: \"sharp\"), 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\", listLevel?: 1..9 (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; listLevel (Tab and Shift+Tab at the start of a list paragraph, Bullets and Numbering…'s Level) is a whole number from 1 to 9 that sets the paragraphs in a list or that the call puts in one, a list keeping it when it changes between bullets and numbers, and a list turned on without listLevel starting at level 1; each level past the first sets the paragraph 18 pt further in than its left indent, and each level counts its own numbers: a list paragraph with text sets the counts of the levels deeper than its own back to 0, so a deeper numbered paragraph after it counts from 1, and a paragraph back at a shallower level goes on with that level's count), 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), kinsokuType?: \"pushInFirst\"|\"pushOutFirst\"|\"pushOutOnly\" (Paragraph panel menu › Kinsoku Shori Type: how area type keeps those characters off a line's ends; pushInFirst sets them on the line when its gaps next to CJK characters can close up enough, by at most a tenth of an em each, pushOutFirst does so only when moving the character before them to the next line would leave the line more than an em short, and pushOutOnly, the default, moves it unless Burasagari hangs an overflowing comma or full stop; no effect with kinsoku \"none\"), 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), tcyUpDown?, tcyLeftRight?: pt -1296..1296 (Character panel menu › Tate-chu-yoko Settings, as text.setRangeStyle: a tate-chu-yoko block moves up or down, right or left, and on a vertical path toward or away from the path's start, to its left or right), standardVerticalRoman?: bool (Character panel menu › Standard Vertical Roman Alignment, as text.setRangeStyle), noBreak?: bool (Character panel menu › No Break: these characters stay on one line, including across a space inside the run), kashidas?: bool (Character panel menu › Kashidas: selected characters allow insertion only when both joined neighbors have it on; on by default), 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\"|\"devanagari\"|\"bengali\"|\"gurmukhi\"|\"gujarati\"|\"odia\"|\"tamil\"|\"telugu\"|\"kannada\"|\"malayalam\" (Character panel menu › Digits: the numeral system every digit is drawn in, whatever was typed: Western, Arabic-Indic (hindi), Eastern Arabic-Indic (farsi) or an Indic script's own digits), fontHeightReference?: \"emBox\"|\"capHeight\"|\"xHeight\"|\"icfBox\" (Character panel › Set Font Height Reference, as text.setRangeStyle: the height of the font that the size measures), language?: name|tag (Character panel › Language, as text.setRangeStyle), 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), koreanLineBreakAnywhere?: bool (Paragraph panel menu › Korean Line Break Anywhere: a line may break between any two Hangul syllables; off, a word with Hangul in it breaks only at its spaces and punctuation, or where it is wider than the line; new type: false, paragraphs saved before the option: true), 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, and graph labels keep the character and paragraph attributes for every label of their kind when the graph is drawn again, the graph still setting each label's alignment; a number or switch given as another type fails the call, and a null value leaves its attribute as it is, so a call with only null values fails with nothing to change; a call that gives the values the text already has records no undo step). Or {paragraphIndents: [{id, start: byte where the paragraph starts, leftIndent?, firstLineIndent?: pt -1296..1296}…]} alone (Tabs panel indent markers over paragraphs with indents of their own): each entry sets one paragraph of a type object, at least one indent each, at most 100000 entries; every entry is checked before anything changes, and an unknown key, a value out of range, an object that is not type, a byte where no paragraph starts or two entries for one paragraph with other values fail the call; all of it is one edit, a threaded story flowing again once, and when no paragraph's indents change nothing is recorded → {ids}", has_doc, set_format ), @@ -467,6 +467,9 @@ fn list_level_param(p: &Value, cmd: &str) -> Result> { fn set_format(s: &mut Session, p: &Value) -> Result { const C: &str = "text.setFormat"; + if p.get("paragraphIndents").is_some() { + return set_paragraph_indents(s, p); + } let ids = super::typecmd::text_targets(s, p, C)?; check_switches( p, @@ -486,6 +489,27 @@ fn set_format(s: &mut Session, p: &Value) -> Result { ], C, )?; + // Each value that `num` reads below must be a number or null. + super::check_numbers( + p, + &[ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ], + C, + )?; let num = |k: &str| p.get(k).and_then(Value::as_f64); let flag = |k: &str| p.get(k).and_then(Value::as_bool); let kerning = match p.get("kerning") { @@ -557,7 +581,8 @@ fn set_format(s: &mut Session, p: &Value) -> Result { "kashida", "composer", ]; - if !keys.iter().any(|k| p.get(*k).is_some()) { + // A null value leaves its attribute as it is, as an omitted one does. + if !keys.iter().any(|k| p.get(*k).is_some_and(|v| !v.is_null())) { return Err(bad(C, "nothing to change")); } // The anti-aliasing method belongs to the whole type object; each frame keeps its own method. @@ -587,7 +612,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let tcy_up_down = super::textedit::tcy_offset_param(p, "tcyUpDown", C)?; let tcy_left_right = super::textedit::tcy_offset_param(p, "tcyLeftRight", C)?; let mojikumi = match p.get("mojikumi") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Mojikumi::None, Some("lineEndHalf") => vectorcraft_doc::Mojikumi::LineEndHalf, @@ -595,7 +620,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kinsoku = match p.get("kinsoku") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Kinsoku::None, Some("hard") => vectorcraft_doc::Kinsoku::Hard, @@ -604,7 +629,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kinsoku_type = match p.get("kinsokuType") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("pushInFirst") => vectorcraft_doc::KinsokuShoriType::PushInFirst, Some("pushOutFirst") => vectorcraft_doc::KinsokuShoriType::PushOutFirst, @@ -613,7 +638,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let direction = match p.get("direction") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("auto") => None, Some("leftToRight") => Some(vectorcraft_doc::ParaDirection::LeftToRight), @@ -622,7 +647,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let leading_model = match p.get("leadingModel") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("romanBaseline") => vectorcraft_doc::LeadingModel::RomanBaseline, Some("emBoxTop") => vectorcraft_doc::LeadingModel::EmBoxTop, @@ -630,7 +655,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let burasagari = match p.get("burasagari") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::Burasagari::None, Some("standard") => vectorcraft_doc::Burasagari::Standard, @@ -639,7 +664,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let kashida = match p.get("kashida") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("none") => vectorcraft_doc::text::Kashida::None, Some("short") => vectorcraft_doc::text::Kashida::Short, @@ -670,7 +695,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { let spacing = |k: &str| spacing_param(p, k, C); let (word_spacing, letter_spacing, glyph_scaling) = (spacing("wordSpacing")?, spacing("letterSpacing")?, spacing("glyphScaling")?); let single_word = match p.get("singleWordJustification") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(match v.as_str() { Some("fullJustify") => SingleWordJustify::FullJustify, Some("alignLeft") => SingleWordJustify::AlignLeft, @@ -680,7 +705,7 @@ fn set_format(s: &mut Session, p: &Value) -> Result { }), }; let auto_leading = match p.get("autoLeading") { - None => None, + None | Some(Value::Null) => None, Some(v) => Some(v.as_f64().filter(|v| v.is_finite()).ok_or_else(|| bad(C, "`autoLeading` must be a number"))?), }; // A paragraph whose Minimum, Desired and Maximum would no longer be in order. @@ -787,12 +812,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { l.level = list_level.unwrap_or(l.level); } }; - s.edit("Character", |d, _| { + // The change is made on a copy first: a call whose values are the ones the text already has + // records no edit, undo step or revision. Only the type objects and graph label styles the call + // changes are compared, not the whole document. + super::variablecmds::settle_typing(s)?; + let mut doc = (*s.doc()?.doc).clone(); + let mut changed = false; + { + let d = &mut doc; // The paragraph change, for the graph labels to keep. let mut para_change = None; let out_of_order_error = |name: &str| bad(C, format!("`{name}`: Minimum must not exceed Desired, nor Desired Maximum")); for id in &ids { let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) else { continue }; + let before = t.clone(); let baseline_shift = num("baselineShift").map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose()?; let h_scale = num("hScale").map(|v| super::typecmd::local_type_value(t, v, (1.0, 10000.0), true, C)).transpose()?; let local = |v: Option| v.map(|v| super::typecmd::local_type_value(t, v, (-1296.0, 1296.0), false, C)).transpose(); @@ -896,16 +929,17 @@ fn set_format(s: &mut Session, p: &Value) -> Result { return Err(out_of_order_error(name)); } super::typecmd::refresh_bounds(t); + changed |= *t != before; para_change = Some(paras); } // Graph labels keep a change made to all of their text through the graph being drawn again. if range.is_none() { let shift = num("baselineShift").map(|v| v.clamp(-1296.0, 1296.0)); let scale = num("hScale").map(|v| v.clamp(1.0, 10000.0)); - super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); + changed |= super::graph::capture_label_styles(d, &ids, &|st| chars(st, shift, scale, (tcy_up_down, tcy_left_right))); if let Some(paras) = para_change { // The kept attributes are checked too: they may differ from the labels'. - super::graph::capture_label_paragraphs(d, &ids, &|para| { + changed |= super::graph::capture_label_paragraphs(d, &ids, &|para| { let mut out_of_order = None; paras(para, &mut out_of_order); out_of_order.map_or(Ok(()), |name| Err(out_of_order_error(name))) @@ -915,14 +949,20 @@ fn set_format(s: &mut Session, p: &Value) -> Result { if let Some(v) = anti_alias { for id in &ids { if let Some(NodeKind::Text(t)) = d.node_mut(*id).map(|n| &mut n.kind) { + changed |= t.anti_alias != v; t.anti_alias = v; } } // Graph labels keep the method through the graph being drawn again. - super::graph::capture_label_anti_alias(d, &ids, v); + changed |= super::graph::capture_label_anti_alias(d, &ids, v); } - Ok(()) - })?; + } + if changed { + s.edit("Character", |d, _| { + *d = doc; + Ok(()) + })?; + } let mut result = json!({ "ids": ids.iter().map(|i| i.0).collect::>() }); if let Some(kerning) = p.get("kerning") { result["kerning"] = kerning.clone(); @@ -930,6 +970,126 @@ fn set_format(s: &mut Session, p: &Value) -> Result { Ok(result) } +/// The most entries `paragraphIndents` takes in one `text.setFormat` call. +pub(crate) const MAX_PARAGRAPH_INDENTS: usize = 100_000; + +/// The indents one `paragraphIndents` entry gives its paragraph. +#[derive(Clone, Copy, Debug, PartialEq)] +struct ParagraphIndent { + left: Option, + first_line: Option, +} + +/// A type object that `paragraphIndents` names: the bytes where its paragraphs start and the +/// indents its entries give them, by paragraph index. +struct IndentedFrame { + id: NodeId, + starts: Vec, + changes: Vec<(usize, ParagraphIndent)>, +} + +/// `text.setFormat` with `paragraphIndents`: each entry names one paragraph of a type object, by +/// the object and the byte where the paragraph starts, and sets its left and first-line indents. +/// Every entry is checked against the document before anything changes. Each type object's +/// paragraph styles are rebuilt once and the whole change is one edit, so a threaded story flows +/// again once however many of its paragraphs change. When no entry changes its paragraph, nothing +/// is edited. +fn set_paragraph_indents(s: &mut Session, p: &Value) -> Result { + const C: &str = "text.setFormat"; + const KEY: &str = "paragraphIndents"; + if let Some(other) = p.as_object().and_then(|o| o.keys().find(|k| *k != KEY)) { + return Err(bad(C, format!("`{KEY}` takes no `{other}` beside it"))); + } + let entries = p.get(KEY).and_then(Value::as_array).ok_or_else(|| bad(C, format!("`{KEY}` must be a list")))?; + if entries.is_empty() { + return Err(bad(C, format!("`{KEY}` is empty"))); + } + if entries.len() > MAX_PARAGRAPH_INDENTS { + return Err(bad(C, format!("`{KEY}` takes at most {MAX_PARAGRAPH_INDENTS} entries (got {})", entries.len()))); + } + let d = &s.doc()?.doc; + // The type objects in the order they first appear. + let mut frames: Vec = vec![]; + let mut frame_at: std::collections::HashMap = std::collections::HashMap::new(); + let mut given: std::collections::HashMap<(NodeId, usize), ParagraphIndent> = std::collections::HashMap::new(); + for (n, e) in entries.iter().enumerate() { + let e = e.as_object().ok_or_else(|| bad(C, format!("`{KEY}` entry {n} must be an object")))?; + if let Some(k) = e.keys().find(|k| !["id", "start", "leftIndent", "firstLineIndent"].contains(&k.as_str())) { + return Err(bad(C, format!("`{KEY}` entry {n}: unknown key `{k}`"))); + } + let indent = |k: &str| -> Result> { + match e.get(k) { + None => Ok(None), + Some(v) => match v.as_f64() { + Some(x) if x.is_finite() && x.abs() <= MAX_PARA => Ok(Some(x)), + _ => Err(bad(C, format!("`{KEY}` entry {n}: `{k}` must be a number from -{MAX_PARA} to {MAX_PARA} pt"))), + }, + } + }; + let change = ParagraphIndent { left: indent("leftIndent")?, first_line: indent("firstLineIndent")? }; + if change.left.is_none() && change.first_line.is_none() { + return Err(bad(C, format!("`{KEY}` entry {n} sets neither `leftIndent` nor `firstLineIndent`"))); + } + let id = NodeId(e.get("id").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `id` must be an object id")))?); + let start = index(e.get("start").and_then(Value::as_u64).ok_or_else(|| bad(C, format!("`{KEY}` entry {n}: `start` must be a byte offset")))?); + let k = match frame_at.get(&id) { + Some(k) => *k, + None => { + let Some(NodeKind::Text(t)) = d.node(id).map(|n| &n.kind) else { + return Err(bad(C, format!("`{KEY}` entry {n}: {} is not a type object", id.0))); + }; + let plain = t.plain_text(); + let mut starts = vec![0]; + starts.extend(plain.bytes().enumerate().filter_map(|(i, c)| (c == b'\n').then_some(i + 1))); + frames.push(IndentedFrame { id, starts, changes: vec![] }); + frame_at.insert(id, frames.len() - 1); + frames.len() - 1 + } + }; + let Some(IndentedFrame { starts, changes, .. }) = frames.get_mut(k) else { continue }; + let para = starts.binary_search(&start).map_err(|_| bad(C, format!("`{KEY}` entry {n}: no paragraph of {} starts at byte {start}", id.0)))?; + match given.insert((id, para), change) { + None => changes.push((para, change)), + Some(was) if was == change => {} + Some(_) => { + return Err(bad( + C, + format!("`{KEY}` entry {n} gives the paragraph at byte {start} of {} other indents than an entry before it", id.0), + )); + } + } + } + let ids = json!({ "ids": frames.iter().map(|f| f.id.0).collect::>() }); + // Only the type objects with a paragraph whose indents change are edited. + let differs = |t: &vectorcraft_doc::TextObject, i: usize, c: &ParagraphIndent| { + let para = t.para_at(i); + c.left.is_some_and(|v| v != para.left_indent) || c.first_line.is_some_and(|v| v != para.first_line_indent) + }; + frames.retain(|f| match d.node(f.id).map(|n| &n.kind) { + Some(NodeKind::Text(t)) => f.changes.iter().any(|(i, c)| differs(t, *i, c)), + _ => false, + }); + if frames.is_empty() { + return Ok(ids); + } + s.edit("Character", |d, _| { + for f in &frames { + let Some(NodeKind::Text(t)) = d.node_mut(f.id).map(|n| &mut n.kind) else { continue }; + let mut styles = t.paragraph_styles(); + for (i, c) in &f.changes { + if let Some(para) = styles.get_mut(*i) { + para.left_indent = c.left.unwrap_or(para.left_indent); + para.first_line_indent = c.first_line.unwrap_or(para.first_line_indent); + } + } + t.set_paragraph_styles(styles); + super::typecmd::refresh_bounds(t); + } + Ok(()) + })?; + Ok(ids) +} + /// The part of a story paragraph in one frame, as [`convert_list_to_text`] reads it: the frame, /// the frame's paragraph, its bytes there, its list, the marker the frame's layout draws for it /// and the numbered paragraphs the frame counts before it at each list level. diff --git a/crates/engine/src/cmd/threads/tests.rs b/crates/engine/src/cmd/threads/tests.rs index 48a1e46fbc..b6ea41df3e 100644 --- a/crates/engine/src/cmd/threads/tests.rs +++ b/crates/engine/src/cmd/threads/tests.rs @@ -1679,3 +1679,164 @@ fn the_frame_a_release_leaves_alone_has_the_bounds_of_the_whole_story() { let cached = t.cached_bounds.unwrap(); assert!((cached.y0 - bounds.y0).abs() < 1e-6 && (cached.y1 - bounds.y1).abs() < 1e-6, "{cached:?} {bounds:?}"); } + +/// Twelve paragraphs that wrap, threaded through three frames, the first two of which end inside +/// a paragraph; the last frame's paragraphs are indented 10 pt. → the session and the frames. +fn split_story() -> (Session, [u64; 3]) { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let words = "aaa bbb ccc ddd eee fff ggg hhh iii jjj ".repeat(3); + let story = (1..=12).map(|i| format!("p{i} {}", words.trim_end())).collect::>().join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 50}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 50]})).unwrap()["id"].as_u64().unwrap(); + let c = s.execute("text.thread.add", &json!({"from": b, "rect": [600, 10, 250, 950]})).unwrap()["id"].as_u64().unwrap(); + s.execute("text.setFormat", &json!({"ids": [c], "leftIndent": 10})).unwrap(); + for id in [b, c] { + assert!(text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap().first_para_continues, "frame {id} starts inside a paragraph"); + } + (s, [a, b, c]) +} + +/// Each frame's text, paragraph styles and whether it continues a paragraph. +fn frames_of(s: &Session, frames: &[u64]) -> Vec<(String, Vec, bool)> { + frames + .iter() + .map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + (t.plain_text(), t.paragraph_styles(), t.first_para_continues) + }) + .collect() +} + +/// One entry for each paragraph of the story: a paragraph split between frames by its piece in the +/// later frame. Each takes indents of its own, wide enough to move text between the frames. +fn indent_entries(s: &Session, frames: &[u64]) -> Vec { + let mut out = vec![]; + for (k, id) in frames.iter().enumerate() { + let t = text_of(&s.doc().unwrap().doc, NodeId(*id)).unwrap(); + let next_continues = frames.get(k + 1).is_some_and(|n| text_of(&s.doc().unwrap().doc, NodeId(*n)).unwrap().first_para_continues); + let plain = t.plain_text(); + let pieces: Vec<&str> = plain.split('\n').collect(); + let mut start = 0; + for (i, piece) in pieces.iter().enumerate() { + if i + 1 < pieces.len() || !next_continues { + let n = out.len() as f64; + out.push(json!({"id": id, "start": start, "leftIndent": 20.0 + n, "firstLineIndent": 15.0 - n})); + } + start += piece.len() + 1; + } + } + out +} + +/// `paragraphIndents` sets each paragraph of a threaded story as one `text.setFormat` command for +/// each paragraph would, the story's last paragraph first, with a paragraph split between frames +/// set once; the story flows again once, and Undo and Redo take the whole change as one step. +#[test] +fn paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would() { + let (mut one, frames) = split_story(); + let (mut each, _) = split_story(); + let entries = indent_entries(&one, &frames); + assert_eq!(entries.len(), 12, "one entry for each paragraph of the story: {entries:?}"); + let commands: Vec = entries + .iter() + .rev() + .map(|e| { + json!({"command": "text.setFormat", "params": { + "ids": [e["id"]], "start": e["start"], "end": e["start"], "leftIndent": e["leftIndent"], "firstLineIndent": e["firstLineIndent"] + }}) + }) + .collect(); + each.execute("command.batch", &json!({"label": "Left Indent", "commands": commands})).unwrap(); + let start = frames_of(&one, &frames); + let undo = one.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = one.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!(COPIED.with(std::cell::Cell::get), copied + 1, "the story flows again once"); + // The first frame holds only the start of the paragraph that runs on into the second. + assert_eq!(r, json!({"ids": frames[1..]})); + assert_eq!(one.doc().unwrap().history.undo.len(), undo + 1); + let set = frames_of(&one, &frames); + assert_ne!(set, start); + assert_eq!(set, frames_of(&each, &frames)); + one.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), start); + one.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(frames_of(&one, &frames), set); +} + +/// Thousands of paragraphs of a threaded story are set by one edit, which flows the story again +/// once. +#[test] +fn paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 900, "height": 1000})).unwrap(); + let story = vec!["x"; 5000].join("\n"); + let a = s.execute("text.create", &json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 300}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = s.execute("text.thread.add", &json!({"from": a, "rect": [300, 10, 150, 300]})).unwrap()["id"].as_u64().unwrap(); + let entries: Vec = [a, b] + .into_iter() + .flat_map(|id| { + let t = text_of(&s.doc().unwrap().doc, NodeId(id)).unwrap(); + let count = t.paragraph_count(); + (0..count).map(move |i| json!({"id": id, "start": 2 * i, "leftIndent": (i % 7) as f64})) + }) + .collect(); + assert!(entries.len() >= 5000, "{}", entries.len()); + let (undo, copied) = (s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)); + s.execute("text.setFormat", &json!({"paragraphIndents": entries})).unwrap(); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo + 1, copied + 1)); + let t = text_of(&s.doc().unwrap().doc, NodeId(a)).unwrap(); + assert_eq!((t.para_at(0).left_indent, t.para_at(1).left_indent, t.para_at(8).left_indent), (0.0, 1.0, 1.0)); +} + +/// `paragraphIndents` that would change no paragraph leaves the document and its history alone, and +/// a bad one is refused before anything changes. +#[test] +fn paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing() { + let (mut s, [a, b, c]) = split_story(); + let start = frames_of(&s, &[a, b, c]); + let undo = s.doc().unwrap().history.undo.len(); + let copied = COPIED.with(std::cell::Cell::get); + let r = + s.execute("text.setFormat", &json!({"paragraphIndents": [{"id": a, "start": 0, "leftIndent": 0}, {"id": c, "start": 0, "leftIndent": 10}]})); + assert_eq!(r.unwrap(), json!({"ids": [a, c]})); + assert_eq!((s.doc().unwrap().history.undo.len(), COPIED.with(std::cell::Cell::get)), (undo, copied), "nothing to change"); + let paragraph = text(&s, c).find('\n').unwrap() + 1; + let rect = s.execute("shape.rectangle", &json!({"x": 10, "y": 900, "width": 10, "height": 10})).unwrap()["id"].as_u64().unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let ok = json!({"id": c, "start": paragraph, "leftIndent": 30}); + for bad in [ + json!({"paragraphIndents": []}), + json!({"paragraphIndents": {"id": a, "start": 0, "leftIndent": 30}}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "ids": [a]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30}], "leftIndent": 5}), + json!({"paragraphIndents": [ok, 3]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": "30"}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 1300}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "firstLineIndent": -1297}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 0, "leftIndent": 30, "spaceBefore": 4}]}), + json!({"paragraphIndents": [ok, {"id": "a", "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": rect, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": 999_999, "start": 0, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": -1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": a, "start": 1, "leftIndent": 30}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 31}]}), + json!({"paragraphIndents": [ok, {"id": c, "start": paragraph, "leftIndent": 30, "firstLineIndent": 0}]}), + json!({"paragraphIndents": vec![ok.clone(); crate::cmd::panelcmds::MAX_PARAGRAPH_INDENTS + 1]}), + ] { + assert!(s.execute("text.setFormat", &bad).is_err(), "{bad}"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "{bad}"); + assert_eq!(frames_of(&s, &[a, b, c]), start, "{bad}"); + } + // The same entry twice is one change. + s.execute("text.setFormat", &json!({"paragraphIndents": [ok, ok]})).unwrap(); + assert_eq!(s.doc().unwrap().history.undo.len(), undo + 1); + assert_eq!(text_of(&s.doc().unwrap().doc, NodeId(c)).unwrap().para_at(1).left_indent, 30.0); +} diff --git a/crates/engine/src/cmd/typecmd/area_tests.rs b/crates/engine/src/cmd/typecmd/area_tests.rs index 5545e1be94..d12bd8acae 100644 --- a/crates/engine/src/cmd/typecmd/area_tests.rs +++ b/crates/engine/src/cmd/typecmd/area_tests.rs @@ -2156,3 +2156,127 @@ fn set_path_handle_reshapes_type_on_a_path() { assert!(s.execute("text.setPathHandle", &json!({"id": id.0, "anchor": 9, "which": "out", "x": 0, "y": 0})).is_err(), "no such anchor"); assert!(s.execute("text.setPathHandle", &json!({"id": path["id"], "anchor": 0, "which": "out", "x": 0, "y": 0})).is_err(), "not type"); } + +/// `text.setFormat` refuses a number or switch given as another type, naming the key, and +/// changes nothing; null leaves the attribute as it is. +#[test] +fn set_format_refuses_a_param_of_the_wrong_type() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| (s.doc().unwrap().doc.clone(), s.doc().unwrap().history.undo.len()); + let before = state(&s); + for key in [ + "baselineShift", + "hScale", + "vScale", + "rotation", + "leftIndent", + "rightIndent", + "firstLineIndent", + "spaceBefore", + "spaceAfter", + "wordsLongerThan", + "afterFirst", + "beforeLast", + "hyphenLimit", + "hyphenationZone", + ] { + for wrong in [json!("30"), json!(true), json!([30])] { + let err = s.execute("text.setFormat", &json!({ key: wrong })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be a number, not {wrong}")), "{key}: {err}"); + } + } + for key in ["underline", "allCaps", "hyphenate", "noBreak"] { + let err = s.execute("text.setFormat", &json!({ key: "true" })).unwrap_err().to_string(); + assert!(err.contains(&format!("`{key}` must be true or false")), "{key}: {err}"); + } + let err = s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": "12"})).unwrap_err().to_string(); + assert!(err.contains("`firstLineIndent` must be a number"), "{err}"); + assert!(state(&s) == before, "a refused call changes nothing and records no undo step"); + s.execute("text.setFormat", &json!({"leftIndent": 30, "firstLineIndent": null})).unwrap(); + let NodeKind::Text(t) = &s.doc().unwrap().doc.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!((t.para_at(0).left_indent, t.para_at(0).first_line_indent), (30.0, 0.0)); +} + +/// `text.setFormat` reads a null value as one left out: a call whose attributes are all null has +/// nothing to change, and records no edit, no undo step and no new revision. +#[test] +fn set_format_with_only_null_values_changes_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + for params in [ + json!({"leftIndent": null}), + json!({"leftIndent": null, "firstLineIndent": null, "underline": null}), + json!({"kerning": null, "antiAlias": null, "list": null, "wordSpacing": null}), + json!({"mojikumi": null}), + json!({"kinsoku": null, "kinsokuType": null, "direction": null, "leadingModel": null}), + json!({"burasagari": null, "kashida": null, "singleWordJustification": null, "autoLeading": null}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": null}), + ] { + let err = s.execute("text.setFormat", ¶ms).unwrap_err().to_string(); + assert!(err.contains("nothing to change"), "{params}: {err}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A null beside a value leaves only its own attribute. + let para = |d: &vectorcraft_doc::Document| { + let NodeKind::Text(t) = &d.node(NodeId(id)).unwrap().kind else { panic!("text") }; + (t.para_at(0).mojikumi, t.para_at(0).kinsoku) + }; + let mojikumi = para(&before.0).0; + s.execute("text.setFormat", &json!({"mojikumi": null, "kinsoku": "soft"})).unwrap(); + assert_eq!(para(&s.doc().unwrap().doc), (mojikumi, vectorcraft_doc::Kinsoku::Soft)); + assert_eq!(s.doc().unwrap().history.undo.len(), before.1 + 1, "one undo step"); +} + +/// `text.setFormat` with the values the text already has succeeds and names its objects, but +/// records no edit, no undo step and no new revision. +#[test] +fn set_format_with_the_current_values_records_nothing() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let id = s.execute("text.create", &json!({"x": 10, "y": 50, "text": "One\nTwo"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [id]})).unwrap(); + let state = |s: &Session| { + let st = s.doc().unwrap(); + (st.doc.clone(), st.history.undo.len(), st.revision) + }; + let before = state(&s); + let NodeKind::Text(t) = &before.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + let hyphenate = t.para_at(0).hyphenate; + for params in [ + json!({"leftIndent": 0, "firstLineIndent": 0}), + json!({"underline": false, "strikethrough": false, "hyphenate": hyphenate}), + json!({"ids": [id], "start": 0, "end": 3, "spaceBefore": 0, "spaceAfter": 0}), + json!({"ids": [id], "start": 4, "end": 7, "leftIndent": null, "rightIndent": 0}), + ] { + let out = s.execute("text.setFormat", ¶ms).unwrap(); + assert_eq!(out["ids"], json!([id]), "{params}"); + assert!(state(&s) == before, "{params}: no edit, no undo step and no new revision"); + } + // A call that changes a value is one undo step; the same call again records nothing. + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + let changed = state(&s); + assert_eq!((changed.1, changed.2), (before.1 + 1, before.2 + 1), "one undo step and one revision"); + s.execute("text.setFormat", &json!({"leftIndent": 24})).unwrap(); + assert!(state(&s) == changed, "the same value again records nothing"); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.doc().unwrap().doc == before.0, "one undo restores the text"); + // The anti-aliasing method is compared the same way. + s.execute("text.setFormat", &json!({"antiAlias": "none"})).unwrap(); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + let strong = state(&s); + assert!(strong.1 > before.1, "a new method is an undo step"); + let NodeKind::Text(t) = &strong.0.node(NodeId(id)).unwrap().kind else { panic!("text") }; + assert_eq!(t.anti_alias, vectorcraft_doc::text::TypeAntiAlias::Strong); + s.execute("text.setFormat", &json!({"antiAlias": "strong"})).unwrap(); + assert!(state(&s) == strong, "the same method again records nothing"); +} diff --git a/crates/engine/src/cmd/variablecmds.rs b/crates/engine/src/cmd/variablecmds.rs index ad70f047bc..dc393003ac 100644 --- a/crates/engine/src/cmd/variablecmds.rs +++ b/crates/engine/src/cmd/variablecmds.rs @@ -46,9 +46,9 @@ fn selection_and_unlocked(s: &Session) -> std::result::Result<(), String> { variables_unlocked(s) } -/// Ends a typing session as [`Session::edit`] would, before a helper below compares the document, +/// Ends a typing session as [`Session::edit`] would, before a command compares the document, /// so the typing is its own undo step whether or not the command then changes anything. -fn settle_typing(s: &mut Session) -> Result<()> { +pub(super) fn settle_typing(s: &mut Session) -> Result<()> { if !s.previewing && super::typing_in_progress(s) { super::settle_interaction(s)?; } diff --git a/crates/ui-egui/src/panels/tabs.rs b/crates/ui-egui/src/panels/tabs.rs index c7b8e47828..77dd07e15f 100644 --- a/crates/ui-egui/src/panels/tabs.rs +++ b/crates/ui-egui/src/panels/tabs.rs @@ -84,11 +84,10 @@ fn append_indent_targets(node: &Node, range: std::ops::Range, targets: &m /// one frame ends and the next begins, the last paragraph of the one and the first of the next are /// pieces of one story paragraph: a paragraph that runs on into the next frame, or an empty piece /// after a frame's last paragraph break. Such a paragraph is targeted once, by its last piece with -/// text in a frame that `selected` takes, the piece whose style the story keeps when it flows again. -/// A paragraph whose text lies only in frames that `selected` leaves out is not targeted. An empty -/// paragraph takes its style from its last piece when the story flows again, so it is targeted by -/// that piece when `selected` takes its frame, and otherwise not. -fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(NodeId) -> bool, targets: &mut Vec) { +/// text, the piece whose style the story keeps when it flows again. An empty paragraph takes its +/// style from its last piece when the story flows again, so it is targeted by that piece. Locked and +/// hidden frames are targeted like the others. +fn append_story_targets(doc: &Document, story: &[NodeId], targets: &mut Vec) { /// The part of a story paragraph in one frame: paragraph `index` there, starting at byte `start`. struct Piece<'a> { id: NodeId, @@ -113,11 +112,7 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } } for pieces in ¶graphs { - let pick = if pieces.iter().any(|p| p.has_text) { - pieces.iter().rev().find(|p| p.has_text && selected(p.id)) - } else { - pieces.last().filter(|p| selected(p.id)) - }; + let pick = pieces.iter().rev().find(|p| p.has_text).or_else(|| pieces.last()); if let Some(p) = pick { targets.push(indent_target(p.id, p.text, p.index, p.start)); } @@ -125,13 +120,14 @@ fn append_story_targets(doc: &Document, story: &[NodeId], selected: impl Fn(Node } /// Paragraphs touched by the Type selection (every paragraph of the story after Select All in -/// threaded text), or all paragraphs of selected text objects. Each paragraph of a threaded story -/// is listed once, in story order ([`append_story_targets`]). +/// threaded text), or all paragraphs of selected text objects. A selected frame of a threaded story +/// selects the whole story, its locked and hidden frames included. Each paragraph of a threaded +/// story is listed once, in story order ([`append_story_targets`]). fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { let mut targets = Vec::new(); if let Some((_, a, b)) = super::character::text_editing(app) { if let (Some(story), Some(st)) = (super::character::text_story(app), app.session.active()) { - append_story_targets(&st.doc, &story, |_| true, &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } else if let NodeKind::Text(text) = &node.kind { append_indent_targets(node, text.paragraphs_in(a, b), &mut targets); } @@ -153,7 +149,7 @@ fn indent_targets(app: &VectorcraftApp, node: &Node) -> Vec { } let story = st.doc.story_of(id); done.extend(story.iter().copied()); - append_story_targets(&st.doc, &story, |f| taken.contains(&f), &mut targets); + append_story_targets(&st.doc, &story, &mut targets); } } targets @@ -243,9 +239,9 @@ fn paint_markers(ui: &mut Ui, painter: &egui::Painter, tokens: &Tokens, view: Ma } } -/// Apply an absolute ruler position through paragraph-format commands. Paragraphs that need values -/// of their own get one command each, the story's last paragraph first: threaded text re-flows -/// after each command, and a re-flow moves only text after the paragraph that changed. +/// Apply an absolute ruler position through paragraph-format commands. When the paragraphs need +/// values of their own, one command sets each paragraph's ([`set_each_indent`]), and threaded text +/// flows again once. fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: &ParaStyle, span: f64, targets: &[IndentTarget], both: bool) { if targets.is_empty() || !target.is_finite() { return; @@ -253,23 +249,10 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: let Some(target) = bounded_marker_position(marker, target, span, targets, both) else { return }; if marker == IndentMarker::Upper { if targets.iter().all(|p| p.left == para.left_indent) { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", json!({"firstLineIndent": target - para.left_indent})) { - app.status(e); - } + run_on_targets(app, targets, json!({"firstLineIndent": target - para.left_indent})); } else { - super::character::end_typing(app); - let commands: Vec = targets - .iter() - .rev() - .map(|p| { - json!({"command": "text.setFormat", "params": { - "ids": [p.id], "start": p.start, "end": p.start, "firstLineIndent": target - p.left - }}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("First-line Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, None, Some(target - p.left))).collect(); + set_each_indent(app, tl!("First-line Left Indent"), changes); } } else { // The ruler shows each paragraph's left indent with its list level's added; the paragraph @@ -284,26 +267,58 @@ fn set_marker(app: &mut VectorcraftApp, marker: IndentMarker, target: f64, para: _ => None, }; if let Some(params) = params { - if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { - app.status(e); - } + run_on_targets(app, targets, params); return; } - super::character::end_typing(app); - let commands: Vec = targets - .iter() - .rev() - .map(|p| { - let mut params = json!({"ids": [p.id], "start": p.start, "end": p.start, "leftIndent": target - p.level}); - if !both { - params["firstLineIndent"] = json!(p.left + p.first_line - target); - } - json!({"command": "text.setFormat", "params": params}) - }) - .collect(); - if let Err(e) = app.run("command.batch", json!({"label": tl!("Left Indent"), "commands": commands})) { - app.status(e); - } + let changes = targets.iter().map(|p| (p, Some(target - p.level), (!both).then_some(p.left + p.first_line - target))).collect(); + set_each_indent(app, tl!("Left Indent"), changes); + } +} + +/// Run one `text.setFormat` with `params` on the paragraphs the Paragraph panel acts on while typing, +/// and with the Selection tool on every type object of `targets`, so a selected frame of a threaded +/// story sets the whole story ([`indent_targets`]). +fn run_on_targets(app: &mut VectorcraftApp, targets: &[IndentTarget], mut params: Value) { + if super::character::text_editing(app).is_none() { + let mut seen = HashSet::new(); + params["ids"] = json!(targets.iter().map(|p| p.id).filter(|id| seen.insert(*id)).collect::>()); + } + if let Err(e) = super::paragraph::run_on_paragraphs(app, "text.setFormat", params) { + app.status(e); + } +} + +/// Give each paragraph its own left and first-line indents (None: as it is) in one `text.setFormat` +/// with `paragraphIndents`, one undo step named `label`. Paragraphs that keep their indents are +/// left out, and when every paragraph keeps them nothing is run. +fn set_each_indent(app: &mut VectorcraftApp, label: &str, changes: Vec<(&IndentTarget, Option, Option)>) { + // As `text.setFormat` keeps a paragraph's indents, within 1296 pt either way. + let bound = |v: Option| v.map(|v| v.clamp(-1296.0, 1296.0)); + let entries: Vec = changes + .into_iter() + .filter_map(|(p, left, first_line)| { + let (left, first_line) = (bound(left), bound(first_line)); + let own_left = p.left - p.level; + if left.is_none_or(|v| v == own_left) && first_line.is_none_or(|v| v == p.first_line) { + return None; + } + let mut entry = json!({"id": p.id, "start": p.start}); + if let Some(v) = left { + entry["leftIndent"] = json!(v); + } + if let Some(v) = first_line { + entry["firstLineIndent"] = json!(v); + } + Some(entry) + }) + .collect(); + if entries.is_empty() { + return; + } + super::character::end_typing(app); + let command = json!({"command": "text.setFormat", "params": {"paragraphIndents": entries}}); + if let Err(e) = app.run("command.batch", json!({"label": label, "commands": [command]})) { + app.status(e); } } diff --git a/crates/ui-egui/src/panels/tabs/tests.rs b/crates/ui-egui/src/panels/tabs/tests.rs index 97e8c8a00a..d6bcef15f1 100644 --- a/crates/ui-egui/src/panels/tabs/tests.rs +++ b/crates/ui-egui/src/panels/tabs/tests.rs @@ -617,6 +617,52 @@ fn indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_p } } +/// A marker drag over many paragraphs of a threaded story with mixed indents sends one +/// `text.setFormat` for all of them, in one undo step, and a drag that changes no paragraph runs +/// nothing. +#[test] +fn indent_markers_over_many_threaded_paragraphs_send_one_format_command() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 900, "height": 3000})).unwrap(); + let story = (1..=128).map(|i| format!("p{i}")).collect::>().join("\n"); + let a = app.run("text.create", json!({"x": 10, "y": 10, "text": story, "size": 12, "area": {"width": 150, "height": 100}})).unwrap()["id"] + .as_u64() + .unwrap(); + let b = app.run("text.thread.add", json!({"from": a, "rect": [300, 10, 200, 2900]})).unwrap()["id"].as_u64().unwrap(); + app.run("text.setFormat", json!({"ids": [b], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a, b]})).unwrap(); + let before = story_paragraphs(&app, &[a, b]); + assert_eq!(before.len(), 128, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let steps = |app: &VectorcraftApp| (app.session.active().unwrap().history.undo.len(), app.session.journal.len()); + let drag = |app: &mut VectorcraftApp, marker, to| { + let (node, para, span) = current_para(app).unwrap(); + let targets = indent_targets(app, &node); + assert_eq!(targets.len(), 128); + set_marker(app, marker, to, ¶, span, &targets, false); + }; + let (undo, journal) = steps(&app); + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1)); + let (name, params) = app.session.journal.last().cloned().unwrap(); + assert_eq!(name, "command.batch"); + let commands = params["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "one command for every paragraph"); + assert_eq!(commands[0]["command"], "text.setFormat"); + assert_eq!(commands[0]["params"]["paragraphIndents"].as_array().unwrap().len(), 128); + let after = story_paragraphs(&app, &[a, b]); + for (was, now) in before.iter().zip(&after) { + assert_eq!((&now.0, now.1, now.2), (&was.0, was.1, 60.0), "{after:?}"); + } + // The same position again changes no paragraph. + drag(&mut app, IndentMarker::Upper, 60.0); + assert_eq!(steps(&app), (undo + 1, journal + 1), "nothing ran"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), before); + app.run("edit.redo", json!({})).unwrap(); + assert_eq!(story_paragraphs(&app, &[a, b]), after); +} + /// After Select All in threaded text, a paragraph split between two frames is one target. #[test] fn indent_markers_after_select_all_list_a_split_paragraph_once() { @@ -633,12 +679,12 @@ fn indent_markers_after_select_all_list_a_split_paragraph_once() { assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); } -/// With the Selection tool on some frames of a threaded story, only the paragraphs with text in -/// those frames are listed, each once: a selected frame that starts inside a paragraph lists that -/// paragraph, and so does a selected frame that ends inside one. Text in a selected group is -/// listed with the story's frames, and an empty text object keeps its one paragraph. +/// With the Selection tool on any frame of a threaded story, the indent markers list every +/// paragraph of the story, each once in story order: a selected story is one text. Text in a +/// selected group is listed with the story's frames, and an empty text object keeps its one +/// paragraph. #[test] -fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { +fn indent_markers_on_some_threaded_frames_list_the_whole_story_once() { let (mut app, [a, b, c]) = split_story_app(); let count = |app: &VectorcraftApp, id: u64| text_of(app, id).paragraph_count(); let targets_of = |app: &mut VectorcraftApp, ids: Vec| { @@ -646,29 +692,64 @@ fn indent_markers_on_some_threaded_frames_list_their_paragraphs_once() { let (node, _, _) = current_para(app).unwrap(); story_positions(&indent_targets(app, &node), &[a, b, c]) }; - // The continuing frame alone: its first paragraph is the one the frame before ends inside. - let only_b = targets_of(&mut app, vec![b]); - assert_eq!(only_b.len(), count(&app, b), "{only_b:?}"); - assert_eq!(only_b.first(), Some(&(1, 0)), "{only_b:?}"); - let only_a = targets_of(&mut app, vec![a]); - assert_eq!(only_a.len(), count(&app, a), "{only_a:?}"); - // Two frames that share a paragraph list it once, by the later frame's piece. - let both = targets_of(&mut app, vec![c, a, b]); - assert_eq!(both.len(), count(&app, a) + count(&app, b) + count(&app, c) - 2, "{both:?}"); - assert!(both.windows(2).all(|w| w[0] < w[1]), "{both:?}"); - // The first and last frames of the story, and a grouped empty text object besides. + let story = count(&app, a) + count(&app, b) + count(&app, c) - 2; + let all = targets_of(&mut app, vec![c, a, b]); + assert_eq!(all.len(), story, "two frames that share a paragraph list it once: {all:?}"); + assert!(all.windows(2).all(|w| w[0] < w[1]), "{all:?}"); + for frame in [a, b, c] { + assert_eq!(targets_of(&mut app, vec![frame]), all, "frame {frame} alone lists the whole story"); + } + // A grouped empty text object besides the last frame of the story. let empty = app.run("text.create", json!({"x": 10, "y": 900, "text": "", "area": {"width": 100, "height": 40}})).unwrap()["id"].as_u64().unwrap(); app.run("select.set", json!({"ids": [empty, c]})).unwrap(); let group = app.run("object.group", json!({})).unwrap()["id"].as_u64().unwrap(); - app.run("select.set", json!({"ids": [group, a]})).unwrap(); - let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(a)).unwrap().clone(); + app.run("select.set", json!({"ids": [group]})).unwrap(); + let node = app.session.active().unwrap().doc.node(vectorcraft_doc::NodeId(c)).unwrap().clone(); let targets = indent_targets(&app, &node); let ids: Vec = targets.iter().map(|p| p.id).collect(); assert_eq!(ids.iter().filter(|id| **id == empty).count(), 1, "{ids:?}"); - let story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); - let positions = story_positions(&story, &[a, b, c]); - assert_eq!(positions.len(), count(&app, a) + count(&app, c), "{positions:?}"); - assert!(positions.windows(2).all(|w| w[0] < w[1]), "{positions:?}"); + let in_story: Vec = targets.into_iter().filter(|p| p.id != empty).collect(); + assert_eq!(story_positions(&in_story, &[a, b, c]), all); +} + +/// A marker drag on one selected frame of a threaded story sets every paragraph of the story, in +/// its locked and hidden frames too, in one undo step, whether the paragraphs share their indents +/// or have their own. +#[test] +fn indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames() { + let (mut app, [a, b, c]) = split_story_app(); + app.run("select.set", json!({"ids": [b]})).unwrap(); + app.run("object.lock", json!({})).unwrap(); + app.run("select.set", json!({"ids": [c]})).unwrap(); + app.run("object.hide", json!({})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + // Every paragraph has the same indents. + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 30.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let shared = story_paragraphs(&app, &[a, b, c]); + assert_eq!(shared.len(), 12, "{shared:?}"); + assert!(shared.iter().all(|p| (p.1, p.2) == (0.0, 30.0)), "{shared:?}"); + // The paragraphs of the hidden frame have a left indent of their own. + app.run("text.setFormat", json!({"ids": [c], "leftIndent": 10})).unwrap(); + app.run("select.set", json!({"ids": [a]})).unwrap(); + let before = story_paragraphs(&app, &[a, b, c]); + assert_eq!(before.len(), 12, "{before:?}"); + assert!(before.iter().any(|p| p.1 == 0.0) && before.iter().any(|p| p.1 == 10.0), "{before:?}"); + let (node, para, span) = current_para(&app).unwrap(); + let targets = indent_targets(&app, &node); + assert_eq!(targets.len(), 12); + let undo = app.session.active().unwrap().history.undo.len(); + set_marker(&mut app, IndentMarker::Upper, 60.0, ¶, span, &targets, false); + assert_eq!(app.session.active().unwrap().history.undo.len(), undo + 1); + let after = story_paragraphs(&app, &[a, b, c]); + assert_eq!(after.iter().map(|p| &p.0).collect::>(), before.iter().map(|p| &p.0).collect::>()); + for (was, now) in before.iter().zip(&after) { + assert_eq!((now.1, now.2), (was.1, 60.0), "{} keeps its left indent and starts at 60 pt: {after:?}", now.0); + } } #[test] diff --git a/docs/mcp.md b/docs/mcp.md index 62a4212472..572100189a 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -8236,6 +8236,28 @@ stops, the command changes nothing and records no undo step. `text.tabs.set` rej holds at least that many, so stops can be removed from a paragraph that holds more. It sorts the stops by position and keeps the first of stops at one position. +`text.setFormat {paragraphIndents: [{id, start, leftIndent?, firstLineIndent?}…]}` gives each paragraph indents of its +own in one call: each entry names one paragraph of a type object by the byte `start` where the paragraph begins, and +sets its `leftIndent`, its `firstLineIndent` or both, in points from -1296 to 1296. The call takes no other key, and +at most 100000 entries. It checks every entry before it changes anything, and fails on an empty list, an unknown key, an +entry with neither indent, a value that is not a number in range, an `id` that is not a type object, a `start` where no +paragraph of that object begins, and two entries for one paragraph with other values (the same entry twice counts once). +The whole change is one edit: each type object's paragraph styles are rebuilt once, and a threaded story flows through +its frames again once, however many of its paragraphs change, where one `text.setFormat {ids, start, end}` for each +paragraph flows it again after every paragraph. When no paragraph's indents change, the command records no edit and no +undo step. It returns `{ids}`, the type objects the entries name. The Tabs panel's indent markers use it when the +paragraphs they move have indents of their own, inside a `command.batch` that names the undo step after the marker. + +```json +{"name":"run_command","arguments":{"command":"text.setFormat","params":{"paragraphIndents":[{"id":12,"start":0,"firstLineIndent":36},{"id":12,"start":40,"leftIndent":24,"firstLineIndent":12}]}}} +``` + +The single-value form of `text.setFormat` fails, naming the key, when a number such as `leftIndent` or `baselineShift` is +given as a string, a switch or a list, or a switch such as `underline` as anything but true or false; it changes nothing +then. A null value of any attribute leaves that attribute as it is, as an omitted one does, so a call whose attributes +are all null fails with "nothing to change" and records no edit and no undo step. A call whose values are the ones the +text already has succeeds and returns its `ids`, but records no edit and no undo step. + `text.tabs.get {start?}` and `paraStyle.new` / `paraStyle.redefine {id, start?}` read the paragraph at `start`; `paraStyle.list` counts paragraphs as uses, and `charStyle.list` counts runs; both count the type in the layers, opacity masks, symbols, graph designs, pattern tiles and the art of brushes. `charStyle.unused` and diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 5ec97e34c1..570a44e8c8 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -657,7 +657,7 @@ since 2026-10-01. - **Names of new styles and symbols:** `charStyle.new` and `paraStyle.new` trim the white space around the name they are given, as `charStyle.rename` and `paraStyle.rename` do, and refuse a name that is then empty; Duplicate names its copy from the trimmed name. `symbol.new` and `symbol.duplicate` trim the name as Symbol Options does, and `library.use` adds a library's text style under its trimmed name. Public reference: [Character and paragraph styles](https://web.archive.org/web/20260825113735/https://helpx.adobe.com/illustrator/using/character-paragraph-styles.html). - **Naming a new text style first:** Alt-click on New in the Character Styles or Paragraph Styles panel opens New Character Style or New Paragraph Style (`ui.newCharStyle`, `ui.newParaStyle`) with the name a plain click's style gets; OK makes the style from the text selected when the dialog opened, and Cancel adds none. Tests `panels::text_styles::tests::alt_clicking_new_character_style_asks_for_its_name` and `alt_clicking_new_paragraph_style_asks_for_its_name`. Public reference: [Default keyboard shortcuts](https://web.archive.org/web/20260726060015/https://helpx.adobe.com/illustrator/using/default-keyboard-shortcuts.html), which lists Alt-click for opening panel options and names Actions, Brushes, Swatches and Symbols as exceptions. - **Moving all tab stops:** in the Tabs panel, a stop dragged on the ruler with Ctrl held (Command on macOS) moves every stop by the same distance, in one `text.tabs.set` and one undo step; the stops keep their order, alignment and leaders, the ruler shows them moving during the drag, and a drag that would take the leftmost stop below 0 leaves it at 0, or the rightmost past 100,000 pt leaves it at 100,000 pt, with the other stops at their spacing from it. A drag that leaves every stop where it was records no undo step. Ctrl must be held when the drag starts. Tests `panels::tabs::tests::command_drag_moves_all_the_stops_together`, `command_drag_stops_at_zero_and_keeps_the_spacing`, `command_drag_stops_at_the_largest_position_and_keeps_the_spacing` and `a_drag_that_moves_no_stop_records_no_undo_step`. Public reference: [Manage tab stops](https://helpx.adobe.com/illustrator/desktop/measure-and-align/grids-and-guides/manage-tab-stops.html), which says that a Ctrl-drag (Windows) or Command-drag (macOS) of a tab moves all the tab stops at the same time. -- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat` for each paragraph of the story, from its last paragraph to its first, whatever order the frames were selected in with the Selection tool; a paragraph that runs on from one frame into the next gets one step, after Type tool Select All too. With the Selection tool only the paragraphs with text in a selected frame change, so a frame selected alone that starts or ends inside a paragraph sets that paragraph. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_after_select_all_list_a_split_paragraph_once` and `indent_markers_on_some_threaded_frames_list_their_paragraphs_once`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). +- **Tabs indent markers on threaded frames:** when a marker drag gives the paragraphs of a threaded story values of their own, the Tabs panel sends one `text.setFormat {paragraphIndents}` with an entry for each paragraph of the story, whatever order the frames were selected in with the Selection tool, so the story flows through its frames again once rather than once for each paragraph, and a drag that changes no paragraph runs nothing; a paragraph that runs on from one frame into the next gets one entry, after Type tool Select All too. A selected story is one text: with the Selection tool, a marker drag on any of its frames sets every paragraph of the story, in its locked and hidden frames too. Tests `panels::tabs::tests::indent_markers_on_selected_threaded_frames_follow_the_story_and_set_a_split_paragraph_once`, `indent_markers_over_many_threaded_paragraphs_send_one_format_command`, `indent_markers_after_select_all_list_a_split_paragraph_once`, `indent_markers_on_some_threaded_frames_list_the_whole_story_once`, `indent_markers_on_one_threaded_frame_set_its_locked_and_hidden_frames`, `cmd::threads::tests::paragraph_indents_flow_a_threaded_story_once_as_one_command_per_paragraph_would`, `paragraph_indents_over_thousands_of_paragraphs_flow_the_story_once` and `paragraph_indents_check_every_entry_and_skip_a_change_that_changes_nothing`, and `cmd::typecmd::area_tests::set_format_refuses_a_param_of_the_wrong_type`. Public reference: [Indent text](https://helpx.adobe.com/illustrator/desktop/design-with-text/edit-format-text/indent-text.html). - **Width Point Edit:** Shift-double-click enables Adjust Adjoining Width Points; plain double-click and direct opening retain the default. Tool dispatch and headless dialog tests cover neighboring widths, Undo/Redo and Cancel. Public reference: [Variable-width strokes](https://helpx.adobe.com/illustrator/using/stroke-object.html). Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a feature lands.