From 0cb2af4f4b60b12be738fcd8b9aff102da3b35df Mon Sep 17 00:00:00 2001 From: dhruvLearner8 <83899022+dhruvLearner8@users.noreply.github.com> Date: Sun, 25 Oct 2026 09:11:20 -0600 Subject: [PATCH] =?UTF-8?q?AutoFormat=20As=20You=20Type:=20optional=20"Add?= =?UTF-8?q?=20a=20period=20with=20double-space",=20and=20an=20AutoCorrect?= =?UTF-8?q?=20Options=20button=20in=20Options=20=E2=80=BA=20Proofing=20(#2?= =?UTF-8?q?848)=20(#3318)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * AutoFormat As You Type: optional period with double-space, off by default (#2848) * Test the double-space option through the AutoCorrect dialog (#2848) * File > Options > Proofing: an AutoCorrect Options button opens the dialog (#476) * #2848: report doubleSpacePeriod in tools.autocorrect's params doc and result Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/engine/src/cmd/text.rs | 25 ++++++++++++++++ crates/engine/src/cmd/tools.rs | 8 ++++- crates/engine/src/tests_typing.rs | 45 ++++++++++++++++++++++++++++ crates/ui-egui/src/dialogs_review.rs | 42 ++++++++++++++++++++++++++ crates/ui-egui/src/options.rs | 13 +++++++- docs/typing-parity.md | 8 +++++ 6 files changed, 139 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 5761a3e94..e0ab3e4de 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -101,6 +101,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { let raw = p::bool(v, "raw").unwrap_or(false); typing_options(s, text); let t = if raw { None } else { autoformat(s, text) }; + let was_collapsed = s.sel.is_collapsed(); type_text(s, t.as_deref().unwrap_or(text))?; let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c)); if raw || !trigger { @@ -117,6 +118,9 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { if text == " " && !listed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.dashes { dash_autoformat(s).inspect_err(|_| s.autocap = None)?; } + if text == " " && !listed && was_collapsed && s.prefs.autocorrect.enabled && s.prefs.autocorrect.double_space_period { + double_space_period(s)?; + } if let Some((para, sel)) = typed && s.doc.para_at(&f) != Some(¶) { @@ -187,6 +191,27 @@ fn list_autoformat(s: &mut Session) -> Result { Ok(true) } +/// "word" + two spaces → "word. ": the second Space turns the first into a period, after a +/// letter, a digit or a closing bracket or quote; not after other punctuation, at the start of a +/// paragraph, after a tab, or for a third space (the text before them ends in a period by then). +fn double_space_period(s: &mut Session) -> Result<(), CmdError> { + let f = s.sel.focus.clone(); + let Some(para) = s.doc.para_at(&f) else { return Ok(()) }; + let Some(before) = para.text.get(..f.off) else { return Ok(()) }; + let Some(word) = before.strip_suffix(" ") else { return Ok(()) }; + if !word.chars().next_back().is_some_and(|c| c.is_alphanumeric() || ")]}\"'\u{201D}\u{2019}\u{00BB}\u{203A}".contains(c)) { + return Ok(()); + } + // The period takes the formatting of the space it replaces. + let at = word.len(); + let p = s.doc.para_mut(f.story, &f.path)?; + let props = p.props_at(at + 1).clone().without_symbol(); + p.delete(at, at + 1)?; + p.insert_text(at, ".", &props)?; + s.sel = Selection::caret(f); + Ok(()) +} + /// "word -- word" → en dash, "word--word" → em dash (on the space after). fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> { let f = s.sel.focus.clone(); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 4bc606ddc..e42035bd0 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -114,6 +114,9 @@ pub struct AutoCorrectPrefs { /// letter or digit makes it Heading 1 (Heading 2 or 3 after one or two leading tabs). /// Off by default. pub heading_styles: bool, + /// A second Space after a word, number or closing bracket or quote turns the first into a + /// period (macOS's "Add period with double-space"); off by default. + pub double_space_period: bool, /// The user's entries: added, or replacing a built-in one. pub entries: Vec<(String, String)>, /// Built-in entries the user deleted. @@ -152,6 +155,7 @@ impl Default for AutoCorrectPrefs { border_lines: true, tab_indent: false, heading_styles: false, + double_space_period: false, entries: Vec::new(), removed: Vec::new(), exceptions: Vec::new(), @@ -344,6 +348,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { ("borderLines", &mut ac.border_lines), ("tabIndent", &mut ac.tab_indent), ("headingStyles", &mut ac.heading_styles), + ("doubleSpacePeriod", &mut ac.double_space_period), ("autoAddFirstLetter", &mut ac.auto_add_first_letter), ("autoAddInitialCaps", &mut ac.auto_add_initial_caps), ] { @@ -420,6 +425,7 @@ fn autocorrect_options(s: &mut Session, v: &Value) -> CmdResult { "borderLines": ac.border_lines, "tabIndent": ac.tab_indent, "headingStyles": ac.heading_styles, + "doubleSpacePeriod": ac.double_space_period, "autoAddFirstLetter": ac.auto_add_first_letter, "autoAddInitialCaps": ac.auto_add_initial_caps, "entries": ac.list().into_iter().map(|(a, b, builtin)| json!({"from": a, "to": b, "builtIn": builtin})).collect::>(), @@ -457,7 +463,7 @@ pub fn specs() -> Vec { .pure(), CommandSpec::new("tools.autocorrect", "AutoCorrect Options", "File › Options › Proofing", autocorrect_options) .params( - r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, + r#"{"enabled"?: bool (all of AutoCorrect), "replaceText"|"twoInitialCaps"|"capSentences"|"capCells"|"capDays"|"capsLock"|"smartQuotes"|"fractions"|"ordinals"|"dashes"|"links"|"bullets"|"numbering"|"borderLines"?: bool, "boldItalic"?: bool (*bold* and _italic_ with real formatting; default false), "tabIndent"?: bool (Set left- and first-indent with tabs and backspaces; default false), "headingStyles"?: bool (Built-in Heading styles; default false), "doubleSpacePeriod"?: bool (Add a period with double-space; default false), "autoAddFirstLetter"|"autoAddInitialCaps"?: bool (Automatically add words to the exception lists when an automatic capital is undone; default true), "add"?: {"from": string, "to": string} (adds or replaces an entry), "delete"?: string (an entry's "from", built in or added), "addException"?: string, "deleteException"?: string, "addInitialCapsException"?: string (keeps TWo INitial CApitals, like "IDs"), "deleteInitialCapsException"?: string} → options, entries and exceptions"#, ) .pure(), CommandSpec::new("tools.customizeKeyboard", "Customize Keyboard", "File › Options", customize_keyboard) diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 37ff3ccb3..007838f74 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -3320,3 +3320,48 @@ fn tab_with_a_partial_selection_still_inserts_a_tab() { run(&mut s, "text.tab", json!({})); assert_eq!(paras(&s), vec![para("H\t world", None)]); } + +/// #2848: "Add a period with double-space" (an AutoFormat As You Type switch, off by default): +/// a second Space after a word, a number or a closing bracket or quote turns the first into a +/// period. Undo puts the two spaces back; other punctuation, a lone space and a run of three +/// spaces are left as typed. +#[test] +fn double_space_adds_a_period_when_asked() { + let with_option = |keys: &str| { + let mut s = s(); + run(&mut s, "tools.autocorrect", json!({"doubleSpacePeriod": true})); + typ(&mut s, keys); + s + }; + let text = |s: &Session| s.doc.plain_text(StoryRef::Body); + // Off unless asked for. + assert_eq!(text(&typed("Hello world")), "Hello world"); + assert!(!s().prefs.autocorrect.double_space_period); + // On: the second space makes the period, and the next sentence starts with a capital. + assert_eq!(text(&with_option("Hello world")), "Hello. world"); + assert_eq!(text(&with_option("Hello world ")), "Hello. World "); + assert_eq!(text(&with_option("See page 5 Then")), "See page 5. Then"); + assert_eq!(text(&with_option("(it is done) Next")), "(it is done). Next"); + assert_eq!(text(&with_option("He said \"stop\" Next")), "He said “stop”. Next"); + // Undo brings the two spaces back, in one step. + let mut t = with_option("Hello "); + assert_eq!(text(&t), "Hello. "); + run(&mut t, "edit.undo", json!({})); + assert_eq!(text(&t), "Hello "); + // Left alone: other punctuation before the spaces, a lone space, spaces at the start of a + // paragraph or after a tab, and a third space. + for keys in ["Hello. x", "Hello, x", "Hello? x", "Hello; x", "Hello: x", "Hello x", " x", "\n x", "a\t x"] { + let want = typed(keys); + assert_eq!(text(&with_option(keys)), text(&want), "{keys:?}"); + } + assert_eq!(text(&with_option("Hello x")), "Hello. x"); + // A selection typed over, and the master switch. + let mut t = with_option("Hello world"); + run(&mut t, "select.all", json!({})); + run(&mut t, "text.insert", json!({"text": " "})); + assert_eq!(text(&t), " "); + let mut t = s(); + run(&mut t, "tools.autocorrect", json!({"doubleSpacePeriod": true, "enabled": false})); + typ(&mut t, "Hello world"); + assert_eq!(text(&t), "Hello world"); +} diff --git a/crates/ui-egui/src/dialogs_review.rs b/crates/ui-egui/src/dialogs_review.rs index 14ca2b78d..da98f115a 100644 --- a/crates/ui-egui/src/dialogs_review.rs +++ b/crates/ui-egui/src/dialogs_review.rs @@ -356,6 +356,7 @@ impl AutoCorrectForm { "borderLines": o.border_lines, "tabIndent": o.tab_indent, "headingStyles": o.heading_styles, + "doubleSpacePeriod": o.double_space_period, "autoAddFirstLetter": o.auto_add_first_letter, "autoAddInitialCaps": o.auto_add_initial_caps, }) @@ -401,6 +402,7 @@ fn autocorrect_ui(app: &mut WordApp, ui: &mut Ui, f: &mut AutoCorrectForm) -> bo ui.checkbox(&mut o.bold_italic, tl!("*Bold* and _italic_ as real formatting")); ui.checkbox(&mut o.dashes, tl!("Hyphens (--) as dashes (—)")); ui.checkbox(&mut o.links, tl!("Internet addresses as links")); + ui.checkbox(&mut o.double_space_period, tl!("Add a period with double-space")); heading(ui, "Apply as you type"); ui.checkbox(&mut o.bullets, tl!("Automatic bulleted lists")); ui.checkbox(&mut o.numbering, tl!("Automatic numbered lists")); @@ -897,4 +899,44 @@ mod tests { } assert!(texts(&h).iter().any(|t| t.contains("(none)")), "no matches: the hint shows"); } + + /// #2848 through the real dialog: tick "Add a period with double-space" on the AutoFormat As + /// You Type tab, press OK, and a double Space typed in the page makes a period. + #[test] + fn the_double_space_option_is_set_in_the_dialog_and_works_when_typing() { + use egui_kittest::kittest::Queryable; + let mut app = app(""); + app.run("tools.autocorrect", json!({})).unwrap(); + let mut h = egui_kittest::Harness::builder().with_size(vec2(1440.0, 900.0)).build_ui_state( + |ui, app: &mut WordApp| { + let ctx = ui.ctx().clone(); + app.logic(&ctx); + app.ui(ui); + }, + app, + ); + for _ in 0..4 { + h.step(); + } + h.get_by_label("AutoFormat As You Type").click(); + for _ in 0..3 { + h.step(); + } + h.get_by_label("Add a period with double-space").click(); + for _ in 0..3 { + h.step(); + } + h.get_by_label("OK").click(); + for _ in 0..4 { + h.step(); + } + assert!(h.state().dialog.is_none(), "OK closes the dialog"); + assert!(h.state().session.prefs.autocorrect.double_space_period, "the option is on"); + h.state_mut().canvas.focused = true; + for t in ["Hello", " ", " ", "world"] { + h.event(egui::Event::Text(t.to_string())); + h.step(); + } + assert_eq!(h.state().session.doc.plain_text(wordcraft_doc::StoryRef::Body), "Hello. world"); + } } diff --git a/crates/ui-egui/src/options.rs b/crates/ui-egui/src/options.rs index b758d9993..42ec30346 100644 --- a/crates/ui-egui/src/options.rs +++ b/crates/ui-egui/src/options.rs @@ -292,7 +292,11 @@ fn display(app: &mut WordApp, ui: &mut Ui) { } fn proofing(app: &mut WordApp, ui: &mut Ui) { - // #476 (AutoCorrect Options…) goes here, at the top. + section(ui, "AutoCorrect"); + if ui.button(tl!("AutoCorrect Options")).clicked() { + let _ = app.run("tools.autocorrect", json!({})); + } + ui.add_space(8.0); section(ui, "Spelling"); let pr = app.session.prefs.clone(); flag(app, ui, "Ignore words in capitals", "ignoreUppercase", pr.ignore_uppercase); @@ -1089,6 +1093,13 @@ mod tests { h.get_by_label("Ignore words in capitals").click(); h.run_steps(2); assert!(!h.state().session.prefs.ignore_uppercase); + // The AutoCorrect Options dialog opens from this pane. + assert!(h.state().dialog.is_none()); + h.get_by_label("AutoCorrect Options").click(); + h.run_steps(3); + assert_eq!(h.state().dialog.as_ref().map(|d| d.name()), Some("autoCorrect")); + h.state_mut().dialog = None; + h.run_steps(2); // #1063: the dictionaries folder and the dictionary each language found. assert!(h.query_by_label("Dictionaries folder:").is_some()); assert!(h.query_by_label_contains("Polish: ").is_some(), "the Polish dictionary's line"); diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 5ab504bfb..a9c5c4944 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -156,6 +156,13 @@ ideographic space (`autocorrect_finds_the_word_after_a_wide_space`): is “ (U+201C) even though it shows on the right of the word, after a letter ” (U+201D); in an acronym (`צה"ל`) it becomes ” as in Word for Mac 16.113.4, not gershayim (״); `'` after a letter (`ג'ירפה`) is ’ as after any letter, with no geresh conversion (not yet checked in Word; `smart_quotes_in_hebrew_use_logical_order`, #1670). +- Add a period with double-space (File › Options › Proofing › AutoCorrect Options › AutoFormat As + You Type, `tools.autocorrect {"doubleSpacePeriod"}`; **off by default**, as macOS's system switch + is a per-user choice): a second Space after a letter, a digit or a closing bracket or quote turns + the first into a period (`Hello world`→`Hello. world`). Other punctuation before the spaces, + a lone space, the start of a paragraph, a tab, a selection typed over and a third space are left + as typed; Undo puts the two spaces back (`double_space_adds_a_period_when_asked`, #2848). Not + observed in Word; Word for Mac follows the macOS setting. - Correct TWo INitial CApitals: a finished word of three or more letters with two capitals and the rest lowercase loses its second capital (`WOrd`→`Word`, `TEh`→`The`); not the exceptions (`IDs`, `CDs`, `PCs`, `TVs`, … plus the user's, `tools.autocorrect {"addInitialCapsException"}`) @@ -427,6 +434,7 @@ again. | 2026-10-19 | trivial | Delete on whole selected cells (rows, columns, the table, a cell) leaves one empty paragraph in each, with the cell's last paragraph's formatting; nested tables go too (#2802) | | 2026-10-19 | trivial | Ctrl+Tab (⌥Tab on macOS) types a tab character in a table cell (#3137) | | 2026-10-16 | trivial | AltGr on Linux (Wayland) types ś, ł, ż… instead of opening KeyTips: the modifiers event without Alt that follows the Right Alt key no longer reads as Alt let go, and a bare AltGr tap doesn't open KeyTips (#2388) | +| 2026-10-20 | trivial | AutoFormat As You Type: optional "Add a period with double-space" (off by default), undoable (#2848) | | 2026-10-18 | trivial | Typing over a selection takes the first selected character's formatting, also when the selection was made right to left (#2879) | | 2026-10-17 | trivial | A table inserted in a right-to-left paragraph is a right-to-left table, so Tab moves to the cell on the left (#2634) | | 2026-10-15 | trivial | Backspace after a section break and Delete before it remove the break, also one on the last paragraph's mark; the text takes the following section's setup and keeps the headers it showed (#2127) |