Typing parity with Word: lists, AutoFormat and AutoCorrect as you type (#204)

* Typing parity with Word: lists, AutoFormat and AutoCorrect as you type

Typed side by side with Word (same keystrokes, results read back) and matched it:

- Enter after a list no longer gets stuck: the paragraph that leaves a list goes back to
  Normal, and later Enters make blank paragraphs (the `num: 0` marker counted as a list item,
  so every Enter re-ran "leave the list" and did nothing; the list indent also stuck to every
  following paragraph).
- Enter on an empty nested item moves it up a level before leaving the list.
- Second Backspace at the start of an un-numbered list paragraph returns it to Normal
  instead of joining it to the paragraph above.
- "1. " always starts numbering at 1; "a. " and "I. " start lettered/roman lists; "- " makes
  a dash bullet.
- Undo right after an AutoFormat/AutoCorrect change undoes only that change.
- AutoCorrect: sentence capitals (with abbreviation/initial exceptions), runs on Enter too,
  "word -- word" en dash, "wait..." ellipsis, 1/2 1/4 3/4, ordinal superscripts, web
  addresses become links.
- Border lines: Enter after ---, ===, ***, ___, ~~~, ### draws a bottom border.
- Layout: no line break right after a slash ("and/or", URLs), as in Word.
- ui_shot: UI_SHOT_FULL=1 prints whole replies for scripted checks.

Regression tests in crates/engine/src/tests_typing.rs and the layout tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Typing parity: record Word's observed typing rules, two more tests

docs/typing-parity.md lists what Word does for each key sequence (lists, Backspace, Tab,
AutoCorrect, AutoFormat, slash line breaks) with the test that pins it, the known gaps, and how
to compare without taking over the shared machine. CLAUDE.md/AGENTS.md point at it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-10 04:52:10 -04:00
committed by GitHub
co-authored by Claude Opus 5.5
parent a1caf22a0b
commit 869ac16110
14 changed files with 686 additions and 38 deletions
+3
View File
@@ -48,6 +48,9 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
## Quality gates
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Typing behaves like Word
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
- Drive it with JSON lines on `127.0.0.1:7981`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md`.
+3
View File
@@ -48,6 +48,9 @@ People trust WordCraft with their writing; a crash loses their work. **This outr
## Quality gates
Before every commit: `cargo xtask ci` (fmt, clippy -D warnings, tests, assets, layers, wasm). Commit after every arc of work that builds, with a task id in the message (`M2.1: line spacing dialog`).
## Typing behaves like Word
Typing, Enter, Backspace, Tab, lists, AutoCorrect and AutoFormat follow Word's observed behaviour in [`docs/typing-parity.md`](docs/typing-parity.md), pinned by `crates/engine/src/tests_typing.rs` (keys go through the same commands as the keyboard). Change behaviour, doc and tests together; a typing fix lands with a test there. Compare headlessly (`ui_shot`); don't drive GUIs with keystrokes on the shared machine.
## Running and looking at the app
- `cargo run --release -p wordcraft -- --sample --control 7981` (sample document + control channel).
- Drive it with JSON lines on `127.0.0.1:7981`, e.g. `{"id":1,"method":"engine.execute","params":{"command":"text.insert","params":{"text":"Hello"}}}` then `{"id":2,"method":"ui.screenshot","params":{"path":"/tmp/shot.png"}}`. Methods: `crates/ui-egui/src/control.rs`; docs: `docs/control-protocol.md`.
+10 -1
View File
@@ -100,6 +100,8 @@ pub enum ListKind {
UpperLetter,
/// a) b) c)
LowerLetter,
/// a. b. c.
LowerLetterDot,
/// i. ii. iii.
LowerRoman,
/// 1. 1.1. 1.1.1.
@@ -115,6 +117,7 @@ impl ListKind {
"outline" => ListKind::Outline,
"upperLetter" => ListKind::UpperLetter,
"lowerLetter" => ListKind::LowerLetter,
"lowerLetterDot" => ListKind::LowerLetterDot,
"lowerRoman" => ListKind::LowerRoman,
"legal" | "multilevel" => ListKind::Legal,
s if s.chars().count() == 1 => ListKind::BulletChar(s.chars().next()?),
@@ -133,7 +136,12 @@ fn bullet_level(i: usize, c: char) -> Level {
indent: 36.0 * (i as f32 + 1.0),
hanging: 18.0,
chr: CharProps {
font: Some(if c == '•' || c == '▪' || c == '○' { "Arial".into() } else { "Segoe UI Symbol".into() }),
font: match c {
'•' | '▪' | '○' => Some("Arial".into()),
// Plain text characters (a dash bullet) use the paragraph's font.
c if c.is_ascii() || c == '–' => None,
_ => Some("Segoe UI Symbol".into()),
},
..CharProps::default()
},
..Level::default()
@@ -159,6 +167,7 @@ pub fn levels_for(kind: ListKind) -> Vec<Level> {
ListKind::NumberedParen => lvl(cyc(NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n})")),
ListKind::UpperLetter => lvl(cyc(NumFormat::UpperLetter, NumFormat::LowerLetter, NumFormat::LowerRoman), format!("%{n}.")),
ListKind::LowerLetter => lvl(cyc(NumFormat::LowerLetter, NumFormat::LowerRoman, NumFormat::Decimal), format!("%{n})")),
ListKind::LowerLetterDot => lvl(cyc(NumFormat::LowerLetter, NumFormat::LowerRoman, NumFormat::Decimal), format!("%{n}.")),
ListKind::LowerRoman => lvl(cyc(NumFormat::LowerRoman, NumFormat::LowerLetter, NumFormat::Decimal), format!("%{n}.")),
ListKind::Outline => {
let f = [NumFormat::UpperRoman, NumFormat::UpperLetter, NumFormat::Decimal, NumFormat::LowerLetter, NumFormat::LowerRoman]
+26 -7
View File
@@ -145,20 +145,39 @@ pub fn type_text(s: &mut Session, text: &str) -> Result<(), CmdError> {
Ok(())
}
/// Take a paragraph out of its list like Word does when Enter or Backspace ends a list: a
/// "List Paragraph" goes back to Normal; any other style keeps itself with numbering switched off.
pub fn leave_list(para: &mut wordcraft_doc::Paragraph) {
if para.props.style.as_deref() == Some("ListParagraph") {
para.props.style = None;
para.props.numbering = None;
} else {
// `num: 0` overrides numbering a style may carry.
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: 0, level: 0 });
}
para.props.indent_left = None;
para.props.indent_first = None;
para.touch();
}
/// Split the paragraph at `at` like Enter does: an empty list paragraph leaves the list, the
/// next paragraph gets the style's "next" style when Enter is at the end.
pub fn split_para(s: &mut Session, at: &Pos) -> Result<Pos, CmdError> {
// `num: 0` means "explicitly not in a list", so it isn't a list item.
let (at_end, style, empty_list) = match s.doc.para_at(at) {
Some(p) => (at.off >= p.len(), p.props.style.clone(), p.is_empty() && p.props.numbering.is_some()),
Some(p) => (at.off >= p.len(), p.props.style.clone(), p.props.numbering.filter(|n| n.num != 0 && p.is_empty())),
None => return Err(CmdError::Failed("no paragraph at caret".into())),
};
if empty_list {
// Enter on an empty list item ends the list (Word behaviour).
if let Some(n) = empty_list {
// Enter on an empty list item: a nested item moves up a level, a top-level one ends
// the list (Word behaviour).
let para = s.doc.para_mut(at.story, &at.path)?;
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: 0, level: 0 });
para.props.indent_left = None;
para.props.indent_first = None;
para.touch();
if n.level > 0 {
para.props.numbering = Some(wordcraft_doc::props::NumRef { num: n.num, level: n.level - 1 });
para.touch();
} else {
leave_list(para);
}
return Ok(at.clone());
}
let new = s.doc.split_paragraph(at)?;
+115 -12
View File
@@ -2,7 +2,7 @@
use serde_json::Value;
use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, PAGE_BREAK, SOFT_HYPHEN};
use wordcraft_doc::props::NumRef;
use wordcraft_doc::props::{Border, BorderStyle, NumRef};
use wordcraft_doc::{Block, ListKind, Pos};
use super::{delete_selection, sel_result, split_para, type_text};
@@ -50,40 +50,64 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult {
let raw = p::bool(v, "raw").unwrap_or(false);
let t = if raw { None } else { autoformat(s, text) };
type_text(s, t.as_deref().unwrap_or(text))?;
if !raw && text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c)) {
let trigger = text.chars().count() == 1 && text.chars().all(|c| c == ' ' || ",.;:!?".contains(c));
if raw || !trigger {
return sel_result(s);
}
// The paragraph as typed, so an automatic change can be undone on its own.
let f = s.sel.focus.clone();
let typed = s.doc.para_at(&f).cloned().map(|p| (p, s.sel.clone()));
// A list label ("a. ") becomes a list before AutoCorrect could capitalise it.
let listed = text == " " && list_autoformat(s)?;
if !listed {
super::tools::autocorrect(s)?;
}
if !raw && text == " " {
list_autoformat(s)?;
if text == " " && !listed {
dash_autoformat(s)?;
}
if let Some((para, sel)) = typed
&& s.doc.para_at(&f) != Some(&para)
{
let mut doc = s.doc.clone();
if let Ok(p) = doc.para_mut(f.story, &f.path) {
*p = para;
p.touch();
s.push_undo("AutoFormat", doc, sel);
}
}
sel_result(s)
}
/// "* " / "- " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph.
fn list_autoformat(s: &mut Session) -> Result<(), CmdError> {
fn list_autoformat(s: &mut Session) -> Result<bool, CmdError> {
let f = s.sel.focus.clone();
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(para) = s.doc.para_at(&f) else { return Ok(false) };
if para.props.numbering.is_some_and(|n| n.num != 0) || f.off != para.len() {
return Ok(());
return Ok(false);
}
let kind = match para.text.as_str() {
"* " | "- " | "> " => ListKind::Bullet,
"* " | "> " => ListKind::Bullet,
"- " => ListKind::BulletChar('-'),
"1. " => ListKind::Numbered,
"1) " => ListKind::NumberedParen,
"a. " => ListKind::LowerLetterDot,
"a) " => ListKind::LowerLetter,
"A. " => ListKind::UpperLetter,
"i. " => ListKind::LowerRoman,
_ => return Ok(()),
"I. " => ListKind::Outline,
_ => return Ok(false),
};
let num = s.doc.numbering.find_kind(kind).unwrap_or_else(|| s.doc.numbering.add_list(kind));
// Bullets join an existing bullet list; typing "1. " always starts numbering again at 1,
// as in Word.
let found = if kind.is_bullet() { s.doc.numbering.find_kind(kind) } else { None };
let num = found.unwrap_or_else(|| s.doc.numbering.add_list(kind));
let para = s.doc.para_mut(f.story, &f.path)?;
let len = para.len();
para.delete(0, len)?;
para.props.numbering = Some(NumRef { num, level: 0 });
para.props.style = Some("ListParagraph".into());
s.sel = Selection::caret(Pos { off: 0, ..f });
Ok(())
Ok(true)
}
/// "word -- word" → en dash, "word--word" → em dash (on the space after).
@@ -92,6 +116,16 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
let Some(para) = s.doc.para_at(&f) else { return Ok(()) };
let Some(before) = para.text.get(..f.off) else { return Ok(()) };
if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" -- ")
&& before.trim_end().get(i + 4..).is_some_and(|w| !w.is_empty() && !w.contains(' '))
{
// "word -- word" → "word – word" once the next word is typed.
let p = s.doc.para_mut(f.story, &f.path)?;
let props = p.props_at(i + 1).clone();
p.delete(i + 1, i + 3)?;
p.insert_text(i + 1, "\u{2013}", &props)?;
s.sel = Selection::caret(Pos { off: f.off + 1, ..f });
} else if before.ends_with(' ')
&& let Some(i) = before.trim_end().rfind(" - ")
&& !before.trim_end()[i + 3..].contains(' ')
{
@@ -114,6 +148,61 @@ fn dash_autoformat(s: &mut Session) -> Result<(), CmdError> {
Ok(())
}
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###" turns the
/// characters into a bottom border on the paragraph above (or this one at the top of a story).
fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result<bool, CmdError> {
let Some(para) = s.doc.para_at(at) else { return Ok(false) };
let text = para.text.as_str();
let Some(c) = text.chars().next() else { return Ok(false) };
if !s.autocorrect_on
|| at.off != text.len()
|| text.len() < 3
|| !text.chars().all(|x| x == c)
|| para.props.numbering.is_some_and(|n| n.num != 0)
{
return Ok(false);
}
let (style, width) = match c {
'-' => (BorderStyle::Single, 0.75),
'_' => (BorderStyle::Thick, 1.5),
'=' => (BorderStyle::Double, 0.75),
'*' => (BorderStyle::Dotted, 2.25),
'~' => (BorderStyle::Wave, 0.75),
'#' => (BorderStyle::Triple, 0.75),
_ => return Ok(false),
};
let line = Border { style, width, color: None, space: 1.0 };
let set_bottom = |p: &mut wordcraft_doc::Paragraph| {
let mut b = p.props.borders.unwrap_or_default();
b.bottom = Some(line);
p.props.borders = Some(b);
p.touch();
};
let i = at.path.last();
let prev = if i > 0 { Some(at.path.with_last(i - 1)) } else { None };
let len = text.len();
let cur = s.doc.para_mut(at.story, &at.path)?;
cur.delete(0, len)?;
let start = Pos { off: 0, ..at.clone() };
match prev.filter(|q| matches!(s.doc.block(at.story, q), Some(Block::Para(_)))) {
Some(q) => {
// The characters' paragraph becomes the one typing continues in.
set_bottom(s.doc.para_mut(at.story, &q)?);
s.sel = Selection::caret(start);
}
None => {
set_bottom(s.doc.para_mut(at.story, &at.path)?);
let new = split_para(s, &start)?;
let p = s.doc.para_mut(new.story, &new.path)?;
p.props.borders = None;
p.touch();
s.sel = Selection::caret(new);
}
}
s.goal_x = None;
Ok(true)
}
fn ins_char(s: &mut Session, c: char) -> CmdResult {
let mut b = [0u8; 4];
type_text(s, c.encode_utf8(&mut b))?;
@@ -121,7 +210,15 @@ fn ins_char(s: &mut Session, c: char) -> CmdResult {
}
fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult {
let at = delete_selection(s)?;
let mut at = delete_selection(s)?;
if border_line_autoformat(s, &at)? {
return sel_result(s);
}
// Enter finishes a word like a space does ("teh" → "the", sentence capitals, links).
if s.sel.is_collapsed() && at.off > 0 {
super::tools::autocorrect_word(s, true)?;
at = s.sel.focus.clone();
}
let new = split_para(s, &at)?;
s.sel = Selection::caret(new);
s.goal_x = None;
@@ -211,11 +308,17 @@ fn backspace(s: &mut Session, _: &Value) -> CmdResult {
}
// At the start of a paragraph.
if para.props.numbering.is_some_and(|n| n.num != 0) {
// First Backspace removes the number but keeps the item's indent (Word).
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.numbering = Some(NumRef { num: 0, level: 0 });
p.touch();
return sel_result(s);
}
if para.props.style.as_deref() == Some("ListParagraph") {
// The second one takes it out of the list's indent instead of joining paragraphs.
super::leave_list(s.doc.para_mut(f.story, &f.path)?);
return sel_result(s);
}
if para.props.indent_first.is_some_and(|x| x > 0.0) {
let p = s.doc.para_mut(f.story, &f.path)?;
p.props.indent_first = Some(0.0);
+128 -14
View File
@@ -45,6 +45,9 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[
("<-", "←"),
("=>", "⇒"),
(":)", "☺"),
("1/2", "½"),
("1/4", "¼"),
("3/4", "¾"),
];
pub fn specs() -> Vec<CommandSpec> {
@@ -446,8 +449,15 @@ fn macros(s: &mut Session, v: &Value) -> CmdResult {
))
}
/// Apply AutoCorrect to the word just before the caret (called after typing a space/punctuation).
/// Apply AutoCorrect to the word just before the caret (called after typing a space/punctuation):
/// replacements, then ordinal superscripts, web addresses as links and sentence capitals.
pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
autocorrect_word(s, false)
}
/// AutoCorrect the word ending at the caret; `on_enter` means Enter finished it (no trigger
/// character was typed).
pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> {
if !s.autocorrect_on {
return Ok(());
}
@@ -455,17 +465,17 @@ pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
let Some(text) = s.doc.para_at(&f).map(|p| p.text.clone()) else { return Ok(()) };
let Some(before) = text.get(..f.off) else { return Ok(()) };
let Some(last) = before.chars().next_back() else { return Ok(()) };
if !(last == ' ' || ",.;:!?".contains(last)) {
if !on_enter && !(last == ' ' || ",.;:!?".contains(last)) {
return Ok(());
}
let body = &before[..before.len() - last.len_utf8()];
let trigger = if on_enter { 0 } else { last.len_utf8() };
let Some(body) = before.get(..before.len() - trigger) else { return Ok(()) };
let start = body.rfind(char::is_whitespace).map(|i| i + 1).unwrap_or(0);
let Some(word) = body.get(start..).map(str::to_string) else { return Ok(()) };
let word = word.as_str();
if word.is_empty() {
return Ok(());
}
let user = s.autocorrect_user.iter().find(|(a, _)| a == word).map(|(_, b)| b.clone());
let user = s.autocorrect_user.iter().find(|(a, _)| *a == word).map(|(_, b)| b.clone());
let builtin = AUTOCORRECT
.iter()
.find(|(a, _)| {
@@ -479,17 +489,120 @@ pub fn autocorrect(s: &mut Session) -> Result<(), CmdError> {
b.to_string()
}
});
let Some(rep) = user.or(builtin) else { return Ok(()) };
let para = s.doc.para_mut(f.story, &f.path)?;
let props = para.props_of_char(start).clone();
let end = start + word.len();
para.delete(start, end)?;
para.insert_text(start, &rep, &props)?;
let off = (f.off + rep.len()).saturating_sub(word.len());
s.sel = Selection::caret(Pos { off, ..f });
// A whole-word replacement, else "wait..." → "wait…".
let (from, rep) = match user.or(builtin) {
Some(r) => (start, Some(r)),
None if word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())),
None => (start, None),
};
let mut caret = f.off;
if let Some(rep) = rep {
let end = start + word.len();
let para = s.doc.para_mut(f.story, &f.path)?;
let props = para.props_of_char(from).clone();
para.delete(from, end)?;
para.insert_text(from, &rep, &props)?;
caret = (f.off + rep.len()).saturating_sub(end - from);
s.sel = Selection::caret(Pos { off: caret, ..f.clone() });
}
let end = caret.saturating_sub(trigger);
let Some(word) = s.doc.para_at(&f).and_then(|p| p.text.get(start..end)).map(str::to_string) else { return Ok(()) };
let at = |off: usize| Pos { off, ..f.clone() };
// 1st, 22nd, 103rd → superscript suffix.
if let Some(n) = ordinal_suffix(&word) {
s.doc.format_range(&at(end - n), &at(end), &|c| c.vert_align = Some(wordcraft_doc::props::VertAlign::Superscript))?;
return Ok(());
}
// Web addresses become links (trailing punctuation stays outside).
let lower = word.to_ascii_lowercase();
if (lower.starts_with("http://") || lower.starts_with("https://") || lower.starts_with("www.")) && word.len() > 8 {
let url = word.trim_end_matches(|c: char| ".,;:!?)]\"'”’".contains(c));
let href = if lower.starts_with("www.") { format!("http://{url}") } else { url.to_string() };
let url_end = start + url.len();
s.doc.format_range(&at(start), &at(url_end), &|c| {
c.link = Some(href.clone());
c.style = Some("Hyperlink".into());
})?;
// Typing after the link isn't part of it (but keeps the text's other formatting).
let mut after = s.doc.para_at(&f).map(|p| p.props_of_char(start).clone()).unwrap_or_default();
after.link = None;
after.style = None;
s.pending = Some(after);
return Ok(());
}
if starts_sentence(text.get(..start).unwrap_or("")) && should_capitalize(&word, if on_enter { '\n' } else { last }) {
let para = s.doc.para_mut(f.story, &f.path)?;
if let Some(c) = word.chars().next() {
let up: String = c.to_uppercase().collect();
let props = para.props_of_char(start).clone();
para.delete(start, start + c.len_utf8())?;
para.insert_text(start, &up, &props)?;
let off = (caret + up.len()).saturating_sub(c.len_utf8());
s.sel = Selection::caret(Pos { off, ..f });
}
}
Ok(())
}
/// Byte length of an ordinal suffix to superscript ("1st" → 2), if `word` is a correct ordinal.
fn ordinal_suffix(word: &str) -> Option<usize> {
let digits = word.find(|c: char| !c.is_ascii_digit())?;
let (num, suffix) = word.split_at(digits);
if num.is_empty() || num.len() > 9 {
return None;
}
let n: u64 = num.parse().ok()?;
let want = match (n % 100, n % 10) {
(11..=13, _) => "th",
(_, 1) => "st",
(_, 2) => "nd",
(_, 3) => "rd",
_ => "th",
};
(suffix == want).then_some(2)
}
/// Abbreviations whose full stop doesn't end a sentence.
const NOT_SENTENCE_END: &[&str] = &[
"e.g.", "i.e.", "etc.", "vs.", "cf.", "mr.", "mrs.", "ms.", "dr.", "st.", "no.", "approx.", "fig.", "p.", "pp.", "jr.", "sr.", "ca.", "inc.",
"ltd.", "co.", "vol.", "a.m.", "p.m.",
];
/// Does a word typed after `before` (the paragraph text up to the word) begin a sentence?
fn starts_sentence(before: &str) -> bool {
let t = before.trim_end();
if t.is_empty() {
return true;
}
if t.len() == before.len() {
return false; // no space between the previous text and the word
}
let t = t.trim_end_matches(|c: char| "\"'”’)]".contains(c));
if !t.ends_with(['.', '!', '?']) || t.ends_with("..") || t.ends_with('…') {
return false;
}
let prev = t.rsplit(char::is_whitespace).next().unwrap_or(t).trim_start_matches(|c: char| "\"'“‘([".contains(c));
let lower = prev.to_lowercase();
// "J." (an initial) or a known abbreviation.
let initial = prev.chars().count() == 2 && prev.chars().next().is_some_and(char::is_uppercase);
!initial && !NOT_SENTENCE_END.contains(&lower.as_str())
}
/// Only plain lowercase words are capitalised (not "iPhone", "x2", file names or addresses).
fn should_capitalize(word: &str, next: char) -> bool {
// A single letter before a full stop is a list label or an initial ("a.", "j.").
if (next == '.' || word.ends_with('.')) && word.trim_end_matches('.').chars().count() < 2 {
return false;
}
if word == "e.g." || word == "i.e." {
return true;
}
word.chars().next().is_some_and(char::is_lowercase)
&& word.chars().all(|c| c.is_alphabetic() || "'’-.".contains(c))
&& !word.chars().any(char::is_uppercase)
&& !word.trim_end_matches('.').contains('.')
}
/// Compare the current document (original) with a revised one: the result shows the
/// differences as tracked insertions and deletions.
fn compare(s: &mut Session, v: &Value) -> CmdResult {
@@ -880,7 +993,8 @@ mod tests {
for t in ["teh", " ", "Teh", " ", "(c)", " "] {
s.run("text.insert", &json!({"text": t})).unwrap();
}
assert_eq!(s.doc.plain_text(StoryRef::Body), "the The © ");
// The first word of a sentence is capitalised too.
assert_eq!(s.doc.plain_text(StoryRef::Body), "The The © ");
}
#[test]
+2
View File
@@ -153,3 +153,5 @@ pub mod p {
#[cfg(test)]
mod tests;
#[cfg(test)]
mod tests_typing;
+9
View File
@@ -249,6 +249,15 @@ impl Session {
}
self.redo.clear();
}
/// Record `doc`/`sel` as their own undo step after the fact and close the typing group, so
/// Undo right after an automatic change (AutoFormat, AutoCorrect) reverts only that change.
pub fn push_undo(&mut self, label: &str, doc: Document, sel: Selection) {
self.history.push(Undo { label: label.to_string(), doc, sel });
if self.history.len() > MAX_UNDO {
self.history.remove(0);
}
self.typing_open = false;
}
/// Make the next mutating command part of the previous undo step instead of a new one, so a
/// drag that runs a command every frame is a single Undo. Call it on every frame of the drag
/// except the first; it is consumed by the next `run`.
+3 -2
View File
@@ -77,7 +77,8 @@ fn backspace_and_delete() {
run(&mut s, "text.insert", json!({"text": "cd"}));
run(&mut s, "caret.home", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(text(&s), "abcd");
// Enter capitalised the first word, as Word's AutoCorrect does.
assert_eq!(text(&s), "Abcd");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(text(&s), "bcd");
@@ -134,7 +135,7 @@ fn styles_and_lists() {
assert_eq!(n1.num, n2.num);
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "text.newParagraph", json!({})); // empty item ends the list
assert_eq!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.map(|n| n.num), Some(0));
assert!(s.doc.para_at(&s.sel.focus).unwrap().props.numbering.is_none_or(|n| n.num == 0));
// "1. " autoformat.
run(&mut s, "text.insert", json!({"text": "1."}));
run(&mut s, "text.insert", json!({"text": " "}));
+300
View File
@@ -0,0 +1,300 @@
//! Typing behaviour checked side by side against Word: keystrokes go through the same commands
//! as the keyboard (one `text.insert` per character, Enter, Tab, Backspace), and the expected
//! results are what Word produces for the same keys.
use serde_json::json;
use wordcraft_doc::props::{BorderStyle, VertAlign};
use wordcraft_doc::{Pos, StoryRef};
use crate::Session;
fn s() -> Session {
Session::new(wordcraft_doc::Document::new())
}
fn run(s: &mut Session, id: &str, v: serde_json::Value) {
s.run(id, &v).unwrap_or_else(|e| panic!("{id}: {e}"));
}
/// Type like a person: `\n` is Enter, `\t` is Tab, `\u{8}` is Backspace, everything else one
/// character at a time.
fn typ(s: &mut Session, keys: &str) {
for c in keys.chars() {
match c {
'\n' => run(s, "text.newParagraph", json!({})),
'\t' => run(s, "text.tab", json!({})),
'\u{8}' => run(s, "text.backspace", json!({})),
c => run(s, "text.insert", json!({"text": c.to_string()})),
}
}
}
/// Body paragraphs as (text, list level if in a list, in "List Paragraph").
fn paras(s: &Session) -> Vec<(String, Option<u8>, bool)> {
s.doc
.body
.iter()
.filter_map(|b| b.as_para())
.map(|p| (p.text.clone(), p.props.numbering.filter(|n| n.num != 0).map(|n| n.level), p.props.style.as_deref() == Some("ListParagraph")))
.collect()
}
fn para(text: &str, level: Option<u8>) -> (String, Option<u8>, bool) {
(text.to_string(), level, level.is_some())
}
fn typed(keys: &str) -> Session {
let mut s = s();
typ(&mut s, keys);
s
}
#[test]
fn enter_twice_after_a_list_item_ends_the_list() {
let s = typed("* item\n\ntext");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("text", None)]);
}
#[test]
fn more_enters_after_a_list_make_blank_paragraphs() {
// The "stuck Enter" bug: after the list ended, further Enters did nothing.
let s = typed("* item\n\n\ntext");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("text", None)]);
let s = typed("1. item\n\n\n\nafter");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("", None), para("", None), para("after", None)]);
}
#[test]
fn the_paragraph_after_a_list_is_normal_text() {
let mut s = typed("* Apple\nBanana\n\nAfter list");
let p = s.doc.para_at(&s.sel.focus).unwrap();
assert_eq!(p.props.style, None, "back to Normal, not an indented List Paragraph");
assert_eq!(p.props.numbering, None);
// And the next paragraph too.
typ(&mut s, "\nMore");
assert_eq!(paras(&s).last(), Some(&para("More", None)));
}
#[test]
fn enter_on_an_empty_nested_item_moves_it_up_a_level_first() {
let s = typed("* a\n\tb\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", Some(0))]);
let s = typed("* a\n\tb\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
let s = typed("* a\n\tb\n\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("", None), para("text", None)]);
let s = typed("* a\n\tb\n\tc\n\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("C", Some(2)), para("text", None)]);
let s = typed("1. a\n\tb\n\n\ntext");
assert_eq!(paras(&s), vec![para("A", Some(0)), para("B", Some(1)), para("text", None)]);
}
#[test]
fn shift_tab_and_tab_change_list_levels() {
let mut s = typed("1. One\nTwo\n\tSub a\nSub b\n");
run(&mut s, "text.backTab", json!({}));
typ(&mut s, "Three\n\nDone");
assert_eq!(
paras(&s),
vec![para("One", Some(0)), para("Two", Some(0)), para("Sub a", Some(1)), para("Sub b", Some(1)), para("Three", Some(0)), para("Done", None)]
);
}
#[test]
fn backspace_at_the_start_of_an_item_removes_the_number_then_the_indent() {
let mut s = typed("* One\nTwo");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\u{8}");
// Number gone, still indented with the list.
assert_eq!(paras(&s)[1], ("Two".to_string(), None, true));
typ(&mut s, "\u{8}|");
// Second Backspace: Normal paragraph, not joined to the one above.
assert_eq!(paras(&s), vec![para("One", Some(0)), para("|Two", None)]);
}
#[test]
fn backspace_on_an_empty_item_keeps_the_paragraph() {
let s = typed("* One\n\u{8}Z");
assert_eq!(paras(&s), vec![para("One", Some(0)), ("Z".to_string(), None, true)]);
}
#[test]
fn enter_in_the_middle_or_at_the_start_of_an_item() {
let mut s = typed("* Hello world");
for _ in 0..5 {
run(&mut s, "caret.left", json!({}));
}
typ(&mut s, "\nX");
assert_eq!(paras(&s), vec![para("Hello ", Some(0)), para("Xworld", Some(0))]);
let mut s = typed("* first\nsecond");
run(&mut s, "caret.home", json!({}));
typ(&mut s, "\nY");
assert_eq!(paras(&s), vec![para("First", Some(0)), para("", Some(0)), para("Ysecond", Some(0))]);
}
fn label_at(s: &Session, i: usize) -> String {
let n = s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).unwrap();
s.doc.numbering.level(n.num, n.level).map(|l| l.text.clone()).unwrap_or_default()
}
#[test]
fn list_autoformat_triggers() {
let s = typed("- Item\nNext");
assert_eq!(label_at(&s, 0), "-");
assert_eq!(paras(&s), vec![para("Item", Some(0)), para("Next", Some(0))]);
let s = typed("1) a\nb");
assert_eq!(label_at(&s, 0), "%1)");
let s = typed("a. alpha\nbeta");
assert_eq!(paras(&s), vec![para("Alpha", Some(0)), para("beta", Some(0))]);
assert_eq!(label_at(&s, 0), "%1.");
let n = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.numbering).unwrap();
assert_eq!(s.doc.numbering.level(n.num, 0).map(|l| l.format), Some(wordcraft_doc::section::NumFormat::LowerLetter));
}
#[test]
fn typing_1_dot_starts_numbering_again() {
let s = typed("1. a\nb\n\nmid\n1. c\nd");
let num = |i: usize| s.doc.para_at(&Pos::body(i, 0)).and_then(|p| p.props.numbering).map(|n| n.num);
assert_eq!(num(0), num(1));
assert_eq!(num(3), num(4));
assert_ne!(num(0), num(3), "a new list restarts at 1");
}
#[test]
fn undo_right_after_autoformat_undoes_only_the_autoformat() {
let mut s = typed("* ");
assert!(paras(&s)[0].1.is_some());
run(&mut s, "edit.undo", json!({}));
typ(&mut s, "x");
assert_eq!(paras(&s), vec![("* x".to_string(), None, false)]);
// Same for AutoCorrect.
let mut s = typed("teh ");
run(&mut s, "edit.undo", json!({}));
assert_eq!(s.doc.plain_text(StoryRef::Body), "teh ");
}
#[test]
fn autocorrect_as_you_type() {
let s = typed("teh cat said hello. world (c) \"quoted\" don't -- yes wait... i think so. e.g. this. ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "The cat said hello. World © “quoted” don’t – yes wait… I think so. E.g. this. ");
// Abbreviations and initials don't end sentences.
let s = typed("see e.g. the map. Ask J. smith or Dr. jones ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "See e.g. the map. Ask J. smith or Dr. jones ");
// Fractions; "iPhone" and file names stay as typed.
let s = typed("1/2 cup. iPhone. see readme.txt ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "½ cup. iPhone. See readme.txt ");
}
#[test]
fn enter_finishes_a_word_for_autocorrect() {
let s = typed("hello\nteh end\n");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Hello\nThe end\n");
}
#[test]
fn ordinals_become_superscript() {
let s = typed("the 1st and 22nd and 13th but not 1th ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let sup = |t: &str| {
let i = p.text.find(t).unwrap() + t.len() - 2;
p.props_of_char(i).vert_align == Some(VertAlign::Superscript)
};
assert!(sup("1st") && sup("22nd") && sup("13th"));
assert!(!sup("1th"));
}
#[test]
fn web_addresses_become_links() {
let mut s = typed("see https://example.org/log, then ");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
let at = p.text.find("https").unwrap();
assert_eq!(p.props_of_char(at).link.as_deref(), Some("https://example.org/log"));
assert_eq!(p.props_of_char(p.text.find(',').unwrap()).link, None, "trailing comma stays outside");
typ(&mut s, "more");
let p = s.doc.para_at(&Pos::body(0, 0)).unwrap();
assert_eq!(p.props_of_char(p.text.find("more").unwrap()).link, None);
}
#[test]
fn dashes() {
let s = typed("a -- b and c - d and e--f ");
assert_eq!(s.doc.plain_text(StoryRef::Body), "A – b and c – d and e—f ");
}
#[test]
fn border_line_autoformat() {
let s = typed("Above\n---\nBelow");
assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\nBelow");
let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom).unwrap();
assert_eq!(b.style, BorderStyle::Single);
assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none());
for (keys, style) in [("===", BorderStyle::Double), ("***", BorderStyle::Dotted), ("___", BorderStyle::Thick)] {
let s = typed(&format!("x\n{keys}\n"));
let b = s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).and_then(|b| b.bottom);
assert_eq!(b.map(|b| b.style), Some(style), "{keys}");
}
// At the top of the document the line goes under that paragraph and typing continues below.
let s = typed("---\nText");
assert_eq!(s.doc.plain_text(StoryRef::Body), "\nText");
assert!(s.doc.para_at(&Pos::body(0, 0)).and_then(|p| p.props.borders).is_some());
assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none());
}
#[test]
fn a_large_mixed_document_types_like_word() {
// The corpus typed into Word and WordCraft side by side; the paragraph structure must match.
let mut s = s();
let prose = "The keeper's log runs to four hundred pages, and almost every one begins with the weather. ";
typ(&mut s, &format!("Notes\n{}\n", prose.repeat(20)));
typ(&mut s, "The supplies were:\n* Lamp oil, 1/2 barrel\nWicks of the 1st quality\n\tSpare chimneys\nSpare springs\n");
run(&mut s, "text.backTab", json!({}));
typ(&mut s, "Salt beef\n\nThe schedule:\n1. Signal the boat\nLower the stores\n\tCheck the net\n");
run(&mut s, "text.backTab", json!({}));
typ(&mut s, "Sign the manifest\n\n---\nIn March the storm broke -- and he slept.\n");
typ(&mut s, "a. Inspect the glass\nReplace the seals\n\n");
typ(&mut s, &format!("Afterword. teh keeper retired. {}\n* one more\nsecond\n\n\nFinal.", prose.repeat(10)));
let got: Vec<(String, Option<u8>)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect();
let want: Vec<(&str, Option<u8>)> = vec![
("Notes", None),
("The keeper’s log runs to", None),
("The supplies were:", None),
("Lamp oil, ½ barrel", Some(0)),
("Wicks of the 1st quality", Some(0)),
("Spare chimneys", Some(1)),
("Spare springs", Some(1)),
("Salt beef", Some(0)),
("The schedule:", None),
("Signal the boat", Some(0)),
("Lower the stores", Some(0)),
("Check the net", Some(1)),
("Sign the manifest", Some(0)),
("In March the storm broke", None),
("Inspect the glass", Some(0)),
("Replace the seals", Some(0)),
("Afterword. The keeper re", None),
("One more", Some(0)),
("Second", Some(0)),
("", None),
("Final.", None),
];
let want: Vec<(String, Option<u8>)> = want.into_iter().map(|(t, l)| (t.to_string(), l)).collect();
assert_eq!(got, want);
// Every non-list paragraph is Normal (no list indent left behind).
for (t, l, lp) in paras(&s) {
assert!(l.is_some() || !lp, "{t:?} still a List Paragraph");
}
}
#[test]
fn enter_right_after_starting_a_list_cancels_it() {
let s = typed("* \nPlain");
assert_eq!(paras(&s), vec![para("Plain", None)]);
}
#[test]
fn shift_enter_stays_inside_the_list_item() {
let mut s = typed("* line one");
run(&mut s, "text.lineBreak", json!({}));
typ(&mut s, "cont\ntwo");
assert_eq!(paras(&s), vec![para("Line one\ncont", Some(0)), para("two", Some(0))]);
}
+5 -1
View File
@@ -487,7 +487,11 @@ pub fn layout_para(p: &Paragraph, env: &ParaEnv) -> ParaLayout {
// Break opportunities.
let mut opps = std::collections::HashSet::new();
for (i, o) in unicode_linebreak::linebreaks(&p.text) {
if o == unicode_linebreak::BreakOpportunity::Allowed || i < p.text.len() {
// Word keeps "and/or" and web addresses whole: no break right after a slash (a word
// too long for the line still breaks anywhere).
let after_slash =
p.text.get(..i).is_some_and(|t| t.ends_with('/')) && p.text.get(i..).and_then(|t| t.chars().next()).is_some_and(char::is_alphanumeric);
if (o == unicode_linebreak::BreakOpportunity::Allowed || i < p.text.len()) && !after_slash {
opps.insert(i);
}
}
+20
View File
@@ -470,3 +470,23 @@ fn auto_hyphenation_breaks_long_words() {
let l2 = lay(&d2);
let _ = hyphens(&l2);
}
#[test]
fn no_line_break_right_after_a_slash() {
// Word keeps "and/or" and web addresses whole on a line when they fit.
let text = "word and/or https://example.org/keepers/log/winter ".repeat(40);
let d = Document::from_text(&text);
let l = lay(&d);
let mut lines = 0;
for p in &l.pages {
for it in &p.items {
if let Placed::Lines { para, l0, l1, .. } = it {
for line in &para.lines[*l0..*l1] {
lines += 1;
assert!(!text[..line.start].ends_with('/'), "line starts after a slash: {:?}", &text[line.start..line.stop]);
}
}
}
}
assert!(lines > 10, "{lines} lines");
}
+3 -1
View File
@@ -62,7 +62,9 @@ fn main() {
step(&mut harness);
if let Ok(r) = reply.try_recv() {
let s = r.to_string();
println!("{m}: {}", &s[..s.len().min(300)]);
// `UI_SHOT_FULL=1` prints whole replies (e.g. `document.inspect` for scripted checks).
let cut = if std::env::var_os("UI_SHOT_FULL").is_some() { s.len() } else { s.len().min(300) };
println!("{m}: {}", s.get(..cut).unwrap_or(&s));
break;
}
}
+59
View File
@@ -0,0 +1,59 @@
# Typing parity with Word
What Word does when you type, observed black-box (same keystrokes typed into Word and
WordCraft, results read back paragraph by paragraph) and pinned by tests in
`crates/engine/src/tests_typing.rs`. When typing behaviour changes, change it here and in the
tests together.
## Lists
| Keys | Word's result | Test |
|---|---|---|
| `* item` Enter Enter `text` | the second Enter ends the list; `text` is a **Normal** paragraph (no list indent left behind) | `enter_twice_after_a_list_item_ends_the_list`, `the_paragraph_after_a_list_is_normal_text` |
| … Enter ×3, ×4 | each further Enter adds a blank Normal paragraph (never swallowed) | `more_enters_after_a_list_make_blank_paragraphs` |
| Enter on an empty **nested** item | moves it up one level; only a top-level empty item leaves the list | `enter_on_an_empty_nested_item_moves_it_up_a_level_first` |
| `* ` Enter (nothing typed) | list cancelled, Normal paragraph | `enter_right_after_starting_a_list_cancels_it` |
| Tab / Shift+Tab at the start of an item | demote / promote | `shift_tab_and_tab_change_list_levels` |
| Backspace at the start of an item | 1st: number removed, indent kept; 2nd: Normal paragraph — **not** joined to the one above | `backspace_at_the_start_of_an_item_removes_the_number_then_the_indent` |
| Enter mid-item / at item start | splits the item / inserts an empty item above | `enter_in_the_middle_or_at_the_start_of_an_item` |
| Shift+Enter in an item | line break inside the same item | `shift_enter_stays_inside_the_list_item` |
| `* ` `- ` `1. ` `1) ` `a. ` `a) ` `A. ` `i. ` `I. ` + text | bullet, dash bullet, 1. 1) a. a) A. i. I. lists | `list_autoformat_triggers` |
| `1. ` after other text | a new list restarting at 1 (never continues an earlier list) | `typing_1_dot_starts_numbering_again` |
| ⌘Z right after an AutoFormat | undoes only the AutoFormat (`* ` comes back as text) | `undo_right_after_autoformat_undoes_only_the_autoformat` |
## AutoCorrect and AutoFormat as you type
Applied when a word is finished by a space, punctuation **or Enter** (`enter_finishes_a_word_for_autocorrect`):
- First letter of a sentence (paragraph or list item start, after `. ! ?`) is capitalised; not
after abbreviations (`e.g.`, `Dr.`, …) or initials (`J.`), and not for mixed-case words
(`iPhone`) or single-letter labels (`a.`). `teh`→`the`, `i`→`I`, `(c)`→`©`, `(tm)`→`™`,
`wait...`→`wait…`, smart quotes (`autocorrect_as_you_type`).
- `1/2 1/4 3/4` → `½ ¼ ¾`; `1st 22nd 13th` get superscript suffixes (`ordinals_become_superscript`).
- `word -- word` → en dash, `word - word` → en dash, `word--word` → em dash (`dashes`).
- Web addresses become links; following text isn't linked (`web_addresses_become_links`).
- Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above
(single, double, dotted, thick, wave, triple) (`border_line_autoformat`).
## Layout
- No line break right after a slash (`and/or`, URLs) unless the word can't fit a line
(`layout::tests::no_line_break_right_after_a_slash`).
## Known gaps
- Word's body font Aptos isn't installed outside Office; our substitute (Carlito, else Helvetica
Neue) is wider, so lines wrap earlier than in Word. Needs a metric-matched open substitute.
- `:)` inside a word (`ok:)`) isn't replaced (Word replaces it).
- Not yet compared: `+---+---+` Enter (table AutoFormat), `> ` arrow bullets, undo after a
border line, Correct TWo INitial CApitals.
## How to compare
- WordCraft: the headless `ui_shot` example (no window, no focus) with `UI_SHOT_FULL=1` prints
the whole `document.inspect`; drive it with `ui.text` / `ui.key` lines so keys take the real
input path.
- Word: the machine is shared. Prefer AppleScript that doesn't take focus (open, read
paragraphs, export PDF). Keystroke runs steal focus and race the person at the keyboard: keep
them short, read the document back to verify, and ask first. Screenshots of Word's window only,
under `plan/word/screenshots/`.