Merge PR #521 from NikitkaRa: Text: AutoCorrect options, case rules and your own entries

# Conflicts:
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 14:24:16 -04:00
6 changed files with 333 additions and 11 deletions
+220 -8
View File
@@ -25,6 +25,15 @@ pub fn specs() -> Vec<CommandSpec> {
),
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.
@@ -315,7 +324,7 @@ pub(crate) fn insert_text(s: &mut Session, text: &str, props: Option<RunProps>)
t.caret = c;
c
})?;
autocorrect(s)?;
autocorrect(s, text.chars().count() == 1)?;
Ok(json!({"caret": [pos.0, pos.1]}))
}
@@ -333,24 +342,151 @@ fn unlinked_at_link_end(body: &TextBody, at: Pos) -> Option<RunProps> {
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<String> {
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<String> {
let mut out = replacement(prefs, word);
if typing && out.is_none() {
let chars: Vec<char> = 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::<String>() } 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::<String>() } 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(());
}
@@ -362,14 +498,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();
@@ -388,6 +541,65 @@ fn autocorrect(s: &mut Session) -> Result<()> {
Ok(())
}
fn autocorrect_options(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
let pos = edit_text(s, |body, t| {
let (a, b) = t.ordered();
+19
View File
@@ -310,6 +310,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<String>,
pub author: String,
pub undo_limit: usize,
/// Minutes between AutoRecover saves of unsaved presentations.
@@ -325,6 +337,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,
+37
View File
@@ -2277,3 +2277,40 @@ fn png_size(bytes: &[u8]) -> Option<(u32, u32)> {
let h: [u8; 4] = bytes.get(20..24)?.try_into().ok()?;
Some((u32::from_be_bytes(w), u32::from_be_bytes(h)))
}
#[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 ");
}
+52 -1
View File
@@ -47,6 +47,7 @@ fn title(id: &str) -> &'static str {
"paragraph" => "Paragraph",
"about" => "About DeckCraft",
"preferences" => "Preferences",
"autocorrect" => "AutoCorrect",
"export" => "Export",
"symbol" => "Symbol",
"photoAlbum" => "Photo Album",
@@ -1902,6 +1903,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;
@@ -1916,7 +1959,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);
+4 -1
View File
@@ -304,7 +304,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
@@ -447,6 +449,7 @@ Detail in [localization-parity.md](localization-parity.md).
| 2026-10-13 | trivial | Command palette skips commands that need a value, so Export opens the dialog (#79) |
| 2026-10-14 | minor | Design ▸ Effects applies a named effect scheme. The checklist row stays P, so breadth stays 79%. Dialogs stay 27 |
| 2026-10-14 | minor | Slide Master Preserve and Title/Footers placeholders. Checklist letters unchanged (breadth stays 79%); dialogs stay 27 |
| 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-13 | minor | Interactive decks: triggers, return-to-slide state and click rules landed in the slide show; the remaining pieces are one new entry under Features |
| 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 |
+1 -1
View File
@@ -167,7 +167,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 |