AutoFormat As You Type: optional "Add a period with double-space", and an AutoCorrect Options button in Options › Proofing (#2848) (#3318)

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Brandon Thomas <echelon@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
dhruvLearner8
2026-10-25 11:11:20 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 50a1c34647
commit 0cb2af4f4b
6 changed files with 139 additions and 2 deletions
+25
View File
@@ -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(&para)
{
@@ -187,6 +191,27 @@ fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
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();
+7 -1
View File
@@ -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::<Vec<_>>(),
@@ -457,7 +463,7 @@ pub fn specs() -> Vec<CommandSpec> {
.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)
+45
View File
@@ -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");
}
+42
View File
@@ -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");
}
}
+12 -1
View File
@@ -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");
+8
View File
@@ -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) |