From 367c3488c5ed5fd571161fbad476f6fb10eb4e22 Mon Sep 17 00:00:00 2001 From: Marco Arnulfo Date: Fri, 16 Oct 2026 02:06:42 +0200 Subject: [PATCH] Text: AutoFormat superscripts ordinals (1st, 22nd) as you type --- ROADMAP.md | 1 + crates/engine/src/cmd/text.rs | 49 ++++++++++++++++++++++++++++++++--- crates/engine/src/lib.rs | 4 ++- crates/engine/src/tests.rs | 31 ++++++++++++++++++++++ crates/ui-egui/src/dialogs.rs | 1 + docs/gaps.md | 5 ++-- 6 files changed, 85 insertions(+), 6 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8ea976e6..a71776ab 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ app's JSON control channel (`deckcraft --control PORT`). | Date | What landed | |---|---| +| 2026-10-16 | AutoFormat as you type: ordinals (1st, 22nd, 103rd) get superscript suffixes after a space or punctuation; Tools ▸ AutoCorrect and `text.autocorrect {ordinals}` turn it off | | 2026-10-15 | PDF export and rendering: `anchorCtr` centres the drawn text block — right-aligned text no longer ran past the box edge (#736) | | 2026-10-14 | connected MCP rejects unrelated, malformed, incomplete and oversized replies without replay; buffered unsolicited bytes force a fresh connection before sending (#718) | | 2026-10-14 | Empty bulleted and numbered paragraphs show gray markers while editing, including on first click and after Enter. Markers use the list style and stay out of slide shows and exports. | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index a0408eff..fc6fcacc 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?: bool, add?: [from, to], remove?: from} → the options and the replacement list", + "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?, ordinals?: bool, add?: [from, to], remove?: from} → the options and the replacement list", always, autocorrect_options ), @@ -655,7 +655,12 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { } else { correct_word(&s.prefs, &word, sentence_start, typing) }; - let Some(rep) = rep else { return Ok(()) }; + let Some(rep) = rep else { + if typing && s.prefs.ac_ordinals && is_ordinal(&word) { + return superscript_suffix(s, &t, word_end); + } + return Ok(()); + }; let delta = rep.chars().count() as isize - word.chars().count() as isize; let st = s.doc_mut()?; let mut d = (*st.doc).clone(); @@ -674,6 +679,43 @@ fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { Ok(()) } +/// `word` is a number with the ordinal suffix it takes (1st, 22nd, 103rd, 11th), as AutoFormat +/// superscripts it. +fn is_ordinal(word: &str) -> bool { + let digits = word.trim_end_matches(|c: char| c.is_ascii_lowercase()); + let suffix = word.get(digits.len()..).unwrap_or_default(); + if digits.is_empty() || !digits.bytes().all(|b| b.is_ascii_digit()) || suffix.len() != 2 { + return false; + } + let d = digits.as_bytes(); + let teen = d.len() >= 2 && d.get(d.len() - 2) == Some(&b'1'); + let want = match d.last() { + _ if teen => "th", + Some(b'1') => "st", + Some(b'2') => "nd", + Some(b'3') => "rd", + _ => "th", + }; + suffix == want +} + +/// Superscript the two letters before `end` in the caret's paragraph (an ordinal's suffix). +fn superscript_suffix(s: &mut Session, t: &crate::TextSel, end: usize) -> Result<()> { + let (a, b) = ((t.caret.0, end.saturating_sub(2)), (t.caret.0, end)); + let st = s.doc_mut()?; + if body_of(st, t).is_none_or(|body| ed::props_at(body, b).baseline.unwrap_or(0.0) > 0.0) { + return Ok(()); + } + let mut d = (*st.doc).clone(); + let sel = st.selection.clone(); + if let Some(body) = body_mut(&mut d, &sel, t) { + ed::format(body, a, b, &|r| r.baseline = Some(0.3)); + } + st.doc = Arc::new(d); + st.revision += 1; + Ok(()) +} + fn autocorrect_options(s: &mut Session, p: &Value) -> Result { let pr = &mut s.prefs; for (key, slot) in [ @@ -683,6 +725,7 @@ fn autocorrect_options(s: &mut Session, p: &Value) -> Result { ("days", &mut pr.ac_days), ("capsLock", &mut pr.ac_caps_lock), ("fractions", &mut pr.ac_fractions), + ("ordinals", &mut pr.ac_ordinals), ] { if let Some(v) = p.get(key).and_then(Value::as_bool) { *slot = v; @@ -729,7 +772,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, - "entries": list, + "ordinals": pr.ac_ordinals, "entries": list, })) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index ac25d666..418bff04 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -324,12 +324,13 @@ 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. + /// and 3/4 with fraction characters; AutoFormat superscripts ordinals (1st) 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, /// Your own "replace … with …" entries; they win over the built-in list. pub ac_entries: Vec<(String, String)>, /// Built-in entries you deleted. @@ -354,6 +355,7 @@ impl Default for Prefs { ac_days: true, ac_caps_lock: true, ac_fractions: true, + ac_ordinals: 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 cc872e66..3c525b65 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -3120,6 +3120,37 @@ fn autocorrect_options_as_you_type() { assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes "); } +#[test] +fn autocorrect_superscripts_ordinals_as_you_type() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + // The typed text with each superscript run in ^(…). + let typed = |s: &mut Session, text: &str| -> String { + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).ok(); + s.execute("text.delete", &json!({"dir": "backward"})).ok(); + for c in text.chars() { + s.execute("text.insert", &json!({"text": c.to_string()})).unwrap(); + } + let st = s.doc().unwrap(); + let sh = st.current_slide().unwrap().shapes.iter().find(|sh| sh.id.0 == title).unwrap(); + sh.text.as_ref().unwrap().paragraphs[0] + .runs + .iter() + .map(|r| if r.props.baseline.unwrap_or(0.0) > 0.0 { format!("^({})", r.text) } else { r.text.clone() }) + .collect() + }; + assert_eq!(typed(&mut s, "1st, 22nd and 103rd. 4th 11th x"), "1^(st), 22^(nd) and 103^(rd). 4^(th) 11^(th) x"); + // Not a word, a number without a suffix it takes, or a suffix not yet followed by a space. + assert_eq!(typed(&mut s, "first x1st 1th 2st 21st"), "First x1st 1th 2st 21st"); + assert_eq!(s.execute("text.autocorrect", &json!({})).unwrap()["ordinals"], true); + s.execute("text.autocorrect", &json!({"ordinals": false})).unwrap(); + assert_eq!(typed(&mut s, "2nd "), "2nd "); + // AutoCorrect off turns it off too. + s.execute("text.autocorrect", &json!({"ordinals": true, "on": false})).unwrap(); + assert_eq!(typed(&mut s, "2nd "), "2nd "); +} + #[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 576a2297..b63ce8ff 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -2095,6 +2095,7 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(&mut pr.ac_days, "Capitalize names of days"); 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.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 4e564951..d8bfa5ef 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -258,8 +258,8 @@ of the effect gallery. Detail in [animation-parity.md](animation-parity.md). 8 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); no AutoFormat of ordinals or automatic bulleted lists, no undo of a - single correction. + 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). ### Equations @@ -355,6 +355,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-16 | trivial | AutoFormat superscripts ordinals (1st, 22nd) as you type, with an AutoCorrect option to turn it off; automatic bulleted lists remain | | 2026-10-14 | trivial | Fixed #718: remote MCP no longer accepts unrelated or buffered stale success; loopback regressions cover invalid envelopes, incomplete and oversized frames | | 2026-10-14 | minor | Community PR wave folded: one entry each for the soak tests (engine and UI), the PPTX corpus (seven owned corpus files), edit points / text warp / 3-D, proofing (Hunspell spelling, thesaurus, AutoCorrect), the smaller missing rows, dialogs (41 vs ~60; Print and Insert Object left) and pen/trackpad input; ranked rows 3, 4 and 10 de-duplicated (comment threads #70 and the pane timeline are done); the fixed command-palette entry (#79) removed | | 2026-10-13 | minor | EMF/WMF rectangle clip regions (`INTERSECTCLIPRECT`, `EXCLUDECLIPRECT`, `EXTSELECTCLIPRGN`) limit later drawing. EMF+ records remain |