diff --git a/ROADMAP.md b/ROADMAP.md index cf2e68306c..3a89f8c834 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -131,6 +131,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm | Date | What landed | |---|---| +| 2026-10-11 | Typing parity (#546): `+---+` Enter makes a table, `> ` `-> ` `=> ` arrow bullets, smileys inside words (`ok:)`), Undo right after a border line brings the characters back | | 2026-10-11 | Windows: DirectX 12 shaders compile with FXC, built into Windows, so the app no longer quits at launch with "Parent device is lost" when no matching `dxcompiler.dll` + `dxil.dll` pair is around (#603) | | 2026-10-11 | Insert › SmartArt (#497): list, process, cycle, hierarchy, pyramid, radial, matrix and Venn graphics laid out by our own code, Text Pane, SmartArt Design tab (Add Shape, Promote, Demote, layouts, colours, Reset); saved as DOCX diagram parts with the drawing. Catalog 410/441 (93.0%) | | 2026-10-11 | Outline view (#493): Outlining tab, outline symbols and indents, collapse/expand, Show Level, first line only, plain text; catalog 428/450 → 441/463 (95.2%) | diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 80368ef833..1f98d19d08 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -3,7 +3,7 @@ use serde_json::Value; use wordcraft_doc::para::{COLUMN_BREAK, NB_HYPHEN, NBSP, OBJ, PAGE_BREAK, SOFT_HYPHEN}; use wordcraft_doc::props::{Border, BorderStyle, NumRef}; -use wordcraft_doc::{Block, ListKind, Pos}; +use wordcraft_doc::{Block, ListKind, Path, Pos, StoryRef, Table}; use super::{delete_selection, non_mac, sel_result, split_para, type_text}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; @@ -119,7 +119,7 @@ fn insert(s: &mut Session, v: &Value) -> CmdResult { sel_result(s) } -/// "* " / "- " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph. +/// "* " / "- " / "> " → bullets, "1. " / "a) " → numbering, typed at the start of a paragraph. fn list_autoformat(s: &mut Session) -> Result { let f = s.sel.focus.clone(); let Some(para) = s.doc.para_at(&f) else { return Ok(false) }; @@ -127,8 +127,12 @@ fn list_autoformat(s: &mut Session) -> Result { return Ok(false); } let kind = match para.text.as_str() { - "* " | "> " => ListKind::Bullet, + "* " => ListKind::Bullet, "- " => ListKind::BulletChar('-'), + // Arrow bullets (Word draws these from a symbol font; we use the Unicode arrows). + "> " => ListKind::BulletChar('\u{27A2}'), + "-> " => ListKind::BulletChar('\u{2794}'), + "=> " => ListKind::BulletChar('\u{21E8}'), "1. " => ListKind::Numbered, "1) " => ListKind::NumberedParen, "a. " => ListKind::LowerLetterDot, @@ -193,12 +197,19 @@ 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) }; +/// An AutoFormat that Enter applies to the paragraph just finished. +enum EnterAutoFormat { + /// "---" and friends: a bottom border. + Border(Border), + /// "+---+-----+": a one-row table; the dash count of each column. + Table(Vec), +} + +/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###". +fn border_line(s: &Session, at: &Pos) -> Option { + let para = s.doc.para_at(at)?; let text = para.text.as_str(); - let Some(c) = text.chars().next() else { return Ok(false) }; + let c = text.chars().next()?; if !s.prefs.autocorrect.enabled || !s.prefs.autocorrect.border_lines || at.off != text.len() @@ -206,7 +217,7 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { || !text.chars().all(|x| x == c) || para.props.numbering.is_some_and(|n| n.num != 0) { - return Ok(false); + return None; } let (style, width) = match c { '-' => (BorderStyle::Single, 0.75), @@ -215,9 +226,39 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { '*' => (BorderStyle::Dotted, 2.25), '~' => (BorderStyle::Wave, 0.75), '#' => (BorderStyle::Triple, 0.75), - _ => return Ok(false), + _ => return None, }; - let line = Border { style, width, color: None, space: 1.0 }; + Some(Border { style, width, color: None, space: 1.0 }) +} + +/// AutoFormat tables: a body paragraph that is exactly "+---+-----+" (plus signs around runs +/// of hyphens). Returns the hyphen count of each column; more columns than a table can hold +/// leave the text alone. +fn table_line(s: &Session, at: &Pos) -> Option> { + let para = s.doc.para_at(at)?; + let text = para.text.as_str(); + if !s.prefs.autocorrect.enabled + || at.story != StoryRef::Body + || at.path.0.len() != 1 + || at.off != text.len() + || para.props.numbering.is_some_and(|n| n.num != 0) + { + return None; + } + let inner = text.strip_prefix('+')?.strip_suffix('+')?; + let mut cols = Vec::new(); + for run in inner.split('+') { + if run.is_empty() || !run.bytes().all(|b| b == b'-') || cols.len() >= wordcraft_doc::table::MAX_COLS { + return None; + } + cols.push(run.len()); + } + Some(cols) +} + +/// Turn the border-line characters into a bottom border on the paragraph above (or this one +/// at the top of a story). +fn apply_border_line(s: &mut Session, at: &Pos, line: Border) -> Result<(), CmdError> { let set_bottom = |p: &mut wordcraft_doc::Paragraph| { let mut b = p.props.borders.unwrap_or_default(); b.bottom = Some(line); @@ -226,8 +267,8 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { }; 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)?; + let len = cur.len(); 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(_)))) { @@ -245,8 +286,34 @@ fn border_line_autoformat(s: &mut Session, at: &Pos) -> Result { s.sel = Selection::caret(new); } } - s.goal_x = None; - Ok(true) + Ok(()) +} + +/// Replace the "+---+" characters with a one-row table across the text width, each column as +/// wide as its share of the hyphens; the emptied paragraph stays after the table and the caret +/// goes into the first cell. +fn apply_table_line(s: &mut Session, at: &Pos, cols: &[usize]) -> Result<(), CmdError> { + let total = cols.iter().sum::().max(1) as f32; + let block = at.path.0.first().copied().unwrap_or(0) as usize; + let width = s.doc.sections().get(s.doc.section_index_of(block)).map(|(_, sp)| sp.text_width()).unwrap_or(468.0).max(36.0); + let mut t = Table::new(1, cols.len(), width); + for (i, d) in cols.iter().enumerate() { + let w = (width * *d as f32 / total).max(6.0); + if let Some(g) = t.grid.get_mut(i) { + *g = w; + } + if let Some(c) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(i)) { + c.props.width = Some(w); + } + } + let p = s.doc.para_mut(at.story, &at.path)?; + let len = p.len(); + p.delete(0, len)?; + s.doc.insert_block(at.story, &at.path, Block::Table(t))?; + let mut first = at.path.0.clone(); + first.extend([0, 0, 0]); + s.sel = Selection::caret(Pos { story: at.story, path: Path(first), off: 0 }); + Ok(()) } /// Toggle Unicode Character (Alt+X): the hex code before the caret (up to six digits, `U+` @@ -325,7 +392,21 @@ fn new_paragraph(s: &mut Session, _: &Value) -> CmdResult { } super::controls::prepare_edit(s)?; let mut at = delete_selection(s)?; - if border_line_autoformat(s, &at)? { + let auto = border_line(s, &at).map(EnterAutoFormat::Border).or_else(|| table_line(s, &at).map(EnterAutoFormat::Table)); + if let Some(auto) = auto { + // Word: Undo right after reverts only the AutoFormat, so the typed characters come back + // and the Enter stays. Record that plain Enter as its own undo step. + let (doc, sel) = (s.doc.clone(), s.sel.clone()); + let new = split_para(s, &at)?; + s.sel = Selection::caret(new); + let plain_doc = std::mem::replace(&mut s.doc, doc); + let plain_sel = std::mem::replace(&mut s.sel, sel); + match auto { + EnterAutoFormat::Border(line) => apply_border_line(s, &at, line)?, + EnterAutoFormat::Table(cols) => apply_table_line(s, &at, &cols)?, + } + s.goal_x = None; + s.push_undo("AutoFormat", plain_doc, plain_sel); return sel_result(s); } // Enter finishes a word like a space does ("teh" → "the", sentence capitals, links). diff --git a/crates/engine/src/cmd/tools.rs b/crates/engine/src/cmd/tools.rs index 9a37ecab7c..6ad3fc46c8 100644 --- a/crates/engine/src/cmd/tools.rs +++ b/crates/engine/src/cmd/tools.rs @@ -12,6 +12,9 @@ use wordcraft_doc::{Block, Document, Paragraph, Pos, RevisionKind, StoryRef, par use super::{delete_selection, sel_result}; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; +/// Smileys replaced even when typed straight after a word ("ok:)"), as Word does. +const SMILEYS: &[&str] = &[":-)", ":-(", ":)", ":(", ":|"]; + /// Built-in AutoCorrect replacements (our own list of common typos and symbols). pub const AUTOCORRECT: &[(&str, &str)] = &[ ("teh", "the"), @@ -48,6 +51,10 @@ pub const AUTOCORRECT: &[(&str, &str)] = &[ ("<-", "←"), ("=>", "⇒"), (":)", "☺"), + (":-)", "☺"), + (":(", "☹"), + (":-(", "☹"), + (":|", "\u{1F610}"), ("1/2", "½"), ("1/4", "¼"), ("3/4", "¾"), @@ -725,6 +732,11 @@ pub fn autocorrect_word(s: &mut Session, on_enter: bool) -> Result<(), CmdError> let (from, rep) = match found { Some(r) => (start, Some(r)), None if ac.replace_text && word.len() > 3 && word.ends_with("...") => (start + word.len() - 3, Some("…".to_string())), + None if ac.replace_text => match SMILEYS.iter().find(|m| word.len() > m.len() && word.ends_with(**m)) { + // "ok:)" → "ok☺": only the smiley at the end of the word is replaced. + Some(m) => (start + word.len() - m.len(), AUTOCORRECT.iter().find(|(a, _)| a == m).map(|(_, b)| b.to_string())), + None => (start, None), + }, None => (start, None), }; let mut caret = f.off; diff --git a/crates/engine/src/tests_typing.rs b/crates/engine/src/tests_typing.rs index 52cd8ca069..c9ed6089f9 100644 --- a/crates/engine/src/tests_typing.rs +++ b/crates/engine/src/tests_typing.rs @@ -257,6 +257,59 @@ fn border_line_autoformat() { assert!(s.doc.para_at(&Pos::body(1, 0)).unwrap().props.borders.is_none()); } +#[test] +fn undo_right_after_a_border_line_brings_the_characters_back() { + let mut s = typed("Above\n---\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n"); + run(&mut s, "edit.undo", json!({})); + // The AutoFormat is undone on its own: the characters are back, the Enter stays. + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---\n"); + assert!(s.doc.body.iter().filter_map(|b| b.as_para()).all(|p| p.props.borders.is_none())); + assert_eq!(s.sel.focus, Pos::body(2, 0)); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "Above\n---"); +} + +#[test] +fn plus_and_hyphen_line_becomes_a_table() { + let mut s = typed("+--+------+\nA"); + let Some(wordcraft_doc::Block::Table(t)) = s.doc.body.first().map(|b| &**b) else { panic!("no table: {:?}", paras(&s)) }; + assert_eq!(t.rows.len(), 1); + assert_eq!(t.grid.len(), 2); + assert!((t.grid[1] / t.grid[0] - 3.0).abs() < 0.01, "widths follow the hyphens: {:?}", t.grid); + assert_eq!(t.rows[0].cells[0].blocks[0].as_para().map(|p| p.text.as_str()), Some("A"), "the caret is in the first cell"); + assert!(s.doc.body.get(1).and_then(|b| b.as_para()).is_some_and(|p| p.is_empty()), "a paragraph follows the table"); + // Undo right after brings the typed characters back, the Enter stays. + run(&mut s, "edit.undo", json!({})); + run(&mut s, "edit.undo", json!({})); + assert_eq!(s.doc.plain_text(StoryRef::Body), "+--+------+\n"); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); + // Not a table: text around it, or more columns than a table can hold. + let s = typed("x +--+--+\n"); + assert_eq!(s.doc.plain_text(StoryRef::Body), "X +--+--+\n"); + let s = typed(&format!("+{}\n", "-+".repeat(64))); + assert!(s.doc.body.iter().all(|b| b.as_para().is_some())); +} + +#[test] +fn arrow_bullets() { + for (keys, bullet) in [("> ", "\u{27A2}"), ("-> ", "\u{2794}"), ("=> ", "\u{21E8}")] { + let s = typed(&format!("{keys}item\nnext")); + assert_eq!(paras(&s), vec![para("Item", Some(0)), para("next", Some(0))], "{keys}"); + assert_eq!(label_at(&s, 0), bullet, "{keys}"); + } + // An arrow list doesn't join a plain bullet list. + let s = typed("* a\n\n> b"); + assert_eq!(label_at(&s, 0), "\u{2022}"); + assert_eq!(label_at(&s, 1), "\u{27A2}"); +} + +#[test] +fn smileys_are_replaced_inside_words_too() { + let s = typed("so ok:) sad:( meh:| :) fine:-) "); + assert_eq!(s.doc.plain_text(StoryRef::Body), "So ok\u{263A} sad\u{2639} meh\u{1F610} \u{263A} fine\u{263A} "); +} + #[test] fn a_large_mixed_document_types_like_word() { // The corpus typed into Word and WordCraft side by side; the paragraph structure must match. diff --git a/docs/typing-parity.md b/docs/typing-parity.md index 7ca93595a2..2705b10846 100644 --- a/docs/typing-parity.md +++ b/docs/typing-parity.md @@ -1,6 +1,6 @@ # Typing parity with Word -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** trivial (outline keys in Outline view, #493) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-11 · **Change:** minor (AutoFormat tables, arrow bullets, smileys inside words, undo after a border line, #546; previously minor: Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4 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 @@ -22,6 +22,7 @@ overall picture is in [`target-app-parity.md`](target-app-parity.md). | Shift+Enter in an item | line break inside the same item | `shift_enter_stays_inside_the_list_item` | | Paste: Keep Text Only `A`, empty line, `B` into an empty item | three paragraphs `A`, empty, `B`: pasted paragraph breaks are text, not Enter (they never end the list) | `plain_text_paste_keeps_an_empty_paragraph_in_a_list` | | `* ` `- ` `1. ` `1) ` `a. ` `a) ` `A. ` `i. ` `I. ` + text | bullet, dash bullet, 1. 1) a. a) A. i. I. lists | `list_autoformat_triggers` | +| `> ` `-> ` `=> ` + text | bulleted list with an arrow bullet (➢ ➔ ⇨; Word takes them from a symbol font, we use the Unicode arrows) ¹ | `arrow_bullets` | | `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` | @@ -70,7 +71,16 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin paragraphs Enter makes from it aren't links (#413; `a_link_covers_only_its_own_text`, `typing_at_either_end_of_a_link_is_not_linked`). - Enter after `---` `===` `***` `___` `~~~` `###` → a bottom border on the paragraph above - (single, double, dotted, thick, wave, triple) (`border_line_autoformat`). + (single, double, dotted, thick, wave, triple) (`border_line_autoformat`). ⌘Z right after brings the + characters back and removes the border; the Enter stays (one undo step) ¹ + (`undo_right_after_a_border_line_brings_the_characters_back`). +- `+---+------+` Enter (plus signs around runs of hyphens, the whole paragraph) → a one-row + table across the text width, each column as wide as its share of the hyphens; the caret goes + into the first cell. ⌘Z right after brings the characters back. More than 63 columns stay + text ¹ (`plus_and_hyphen_line_becomes_a_table`). +- Smileys `:)` `:-)` `:(` `:-(` `:|` → ☺ ☹ 😐, also straight after a word (`ok:)` → `ok☺`) ¹ + (`smileys_are_replaced_inside_words_too`). + - Names of days are capitalised (`monday` → `Monday`). AutoCorrect Options (#412, `tools.autocorrect`, saved per user) switch each of these off: replacements, sentence and table-cell capitals, days, smart quotes, fractions, ordinals, dashes, links, bulleted and @@ -78,6 +88,8 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin and "don't capitalize after" exceptions extend the abbreviation list. A replacement of several words takes a sentence capital on its first (`autocorrect_options_change_what_typing_does`). +¹ From Word's documented AutoFormat and AutoCorrect options; not yet keystroke-compared with +Word. ## Editing options (File › Options › Advanced) | Keys / setting | Result | Test | @@ -94,12 +106,10 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin - 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). - Moving paragraphs with Track Changes on isn't recorded as a tracked move; whole table rows don't move yet (a selection across cells is refused). Go Back's places aren't mapped through later edits (they are clamped to the document). -- Not yet compared: `+---+---+` Enter (table AutoFormat), `> ` arrow bullets, undo after a - border line, Correct TWo INitial CApitals. +- Not yet compared: Correct TWo INitial CApitals; the rows marked ¹ above. ## How to compare @@ -115,6 +125,7 @@ Applied when a word is finished by a space, punctuation **or Enter** (`enter_fin | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | AutoFormat tables from `+---+`, arrow bullets, smileys inside words, undo after a border line (#546) | | 2026-10-11 | trivial | Outline view keys: expand/collapse, Show Level, moving and promoting with collapsed content (#493) | | 2026-10-11 | minor | Editing options: typing replaces selection, overtype and the Insert key (#485) | | 2026-10-11 | minor | Pasted paragraph breaks: tracked under Track Changes (#417), kept in a list by Keep Text Only (#422) |