From e615188f8ba12e4cbde44b4906a5fb53fef2c928 Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 22:43:32 +0300 Subject: [PATCH] Text: AutoCorrect options, case rules and your own entries MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AutoCorrect gains PowerPoint's options: correct TWo INitial CApitals, capitalize the first letter of sentences and the names of days, fix accidental cAPS LOCK, and replace 1/2, 1/4, 3/4 with fraction characters. "word..." becomes "word…", "word," and "word." are corrected too, and the built-in list grows from 13 to ~70 entries. The case rules apply only to typing, not pasted text. New text.autocorrect sets the options and adds or deletes replace entries (your own win over the built-in list; deleted built-ins stay deleted) and returns the list. Preferences ▸ AutoCorrect Options… opens an AutoCorrect dialog with the options and the editable list. Co-authored-by: Cursor --- crates/engine/src/cmd/text.rs | 228 ++++++++++++++++++++++++++++++++-- crates/engine/src/lib.rs | 19 +++ crates/engine/src/tests.rs | 37 ++++++ crates/ui-egui/src/dialogs.rs | 53 +++++++- docs/gaps.md | 5 +- docs/parity-checklist.md | 2 +- 6 files changed, 333 insertions(+), 11 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 1b2cb2d2..cab53299 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -15,6 +15,15 @@ pub fn specs() -> Vec { vec![ cmd!(noundo "text.edit", "Edit Text", [], Some("Enter"), "{id?, at?: [paragraph, char], end?: bool, cell?: [row, col], notes?: bool}", has_slide, edit), cmd!(noundo "text.exit", "Stop Editing Text", [], None, "{}", has_doc, exit), + cmd!( + noundo "text.autocorrect", + "AutoCorrect Options", + ["Tools"], + None, + "{on?, twoInitials?, sentenceCaps?, days?, capsLock?, fractions?: bool, add?: [from, to], remove?: from} → the options and the replacement list", + always, + autocorrect_options + ), cmd!("text.insert", "Type Text", [], None, "{text} (\\n = new paragraph, \\u000b = line break)", has_text, insert), cmd!("text.delete", "Delete Text", [], Some("Backspace"), "{dir?: backward|forward|wordBackward|wordForward}", has_text, delete), // The input method's composition: shown in place, an undo step only once committed. @@ -294,7 +303,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option) t.caret = c; c })?; - autocorrect(s)?; + autocorrect(s, text.chars().count() == 1)?; Ok(json!({"caret": [pos.0, pos.1]})) } @@ -312,24 +321,151 @@ fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option { Some(props) } -const AUTOCORRECT: &[(&str, &str)] = &[ +/// Built-in AutoCorrect replacements (common slips and symbols). +pub const AUTOCORRECT: &[(&str, &str)] = &[ ("teh", "the"), ("adn", "and"), ("recieve", "receive"), + ("recieved", "received"), ("seperate", "separate"), ("occured", "occurred"), + ("occurence", "occurrence"), ("thier", "their"), ("wich", "which"), + ("acheive", "achieve"), + ("accross", "across"), + ("adress", "address"), + ("alot", "a lot"), + ("appearence", "appearance"), + ("arguement", "argument"), + ("basicly", "basically"), + ("beggining", "beginning"), + ("beleive", "believe"), + ("calender", "calendar"), + ("commitee", "committee"), + ("definately", "definitely"), + ("dont", "don't"), + ("doesnt", "doesn't"), + ("didnt", "didn't"), + ("cant", "can't"), + ("wont", "won't"), + ("isnt", "isn't"), + ("embarass", "embarrass"), + ("enviroment", "environment"), + ("existance", "existence"), + ("foward", "forward"), + ("freind", "friend"), + ("goverment", "government"), + ("happend", "happened"), + ("i", "I"), + ("im", "I'm"), + ("ive", "I've"), + ("immediatly", "immediately"), + ("independant", "independent"), + ("knowlege", "knowledge"), + ("libary", "library"), + ("neccessary", "necessary"), + ("necesary", "necessary"), + ("noticable", "noticeable"), + ("occassion", "occasion"), + ("posession", "possession"), + ("prefered", "preferred"), + ("publically", "publicly"), + ("realy", "really"), + ("recomend", "recommend"), + ("refered", "referred"), + ("relevent", "relevant"), + ("succesful", "successful"), + ("tommorow", "tomorrow"), + ("tomorow", "tomorrow"), + ("truely", "truly"), + ("untill", "until"), + ("wierd", "weird"), + ("youre", "you're"), ("(c)", "©"), ("(r)", "®"), ("(tm)", "™"), + ("(e)", "€"), + ("...", "…"), ("->", "→"), ("<-", "←"), + ("=>", "⇒"), ("--", "–"), ]; -/// After a space or punctuation, replace a just-typed autocorrect entry. -fn autocorrect(s: &mut Session) -> Result<()> { +const DAYS: &[&str] = &["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"]; + +/// The replacement for `word` from your entries, then the built-in list. +fn replacement(prefs: &crate::Prefs, word: &str) -> Option { + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k == word) { + return Some(to.clone()); + } + if let Some((_, to)) = prefs.ac_entries.iter().find(|(k, _)| k.eq_ignore_ascii_case(word)) { + return Some(to.clone()); + } + let (k, to) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(word))?; + if prefs.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) { + return None; + } + // "i" only on its own, lower case. + if *k == "i" && word != "i" { + return None; + } + Some(if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(to, "sentence") } else { to.to_string() }) +} + +/// What a just-typed `word` becomes (`None`: unchanged). `sentence_start`: nothing but spaces +/// or a sentence's end (`.`, `!`, `?`) before it in the paragraph. `typing`: one character was +/// typed (the case rules apply only then). +pub fn correct_word(prefs: &crate::Prefs, word: &str, sentence_start: bool, typing: bool) -> Option { + let mut out = replacement(prefs, word); + if typing && out.is_none() { + let chars: Vec = word.chars().collect(); + let letters = chars.iter().all(|c| c.is_alphabetic()); + if prefs.ac_fractions { + out = match word { + "1/2" => Some("½".into()), + "1/4" => Some("¼".into()), + "3/4" => Some("¾".into()), + _ => None, + }; + } + if out.is_none() && letters && chars.len() >= 3 { + let upper = |c: &char| c.is_uppercase(); + let rest_lower = chars.get(2..).is_some_and(|r| r.iter().all(|c| c.is_lowercase())); + let rest_upper = chars.get(1..).is_some_and(|r| r.iter().all(upper)); + if prefs.ac_two_initials && chars.first().is_some_and(upper) && chars.get(1).is_some_and(upper) && rest_lower { + out = + Some(chars.iter().enumerate().map(|(i, c)| if i == 1 { c.to_lowercase().collect::() } else { c.to_string() }).collect()); + } else if prefs.ac_caps_lock && chars.first().is_some_and(|c| c.is_lowercase()) && rest_upper { + out = Some( + chars + .iter() + .enumerate() + .map(|(i, c)| if i == 0 { c.to_uppercase().collect::() } else { c.to_lowercase().collect() }) + .collect(), + ); + } + } + if out.is_none() && prefs.ac_days && DAYS.contains(&word) { + out = Some(ed::change_case(word, "sentence")); + } + } + if typing && prefs.ac_sentence_caps && sentence_start { + let w = out.clone().unwrap_or_else(|| word.to_string()); + let mut cs = w.chars(); + if let Some(f) = cs.next() + && f.is_lowercase() + && w.chars().all(char::is_alphabetic) + { + out = Some(f.to_uppercase().chain(cs).collect()); + } + } + out.filter(|o| o != word) +} + +/// After a space or punctuation, correct the word just typed. +fn autocorrect(s: &mut Session, typing: bool) -> Result<()> { if !s.prefs.autocorrect { return Ok(()); } @@ -341,14 +477,31 @@ fn autocorrect(s: &mut Session) -> Result<()> { if end == 0 || !line.get(end - 1).is_some_and(|c| c.is_whitespace() || ".,;:!?".contains(*c)) { return Ok(()); } - let word_end = end - 1; + 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()) { start -= 1; } - let word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); - let Some((_, rep)) = AUTOCORRECT.iter().find(|(k, _)| k.eq_ignore_ascii_case(&word)) else { return Ok(()) }; - let rep = if word.chars().next().is_some_and(char::is_uppercase) { ed::change_case(rep, "sentence") } else { rep.to_string() }; + // "word," and "word." also count: the punctuation was typed before the space. + let mut word: String = line.get(start..word_end).map(|w| w.iter().collect()).unwrap_or_default(); + if replacement(&s.prefs, &word).is_none() { + let trimmed = word.trim_end_matches(|c: char| ".,;:!?".contains(c)); + if !trimmed.is_empty() && trimmed.len() < word.len() && !word.ends_with("...") { + word_end -= word.chars().count() - trimmed.chars().count(); + word = trimmed.to_string(); + } + } + let before = line.get(..start).map(|b| b.iter().rev().find(|c| !c.is_whitespace()).copied()).unwrap_or(None); + let sentence_start = before.is_none_or(|c| ".!?".contains(c)); + // "word..." becomes "word…", the word corrected too. + let ellipsis = word.chars().count() > 3 && word.ends_with("...") && replacement(&s.prefs, "...").is_some(); + let rep = if ellipsis { + let stem = word.trim_end_matches('.'); + Some(format!("{}…", correct_word(&s.prefs, stem, sentence_start, typing).unwrap_or_else(|| stem.to_string()))) + } else { + correct_word(&s.prefs, &word, sentence_start, typing) + }; + let Some(rep) = rep else { 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(); @@ -367,6 +520,65 @@ fn autocorrect(s: &mut Session) -> Result<()> { Ok(()) } +fn autocorrect_options(s: &mut Session, p: &Value) -> Result { + let pr = &mut s.prefs; + for (key, slot) in [ + ("on", &mut pr.autocorrect), + ("twoInitials", &mut pr.ac_two_initials), + ("sentenceCaps", &mut pr.ac_sentence_caps), + ("days", &mut pr.ac_days), + ("capsLock", &mut pr.ac_caps_lock), + ("fractions", &mut pr.ac_fractions), + ] { + if let Some(v) = p.get(key).and_then(Value::as_bool) { + *slot = v; + } + } + if let Some(Value::Array(a)) = p.get("add") { + let (Some(from), Some(to)) = (a.first().and_then(Value::as_str), a.get(1).and_then(Value::as_str)) else { + return Err(bad("text.autocorrect", "`add` is [from, to]")); + }; + let from = from.trim(); + if from.is_empty() || from.chars().any(char::is_whitespace) || from.chars().count() > 64 || to.chars().count() > 255 { + return Err(bad("text.autocorrect", "`from` is one word (up to 64 characters), `to` up to 255")); + } + pr.ac_entries.retain(|(k, _)| k != from); + pr.ac_removed.retain(|k| !k.eq_ignore_ascii_case(from)); + if pr.ac_entries.len() >= 2000 { + return Err(bad("text.autocorrect", "the list is full")); + } + pr.ac_entries.push((from.to_string(), to.to_string())); + } + if let Some(from) = str_param(p, "remove") { + let had = pr.ac_entries.len(); + pr.ac_entries.retain(|(k, _)| k != from); + if had == pr.ac_entries.len() + && AUTOCORRECT.iter().any(|(k, _)| k.eq_ignore_ascii_case(from)) + && !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(from)) + { + pr.ac_removed.push(from.to_string()); + } + } + let list: Vec<(String, String)> = pr + .ac_entries + .iter() + .cloned() + .chain( + AUTOCORRECT + .iter() + .filter(|(k, _)| { + !pr.ac_removed.iter().any(|r| r.eq_ignore_ascii_case(k)) && !pr.ac_entries.iter().any(|(e, _)| e.eq_ignore_ascii_case(k)) + }) + .map(|(k, v)| (k.to_string(), v.to_string())), + ) + .collect(); + 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, + })) +} + pub(crate) fn paste_body(s: &mut Session, src: &TextBody) -> Result { let pos = edit_text(s, |body, t| { let (a, b) = t.ordered(); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..9db1dc10 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -247,6 +247,18 @@ pub struct Prefs { pub duplicate_offset: f64, pub autocorrect: bool, 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. + pub ac_two_initials: bool, + pub ac_sentence_caps: bool, + pub ac_days: bool, + pub ac_caps_lock: bool, + pub ac_fractions: bool, + /// Your own "replace … with …" entries; they win over the built-in list. + pub ac_entries: Vec<(String, String)>, + /// Built-in entries you deleted. + pub ac_removed: Vec, pub author: String, pub undo_limit: usize, /// Minutes between AutoRecover saves of unsaved presentations. @@ -262,6 +274,13 @@ impl Default for Prefs { duplicate_offset: 12.0, autocorrect: true, smart_quotes: true, + ac_two_initials: true, + ac_sentence_caps: true, + ac_days: true, + ac_caps_lock: true, + ac_fractions: true, + ac_entries: Vec::new(), + ac_removed: Vec::new(), author: "Presenter".into(), undo_limit: 500, recovery_minutes: 1.0, diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index e83f72ef..a681967b 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -835,3 +835,40 @@ fn slide_ranges_parse_like_powerpoints_print_box() { let opts = cmd::file::pdf_options(&json!({"slides": [0, 7, 1]}), 2).unwrap(); assert_eq!(opts.slides, Some(vec![0, 1]), "indices past the end are dropped"); } + +#[test] +fn autocorrect_options_as_you_type() { + let mut s = session(); + let title = s.doc().unwrap().current_slide().unwrap().shapes[0].id.0; + 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(); + } + s.execute("text.get", &json!({"id": title})).unwrap()["text"].as_str().unwrap().to_string() + }; + 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… "); + // Options turn rules off. + s.execute("text.autocorrect", &json!({"sentenceCaps": false, "days": false})).unwrap(); + assert_eq!(typed(&mut s, "hello monday "), "hello monday "); + // Your own entries, and deleting a built-in one. + s.execute("text.autocorrect", &json!({"add": ["dc", "DeckCraft"], "remove": "teh"})).unwrap(); + assert_eq!(typed(&mut s, "dc teh "), "DeckCraft teh "); + let v = s.execute("text.autocorrect", &json!({})).unwrap(); + let entries = v["entries"].as_array().unwrap(); + assert!(entries.iter().any(|e| e[0] == "dc")); + assert!(!entries.iter().any(|e| e[0] == "teh")); + assert!(s.execute("text.autocorrect", &json!({"add": ["two words", "x"]})).is_err()); + // Off entirely. + s.execute("text.autocorrect", &json!({"on": false})).unwrap(); + assert_eq!(typed(&mut s, "dc adn "), "dc adn "); + // Pasting a whole string applies only the replacement list, not the case rules. + s.execute("text.autocorrect", &json!({"on": true, "sentenceCaps": true})).unwrap(); + s.execute("text.edit", &json!({"id": title})).unwrap(); + s.execute("text.selectAll", &json!({})).ok(); + s.execute("text.insert", &json!({"text": "monday notes "})).unwrap(); + assert_eq!(s.execute("text.get", &json!({"id": title})).unwrap()["text"], "monday notes "); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index d1934be9..969a5310 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -45,6 +45,7 @@ fn title(id: &str) -> &'static str { "paragraph" => "Paragraph", "about" => "About DeckCraft", "preferences" => "Preferences", + "autocorrect" => "AutoCorrect", "export" => "Export", "symbol" => "Symbol", "equation" => "Equation", @@ -708,6 +709,48 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { let (ok, _) = buttons(ui, "Close"); ok } + "autocorrect" => { + let pr = &mut app.session.prefs; + ui.checkbox(&mut pr.ac_two_initials, "Correct TWo INitial CApitals"); + ui.checkbox(&mut pr.ac_sentence_caps, "Capitalize first letter of sentences"); + 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.autocorrect, "Replace text as you type"); + ui.add_space(4.0); + let mut from = d.get("from", ""); + let mut to = d.get("to", ""); + ui.horizontal(|ui| { + ui.label("Replace"); + ui.add(egui::TextEdit::singleline(&mut from).desired_width(90.0)); + ui.label("With"); + ui.add(egui::TextEdit::singleline(&mut to).desired_width(130.0)); + if ui.add_enabled(!from.trim().is_empty(), egui::Button::new("Add")).clicked() { + run(app, "text.autocorrect", json!({"add": [from.trim(), to]})); + from.clear(); + to.clear(); + } + }); + d.fields.insert("from".into(), from.clone()); + d.fields.insert("to".into(), to); + let list = app.session.execute("text.autocorrect", &json!({})).ok().and_then(|v| v.get("entries").cloned()); + let entries: Vec<(String, String)> = list.and_then(|v| serde_json::from_value(v).ok()).unwrap_or_default(); + let q = from.trim().to_lowercase(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + egui::Grid::new("ac-list").striped(true).show(ui, |ui| { + for (k, v) in entries.iter().filter(|(k, _)| q.is_empty() || k.to_lowercase().starts_with(&q)) { + ui.label(k); + ui.label(v); + if ui.small_button("Delete").clicked() { + run(app, "text.autocorrect", json!({"remove": k})); + } + ui.end_row(); + } + }); + }); + let (ok, cancel) = buttons(ui, "OK"); + ok || cancel + } "preferences" => { ui.label(egui::RichText::new("General").font(theme::bold(13.0))); let mut dark = app.ui.brightness == theme::Brightness::Dark; @@ -722,7 +765,15 @@ fn body(app: &mut SlideApp, ui: &mut Ui, d: &mut Dialog) -> bool { ui.checkbox(&mut app.session.prefs.smart_guides, "Show smart guides"); ui.checkbox(&mut app.session.prefs.snap_to_grid, "Snap objects to grid"); ui.checkbox(&mut app.session.prefs.smart_quotes, "Replace straight quotes with smart quotes"); - ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + ui.horizontal(|ui| { + ui.checkbox(&mut app.session.prefs.autocorrect, "AutoCorrect as you type"); + if ui.button("AutoCorrect Options…").clicked() { + *d = Dialog::new("autocorrect"); + } + }); + if d.id != "preferences" { + return false; + } ui.horizontal(|ui| { ui.label("Your name (comments):"); ui.text_edit_singleline(&mut app.session.prefs.author); diff --git a/docs/gaps.md b/docs/gaps.md index a2894b42..a934cf1c 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -176,7 +176,9 @@ Detail in [animation-parity.md](animation-parity.md). 10–16 h; the pane is a * ### Proofing: spelling, thesaurus, AutoCorrect - **Evidence:** `review.spelling` checks a small built-in list of frequent misspellings - (`crates/engine/src/cmd/review.rs`); no dictionaries, no thesaurus, no AutoCorrect. + (`crates/engine/src/cmd/review.rs`); no dictionaries, no thesaurus. 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. - **Estimate:** 8–14 h (Hunspell-format dictionaries under an open licence per language). ### Equations @@ -256,6 +258,7 @@ Detail in [localization-parity.md](localization-parity.md). | Date | Change | Summary | |---|---|---| +| 2026-10-13 | minor | AutoCorrect options: case rules, fractions, ellipsis, a larger built-in list and your own entries (`text.autocorrect`, Tools ▸ AutoCorrect) | | 2026-10-13 | minor | Shape, picture and table rendering checked against PowerPoint on ~150 synthetic edge-case slides; 3-D bevels, cameras and face lighting now render approximately (gap narrowed to extrusion, rigs and the 3-D pane) | | 2026-10-12 | minor | IME composition in slide text and notes, and CJK interface text with real glyphs (#101): the IME gap is now Korean on the web and table-cell underlines | | 2026-10-12 | minor | EMF/WMF text, pattern and hatch brushes drawn; legacy OLE and ActiveX previews shown; the SVG gap keeps only EMF clip regions | diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..593509a2 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -156,7 +156,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Find and replace, replace fonts | P1 | P | | Text | Spelling check | P1 | P | | Text | Thesaurus | P2 | M | -| Text | AutoCorrect and smart quotes | P2 | M | +| Text | AutoCorrect and smart quotes | P2 | Y | | Text | Symbols and emoji | P1 | D | | Text | Equations | P2 | M | | Text | Hyperlinks (URL, slide, email) | P1 | D |