diff --git a/ROADMAP.md b/ROADMAP.md index 73b62ee6..efa9a5eb 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -134,6 +134,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-24 | AutoFormat as you type: a paragraph that starts with `*`, `-`, `--`, `>`, `<>`, `1.`, `1)`, `I.`, `i.`, `A.`, `a.` or `a)` and a space or tab becomes a bulleted or numbered list in that style; Backspace right after puts the typed text back, and Tools ▸ AutoCorrect and `text.autocorrect {lists}` turn it off | | 2026-10-23 | Chart text colour: `chart.options {textColor}` and Font Color on a selected chart colour its title, axes, legend and data labels; the colour is written to the chart part (`c:txPr` on the axes, legend, labels and chart, the title's run defaults) so an imported chart keeps its `txPr` colours through an edit and a save; text without a colour of its own takes PowerPoint's default, the theme's text colour at 65 % plus 35 % (595959 on a light theme, light grey on a dark one) | | 2026-10-23 | Format Background ▸ Apply to All also sets the background of the slide masters the slides use (and clears their layouts' overrides), so new slides keep it (#891) | | 2026-10-22 | Table text takes the master's other-text style (`otherStyle`), shapes and text boxes the presentation's default text style, as PowerPoint for Mac draws them (synthetic probe: 14 pt cells under a 14 pt `otherStyle` and a 26 pt `defaultTextStyle`); a private deck's table had been drawn 20 pt instead of 18 | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 5e7047f8..a0c3474f 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -31,7 +31,7 @@ pub fn specs() -> Vec { "AutoCorrect Options", ["Tools"], None, - "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list", + "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?, lists?: bool, add?: [from, to], remove?: from} → the options and the replacement list", always, autocorrect_options ), @@ -426,6 +426,8 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) c })?; autocorrect(s, text.chars().count() == 1)?; + // AutoCorrect can move the caret: a replaced word, or a list lead it removed. + let pos = s.doc()?.selection.text.as_ref().map_or(pos, |t| t.caret); Ok(json!({"caret": [pos.0, pos.1]})) } @@ -632,6 +634,13 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) { return Ok(()); } + if typing && s.prefs.ac_lists { + // A lead such as `i.` or `a.` is kept as typed, so the space after it starts its list. + let lead: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); + if auto_list_style(&lead).is_some() || start_auto_list(s, &t, &line, end)? { + return Ok(()); + } + } let mut word_end = end - 1; let mut start = word_end; while start > 0 && line.get(start - 1).is_some_and(|c| !c.is_whitespace()) { @@ -680,6 +689,83 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { Ok(()) } +/// A list AutoFormat started: the text being edited right after it, the typed lead with its +/// space, the paragraph's own formatting before, and the revision the change left. +#[derive(Clone, Debug)] +pub struct AutoList { + sel: TextSel, + typed: String, + props: deckcraft_model::text::ParaProps, + revision: u64, +} + +/// The list a paragraph that starts with `lead` and a space or tab becomes, as the command and +/// parameters that make it: `*` •, `-` or `–` –, `--` or `—` ■, `>` ➢, `<>` ◆, and the numbering +/// styles `1.`, `1)`, `I.`, `i.`, `A.`, `a.` and `a)`. +fn auto_list_style(lead: &str) -> Option<(&'static str, Value)> { + let bullet = |c: &str| Some(("format.bullets", json!({"on": true, "char": c}))); + let number = |scheme: &str| Some(("format.numbering", json!({"on": true, "scheme": scheme}))); + match lead { + "*" => bullet("•"), + "-" | "–" => bullet("–"), + "--" | "—" => bullet("■"), + ">" => bullet("➢"), + "<>" => bullet("◆"), + "1." => number("arabicPeriod"), + "1)" => number("arabicParenR"), + "I." => number("romanUcPeriod"), + "i." => number("romanLcPeriod"), + "A." => number("alphaUcPeriod"), + "a." => number("alphaLcPeriod"), + "a)" => number("alphaLcParenR"), + _ => None, + } +} + +/// AutoFormat's automatic bulleted and numbered lists: when the caret's paragraph holds only a +/// list lead (`*`, `1.`…) before the space or tab just typed, the lead and the space are removed +/// and the paragraph becomes a list paragraph in that style. `false` when nothing changed. +fn start_auto_list(s: &mut Session, t: &TextSel, line: &[char], end: usize) -> Result { + if t.is_range() || !line.get(end - 1).is_some_and(|c| *c == ' ' || *c == '\t') { + return Ok(false); + } + let lead: String = line.get(..end - 1).map(|l| l.iter().collect()).unwrap_or_default(); + let Some((id, params)) = auto_list_style(&lead) else { return Ok(false) }; + let typed: String = line.get(..end).map(|l| l.iter().collect()).unwrap_or_default(); + let para = t.caret.0; + let props = body_of(s.doc()?, t).and_then(|b| b.paragraphs.get(para)).map(|p| p.props.clone()).unwrap_or_default(); + edit_text(s, |body, tt| { + ed::delete(body, (para, 0), (para, end)); + tt.anchor = (para, 0); + tt.caret = (para, 0); + })?; + s.execute(id, ¶ms)?; + let st = s.doc_mut()?; + if let Some(sel) = st.selection.text.clone() { + st.auto_list = Some(AutoList { sel, typed, props, revision: st.revision }); + } + Ok(true) +} + +/// Backspace right after AutoFormat started a list: the paragraph gets back the typed lead and +/// space and the formatting it had. `false` when the last change was anything else. +fn undo_auto_list(s: &mut Session) -> Result { + let st = s.doc_mut()?; + let Some(a) = st.auto_list.take() else { return Ok(false) }; + if a.revision != st.revision || st.selection.text.as_ref() != Some(&a.sel) { + return Ok(false); + } + edit_text(s, |body, t| { + if let Some(p) = body.paragraphs.get_mut(a.sel.caret.0) { + p.props = a.props.clone(); + } + let c = ed::insert(body, a.sel.caret, &a.typed, None); + t.anchor = c; + t.caret = c; + })?; + Ok(true) +} + /// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat /// superscripts it. fn is_ordinal(word: &str) -> bool { @@ -727,6 +813,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { ("capsLock", &mut pr.ac_caps_lock), ("fractions", &mut pr.ac_fractions), ("ordinals", &mut pr.ac_ordinals), + ("lists", &mut pr.ac_lists), ] { if let Some(v) = p.get(key).and_then(Value::as_bool) { *slot = v; @@ -773,7 +860,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { Ok(json!({ "on": pr.autocorrect, "twoInitials": pr.ac_two_initials, "sentenceCaps": pr.ac_sentence_caps, "days": pr.ac_days, "capsLock": pr.ac_caps_lock, "fractions": pr.ac_fractions, - "ordinals": pr.ac_ordinals, "entries": list, + "ordinals": pr.ac_ordinals, "lists": pr.ac_lists, "entries": list, })) } @@ -1028,6 +1115,9 @@ fn insert(s: &mut Session, p: &Value) -> Result { } pub(crate) fn delete_dir(s: &mut Session, dir: &str) -> Result { + if dir == "backward" && undo_auto_list(s)? { + return ok(); + } let dir = dir.to_string(); edit_text(s, move |body, t| { let (a, b) = t.ordered(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 54380bd1..ff478e8e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -225,6 +225,9 @@ pub struct DocState { /// The last Duplicate: where it happened and each original with its copy, so duplicating the /// copies again repeats how far they were moved, as in PowerPoint (#966). pub last_duplicate: Option<(usize, Target, Vec<(ShapeId, ShapeId)>)>, + /// The list AutoFormat just started from a typed `* ` or `1. `, so Backspace right after + /// can put the typed text back. + pub auto_list: Option, } static NEXT_UID: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); @@ -253,6 +256,7 @@ impl DocState { composition: None, compare: None, last_duplicate: None, + auto_list: None, } } pub fn is_dirty(&self) -> bool { @@ -328,13 +332,15 @@ pub struct Prefs { pub smart_quotes: bool, /// AutoCorrect options (Tools ▸ AutoCorrect): fix TWo INitial CApitals, capitalize the first /// letter of sentences and the names of days, fix accidental cAPS lOCK, and replace 1/2, 1/4 - /// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) as you type. + /// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) and starts + /// bulleted and numbered lists (`* `, `1. `) as you type. pub ac_two_initials: bool, pub ac_sentence_caps: bool, pub ac_days: bool, pub ac_caps_lock: bool, pub ac_fractions: bool, pub ac_ordinals: bool, + pub ac_lists: bool, /// Your own "replace … with …" entries; they win over the built-in list. pub ac_entries: Vec<(String, String)>, /// Built-in entries you deleted. @@ -360,6 +366,7 @@ impl Default for Prefs { ac_caps_lock: true, ac_fractions: true, ac_ordinals: true, + ac_lists: true, ac_entries: Vec::new(), ac_removed: Vec::new(), author: "Presenter".into(), diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index fc5cd2c9..54ab24df 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3580,6 +3580,10 @@ fn autocorrect_options_as_you_type() { }; assert_eq!(typed(&mut s, "hello world. see you monday, i think. "), "Hello world. See you Monday, I think. "); assert_eq!(typed(&mut s, "THe tHIS 1/2 recieved... "), "The This ½ received… "); + // `text.insert` reports the caret after the correction. + assert_eq!(typed(&mut s, "(c)"), "(c)"); + assert_eq!(s.execute("text.insert", &json!({"text": " "})).unwrap()["caret"], json!([0, 2])); + assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "© "); // Options turn rules off. s.execute("text.autocorrect", &json!({"sentenceCaps": false, "days": false})).unwrap(); assert_eq!(typed(&mut s, "hello monday "), "hello monday "); @@ -3633,6 +3637,74 @@ fn autocorrect_superscripts_ordinals_as_you_type() { assert_eq!(typed(&mut s, "2nd "), "2nd "); } +#[test] +fn autoformat_starts_bulleted_and_numbered_lists_as_you_type() { + use deckcraft_model::text::Bullet; + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + let para = |s: &Session| { + let st = s.doc().unwrap(); + let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap(); + let p = sh.text.as_ref().unwrap().paragraphs[0].clone(); + (p.text(), p.props.bullet) + }; + // Clear the title (and its paragraph formatting) and type `text` one character at a time. + let typed = |s: &mut Session, text: &str| { + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).ok(); + s.execute("text.delete", &json!({"dir": "backward"})).ok(); + s.execute("format.bullets", &json!({"on": false})).unwrap(); + for c in text.chars() { + s.execute("text.insert", &json!({"text": c.to_string()})).unwrap(); + } + para(s) + }; + let ch = |c: &str| Some(Bullet::Char { char: c.into() }); + let num = |scheme: &str| Some(Bullet::AutoNum { scheme: scheme.into(), start_at: 1 }); + for (text, bullet) in [ + ("* x", ch("•")), + ("- x", ch("–")), + ("– x", ch("–")), + ("-- x", ch("■")), + ("— x", ch("■")), + ("> x", ch("➢")), + ("<> x", ch("◆")), + ("*\tx", ch("•")), + ("1. x", num("arabicPeriod")), + ("1) x", num("arabicParenR")), + ("I. x", num("romanUcPeriod")), + ("i. x", num("romanLcPeriod")), + ("A. x", num("alphaUcPeriod")), + ("a. x", num("alphaLcPeriod")), + ("a) x", num("alphaLcParenR")), + ] { + assert_eq!(typed(&mut s, text), ("x".to_string(), bullet), "{text:?}"); + } + // Only a lead at the start of the paragraph, and only before a space or tab. + let none = Some(Bullet::None); + assert_eq!(typed(&mut s, "9 * x"), ("9 * x".to_string(), none.clone())); + assert_eq!(typed(&mut s, "*, x"), ("*, x".to_string(), none.clone())); + assert_eq!(typed(&mut s, "2. x"), ("2. x".to_string(), none.clone())); + assert_eq!(typed(&mut s, "b) x"), ("b) x".to_string(), none.clone())); + // The space that starts the list reports the caret at the start of the emptied paragraph. + assert_eq!(typed(&mut s, "*"), ("*".to_string(), none.clone())); + assert_eq!(s.execute("text.insert", &json!({"text": " "})).unwrap()["caret"], json!([0, 0])); + assert_eq!(para(&s), (String::new(), ch("•"))); + // Backspace right after puts the lead and the space back, with the paragraph as it was. + s.execute("text.delete", &json!({"dir": "backward"})).unwrap(); + assert_eq!(para(&s), ("* ".to_string(), none.clone())); + s.execute("text.insert", &json!({"text": "x"})).unwrap(); + assert_eq!(para(&s), ("* x".to_string(), none.clone())); + // After anything else Backspace deletes as usual. + assert_eq!(typed(&mut s, "1. xy"), ("xy".to_string(), num("arabicPeriod"))); + s.execute("text.delete", &json!({"dir": "backward"})).unwrap(); + assert_eq!(para(&s), ("x".to_string(), num("arabicPeriod"))); + // The AutoCorrect option turns it off. + assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["lists"], true); + s.execute("text.autocorrect", &json!({"lists": false})).unwrap(); + assert_eq!(typed(&mut s, "* x"), ("* x".to_string(), none)); +} + #[test] fn theme_variants_apply_and_report_the_current_one() { let mut s = session(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index e0fa02e9..8e2508d3 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2211,6 +2211,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(&mut pr.ac_caps_lock, "Correct accidental use of cAPS LOCK key"); ui.checkbox(&mut pr.ac_fractions, "Replace fractions (1/2) with fraction characters (½)"); ui.checkbox(&mut pr.ac_ordinals, "Ordinals (1st) with superscript"); + ui.checkbox(&mut pr.ac_lists, "Automatic bulleted and numbered lists"); ui.checkbox(&mut pr.autocorrect, "Replace text as you type"); ui.add_space(4.0); let mut from = d.get("from", ""); diff --git a/docs/gaps.md b/docs/gaps.md index b287bc33..07634616 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -260,9 +260,10 @@ and starts at that effect. The pane has an advanced timeline with draggable bars installed (always on Windows and the web) only a short list of common misspellings is found. `review.thesaurus` looks up the selected word (checklist row partial). AutoCorrect has PowerPoint's options (two initial capitals, sentence and day capitals, caps lock, fractions, an - editable replace list), and AutoFormat superscripts ordinals (1st) as you type; no automatic - bulleted lists, no undo of a single correction. -- **Estimate:** 1–2 h (bundle openly licensed dictionaries); 2–4 h (AutoFormat as you type). + editable replace list), and AutoFormat superscripts ordinals (1st) and starts bulleted and + numbered lists (`* `, `1. `, Backspace puts the typed text back) as you type; no undo of a + single correction. +- **Estimate:** 1–2 h (bundle openly licensed dictionaries). ### Equations - **Evidence:** checklist row missing; PR #55 (OMML editor, +10.5k lines) is open. @@ -357,6 +358,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-24 | trivial | AutoFormat starts bulleted and numbered lists as you type (`* `, `-- `, `> `, `1. `, `a) ` and the rest), with an AutoCorrect option to turn it off | | 2026-10-22 | minor | Animation Pane ▸ Play From starts the preview at the selected effect; gap 10 keeps the effect gallery (7–13 h) | | 2026-10-20 | trivial | Explicit empty chart number-format sections hide their values; blank whole-code General fallback is retained. Synthetic formatter regressions; chart-element scores and readiness estimates unchanged | | 2026-10-18 | minor | Native chart payloads use referenced ZIP members and share media byte/item limits; old inline sidecars remain readable. Synthetic tests cover multi-megabyte layout, all shape owners, shared budgets, missing/corrupt members and invalid references. Scores and milestone scope unchanged |