From e04ef2b4a848e30a6e62d56f3a8df0afbb287e39 Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Fri, 23 Oct 2026 21:53:23 -0700 Subject: [PATCH] Text: AutoFormat starts bulleted and numbered lists as you type MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A paragraph that starts with *, -, --, >, <>, 1., 1), I., i., A., a. or a) followed by a space or tab now becomes a bulleted or numbered list in that style through format.bullets or format.numbering, and the typed lead and space are removed. The keystroke is one undo step. Backspace right after the change puts the typed text and the paragraph's formatting back. Tools ▸ AutoCorrect has "Automatic bulleted and numbered lists" to turn it off, and text.autocorrect takes and reports it as lists. --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 92 ++++++++++++++++++++++++++++++++++- crates/engine/src/lib.rs | 9 +++- crates/engine/src/tests.rs | 65 +++++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 1 + docs/gaps.md | 8 +-- 6 files changed, 170 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d54c160f..040fcae6 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 | 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 | | 2026-10-18 | Find (Ctrl+F): "Find what:" has the keyboard as soon as the dialog opens, and keeps it after Enter finds a match, so the search can be typed and changed without clicking the field (#951) | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index c681ac76..a7d0b71d 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 ), @@ -631,6 +631,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()) { @@ -679,6 +686,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 { @@ -726,6 +810,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; @@ -772,7 +857,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, })) } @@ -1026,6 +1111,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 35b0ab39..f9717717 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3388,6 +3388,71 @@ 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())); + // Backspace right after puts the lead and the space back, with the paragraph as it was. + assert_eq!(typed(&mut s, "* "), (String::new(), ch("•"))); + 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 efe8a07f..1b1fed12 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2121,6 +2121,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 ad620c50..7a6bc668 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -261,9 +261,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. @@ -358,6 +359,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 |