AutoFormat: +---+ tables, arrow bullets, smileys inside words, border-line undo (#546) (#547)

- `+---+------+` Enter turns the paragraph into a one-row table across the text
  width, columns proportional to the hyphens, caret in the first cell (body,
  top level, at most 63 columns).
- `> ` `-> ` `=> ` start a bulleted list with an arrow bullet (➢ ➔ ⇨).
- Smileys `:)` `:-)` `:(` `:-(` `:|` are replaced also straight after a word.
- Undo right after a border line or table AutoFormat restores the typed
  characters and keeps the Enter (its own undo step).

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
Valentin Klein
2026-10-11 21:11:22 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 Brandon Thomas
parent 46d5e99b3e
commit 850fa0576a
5 changed files with 178 additions and 20 deletions
+1
View File
@@ -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%) |
+96 -15
View File
@@ -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<bool, CmdError> {
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<bool, CmdError> {
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<bool, CmdError> {
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<usize>),
}
/// AutoFormat border lines: Enter after "---", "___", "===", "***", "~~~" or "###".
fn border_line(s: &Session, at: &Pos) -> Option<Border> {
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<bool, CmdError> {
|| !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<bool, CmdError> {
'*' => (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<Vec<usize>> {
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<bool, CmdError> {
};
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<bool, CmdError> {
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::<usize>().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).
+12
View File
@@ -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;
+53
View File
@@ -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.
+16 -5
View File
@@ -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) |