diff --git a/AGENTS.md b/AGENTS.md index b6af66b21..ac74d604f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`. diff --git a/CLAUDE.md b/CLAUDE.md index f725e1e44..a852ec113 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/crates/doc/src/numbering.rs b/crates/doc/src/numbering.rs index 111d2a24e..7cbae720d 100644 --- a/crates/doc/src/numbering.rs +++ b/crates/doc/src/numbering.rs @@ -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 { 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] diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index c2a56d08b..9c51cc9e5 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 { + // `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)?; diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index f295b382f..3eb15e4da 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -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(¶) + { + 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 { 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 { + 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); diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 0f8fd23f0..686d939f6 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -45,6 +45,9 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("<-", "←"), ("=>", "⇒"), (":)", "☺"), + ("1/2", "½"), + ("1/4", "¼"), + ("3/4", "¾"), ]; pub fn specs() -> Vec { @@ -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 { + 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] diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f9cc63054..df055d600 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -153,3 +153,5 @@ pub mod p { #[cfg(test)] mod tests; +#[cfg(test)] +mod tests_typing; diff --git a/crates/engine/src/session.rs b/crates/engine/src/session.rs index 2a9495ad4..d7b3be33f 100644 --- a/crates/engine/src/session.rs +++ b/crates/engine/src/session.rs @@ -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`. diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 5ae75e337..c3d55ed63 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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": " "})); diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs new file mode 100644 index 000000000..752de8e7b --- /dev/null +++ b/crates/engine/src/tests_typing.rs @@ -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, 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) -> (String, Option, 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(¶("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)> = paras(&s).into_iter().map(|(t, l, _)| (t.chars().take(24).collect(), l)).collect(); + let want: Vec<(&str, Option)> = 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)> = 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))]); +} diff --git a/crates/layout/src/para.rs b/crates/layout/src/para.rs index a734d2e33..05a530c0e 100644 --- a/crates/layout/src/para.rs +++ b/crates/layout/src/para.rs @@ -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); } } diff --git a/crates/layout/src/tests.rs b/crates/layout/src/tests.rs index 650772b12..49a89d95e 100644 --- a/crates/layout/src/tests.rs +++ b/crates/layout/src/tests.rs @@ -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 ¶.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"); +} diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index 4c877ea65..be94ee597 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -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; } } diff --git a/docs/typing-parity.md b/docs/typing-parity.md new file mode 100644 index 000000000..9b95a1287 --- /dev/null +++ b/docs/typing-parity.md @@ -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/`.