Tables: Backspace removes a selected table, rows or columns; Delete clears them (part of #1844) (#2005)

* WIP for #1844

* Tables: Backspace removes a selected table, rows or columns; Delete clears them (#1844)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* Tables: delete_range edge-case tests, single-cell Backspace clears, deep tables kept (#1844)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-18 04:42:35 -04:00
1 parent 3787af0a67
commit da8b7b7feb
7 files changed
+460 -33

No files matched your search

+3 -1
View File
@@ -2,7 +2,7 @@
**Stage: alpha** · next: beta, ~15 points (60% → 75% ready for real work) and ~150–230 h away
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** trivial (Backspace removes a selected table, rows or columns, #1844; previously trivial: progress log: mixed Hebrew/Latin text keeps its order in Word, #1543; previously trivial: progress log: web typing in non-Latin text no longer repeats, #1480; previously trivial: progress log: font family caches bounded and math font lookups follow font changes, #1354; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: numbered headings, #1793; previously trivial: Navigation Find exposes Results, #2096; previously trivial: progress log: Columns on selected text, #2024; previously trivial: progress log: legacy outline, shadow, emboss and imprint text effects drawn, #2534; previously trivial: progress log: Indonesian proofing, #498; previously trivial: progress log: DOCX glossary documents (building blocks); previously trivial: progress log: Read Mode keeps the keyboard and shows whole, unobscured columns, #1772); previously trivial: progress log: font list entries of tall-ascent fonts no longer cut off, #2570); previously trivial: progress log: Split Cells and Convert Text to Table dialogs follow the Word check, #2592; previously trivial: a new source's Tag is made like Word's, three letters of the author and two digits of the year with a number added when taken, and an edit keeps the Tag, #2094; previously trivial: Web Layout fills the window width, #1876; previously trivial: progress log: Different First Page headers reachable from page 1, #2826; previously trivial: Language dialog keeps inherited proofing language and no-proof choices (#2717); previously trivial: progress log: web preferences kept between visits, #2132; previously trivial: progress log: undrawable private-use list bullets show the level's default bullet, #1683); previously trivial: table style editor formats every part, #714; previously trivial: VML freeforms read and kept on save; previously trivial: progress log: consistent preferred cell widths; previously trivial: multi-paragraph field instructions no longer add blank lines; previously trivial: page-break-only list paragraphs hide their labels; previously trivial: progress log: grouped `flowChartOr` shapes draw their circle and crossing spokes; previously trivial: progress log: copies keep their formatting in other programs, #1523; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-17 · **Change:** trivial (progress log: pasted blockquotes are indented paragraphs, #1270; previously trivial: progress log: print-area strings translated in every catalog; previously trivial: progress log: comments readable in the dark theme, #2045; previously trivial: progress log: ODT sections, #1379; previously trivial: progress log: table deletes, Change Case and Unlink Fields respect Track Changes and locked content controls, #1329; previously trivial: progress log: rounded rectangle corners, #1476; previously trivial: progress log: Save As type list, #1399; previously trivial: progress log: privacy options on save, #1062; previously trivial: progress log: equation pictures in theme colours on Windows, #1002; previously trivial: symbol fonts, #776; previously trivial: the save prompt answers from the keyboard, #861; previously trivial: table column and row markers on the rulers, #700; previously trivial: table borders and the resize handle drag on the page, #217; previously trivial: formatting revisions landed, #41; previously trivial: Draw tab ink, #307, and Draw Table and Eraser, #303, landed: catalog 402/434; previously minor: readiness table with hours per audience; full number back to the additive weighted sum, 60%; mainstream and essentials numbers added; alpha gate checked; previously major: full re-measure against Word for Mac 16.113.4; restructured to the craftrules progress-docs standard) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Printing update:** 2026-10-12 · direct desktop printing through craft-print; details below.
@@ -137,6 +137,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
- 2026-10-15: Find opens Navigation on Results; editing the query restores Results after manual Headings/Pages navigation. Typed and selected-text searches retain document contents (#2096).
| Date | What landed |
| 2026-10-15 | Backspace with a whole table selected removes it, as in Word, and selected whole rows or columns go the same way; Delete clears them and keeps the table. A selection that covers a table and the paragraph before or after it removes the table, also when it starts inside the table or the table is nested (#1844) |
| 2026-10-15 | Image size limits are checked before allocating (#1321): a picture whose header declares more RGBA bytes than the 512 MiB budget is refused before decoding (render and PDF share one helper in `wordcraft-geom`), and page rasters are capped at 100 million pixels in total, scaled down rather than only per side. |
| 2026-10-15 | Saved DOCX files keep the order of mixed right-to-left and left-to-right text in Word: Hebrew around a Latin name, numbers, brackets or an e-mail address no longer changes places, and Word reports the Hebrew as Hebrew rather than Arabic (#1543). |
| 2026-10-15 | Web: typing in Cyrillic or another non-Latin script no longer repeats text. WordCraft now honours the input method's request to erase around the caret before retyping (#1480). Chrome can still move the hidden text field's caret, so later letters may still land out of order. That cause is still open |
@@ -465,6 +466,7 @@ Ranked, to beta (detail and remaining milestones in [docs/roadmap.md](docs/roadm
| 2026-10-14 | trivial | Progress log: Update Fields reaches headers, footers and text boxes (#1706) |
| 2026-10-15 | trivial | Progress log: font family caches bounded; math font resolution follows font changes (#1354) |
| 2026-10-15 | trivial | Progress log: web typing in non-Latin text no longer repeats (#1480) |
| 2026-10-15 | trivial | Progress log: Backspace removes a selected table, rows or columns (#1844) |
| 2026-10-14 | trivial | Progress log: row table border exceptions |
| 2026-10-12 | minor | Czech proofing review: short-word splits rank after deletion, NOSUGGEST excludes suggestions while accepting typed forms, affix comments apply only to headers, ratios keep spaces and repeated-word messages use Czech quotes; synthetic engine end-to-end and latency checks |
| 2026-10-12 | minor | Czech spelling from the installed Hunspell dictionary, runtime-table suggestions and generic checks; Czech interface writes cs-CZ documents; no Czech hyphenation patterns bundled; estimates unchanged |
+225 -30
View File
@@ -74,6 +74,19 @@ impl Fragment {
}
}
/// Whether block `b`, in a block list the paragraph walk reaches at `depth`, has cells the walk
/// skips ([`crate::PARA_WALK_DEPTH`]). Bounded by that depth.
fn deeper_than_walk(b: &Block, depth: usize) -> bool {
match b {
Block::Para(_) => false,
Block::Table(t) => t
.rows
.iter()
.flat_map(|r| &r.cells)
.any(|c| depth + 1 > crate::PARA_WALK_DEPTH || c.blocks.iter().any(|x| deeper_than_walk(x, depth + 1))),
}
}
fn order(a: &Pos, b: &Pos) -> (Pos, Pos) {
if a <= b { (a.clone(), b.clone()) } else { (b.clone(), a.clone()) }
}
@@ -425,7 +438,9 @@ impl Document {
return Ok(a);
}
// Different containers (range enters or leaves a table): clear text in every paragraph
// inside; remove whole tables that are entirely covered when at the same level as `a`.
// inside, then remove the tables it covers whole (below).
// Whether `b` is at its paragraph's end, before that paragraph is trimmed.
let b_at_end = self.para(b.story, &b.path).is_some_and(|p| p.len() == b.off);
let paths = self.paths_between(&a, &b);
for p in paths.iter().rev() {
let (from, to) = {
@@ -436,41 +451,68 @@ impl Document {
};
self.para_mut(a.story, p)?.delete_except(from, to, &kept_in(p))?;
}
// Drop top-level tables strictly between a and b at a's level.
if a.path.depth() == 0 && b.path.depth() > 0 {
let first_b = b.path.0.first().copied().unwrap_or(0) as usize;
let ia = a.path.last();
let bl = self.container_mut(a.story, &a.path)?;
let mut i = first_b;
while i > ia + 1 {
i -= 1;
// Tables the range covers whole, in the innermost container holding both ends, go (#1198,
// #1844): those strictly between the ends, the one `a` is in when it starts at that
// table's very start, and the one `b` is in when it ends at that table's very end. A range
// that is one table and nothing else only empties it (Word's Delete keeps the table).
let common = a.path.0.iter().zip(&b.path.0).take_while(|(x, y)| x == y).count();
let level = common - common % 3;
let (Some(&ia), Some(&ib)) = (a.path.0.get(level), b.path.0.get(level)) else { return Ok(a) };
let (ia, ib) = (ia as usize, ib as usize);
if ia >= ib {
return Ok(a);
}
let prefix = a.path.0.get(..level).unwrap_or_default().to_vec();
let block_path = |i: usize| {
let mut v = prefix.clone();
v.push(i as u32);
Path(v)
};
// Paragraphs inside the block at index `i` of the container, in document order.
let paths = self.para_paths(a.story);
let inside = |i: usize| -> Vec<&Path> {
let pre = block_path(i);
paths.iter().filter(|q| q.0.len() > pre.0.len() && q.0.starts_with(&pre.0)).collect()
};
// A table with cells deeper than the paragraph walk goes has paragraphs `inside` never
// lists, so where it starts or ends isn't known: it is never taken as covered.
let walked = |i: usize| self.container(a.story, &block_path(i)).and_then(|bl| bl.get(i)).is_some_and(|b| !deeper_than_walk(b, level / 3));
let a_covers = a.path.0.len() > level + 1 && a.off == 0 && walked(ia) && inside(ia).first() == Some(&&a.path);
let b_covers = b.path.0.len() > level + 1 && walked(ib) && inside(ib).last() == Some(&&b.path) && b_at_end;
let first = if a_covers { ia } else { ia + 1 };
let last = if b_covers { ib } else { ib - 1 };
let mut removed = 0;
{
let bl = self.container_mut(a.story, &block_path(ia))?;
for i in (first..=last.min(bl.len().saturating_sub(1))).rev() {
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
bl.remove(i);
removed += 1;
}
}
}
// From the very start of a top-level table to a top-level paragraph (Select All in a
// document that starts with a table, #1198): the tables are covered whole and go too,
// and the paragraphs left between join.
if a.path.depth() > 0 && b.path.depth() == 0 && a.off == 0 {
let ta = a.path.0.first().copied().unwrap_or(0) as usize;
let first = self.para_paths(a.story).into_iter().find(|q| q.0.first().is_some_and(|t| *t as usize == ta));
if first.as_ref() == Some(&a.path) {
let ib = b.path.last();
let bl = self.container_mut(a.story, &b.path)?;
let mut removed = 0;
for i in (ta..ib).rev() {
if matches!(bl.get(i).map(|b| &**b), Some(Block::Table(_))) {
bl.remove(i);
removed += 1;
}
}
let at = Pos { story: a.story, path: Path::top(ta), off: 0 };
let end = Pos { story: a.story, path: Path::top(ib.saturating_sub(removed)), off: 0 };
return self.delete_range(&at, &end);
if removed == 0 {
return Ok(a);
}
// A story or cell always ends with a paragraph.
if bl.is_empty() || matches!(bl.last().map(|b| &**b), Some(Block::Table(_))) {
bl.push(para_block(Paragraph::new()));
}
}
Ok(a)
// The paragraphs left between the ends join, as in a range within one container.
let at = if a_covers { Pos { story: a.story, path: block_path(ia), off: 0 } } else { a.clone() };
let end = if b_covers {
Pos { story: a.story, path: block_path((ib + 1).saturating_sub(removed)), off: 0 }
} else {
Pos { story: a.story, path: block_path(ib.saturating_sub(removed)), off: 0 }
};
let paras = |p: &Pos| p.path.0.len() == level + 1 && self.para(p.story, &p.path).is_some();
if paras(&at) && paras(&end) && at.path <= end.path {
return self.delete_range(&at, &end);
}
if self.para(at.story, &at.path).is_some() {
return Ok(at);
}
Ok(self.clamp(&at))
}
/// Apply `f` to the character formatting of `a..b`. A collapsed range changes nothing (the
@@ -989,4 +1031,157 @@ mod tests {
assert_eq!(c, Pos::body(0, 2));
assert!(d.delete_range(&Pos { story: StoryRef::Part(3), path: Path::top(0), off: 0 }, &Pos::body(0, 0)).is_err());
}
// delete_range over whole tables (#1844), in the body, a header and a footnote.
/// A document whose story (the body, or a header or footnote part) holds `lines`.
fn story_doc(kind: Option<PartKind>, lines: &[&str]) -> (Document, StoryRef) {
match kind {
None => (doc(lines), StoryRef::Body),
Some(k) => {
let mut d = doc(&["body"]);
let blocks = lines.iter().map(|l| para_block(Paragraph::with_text(l, CharProps::default()))).collect();
let id = d.add_part(k, blocks);
(d, StoryRef::Part(id))
}
}
}
const STORIES: [Option<PartKind>; 3] = [None, Some(PartKind::Header), Some(PartKind::Footnote)];
fn at(story: StoryRef, path: &[u32], off: usize) -> Pos {
Pos { story, path: Path(path.to_vec()), off }
}
/// Insert an `rows`×`cols` table at `path` whose cells hold `texts`, row by row.
fn put_table(d: &mut Document, story: StoryRef, path: &[u32], rows: usize, cols: usize, texts: &[&str]) {
d.insert_block(story, &Path(path.to_vec()), Block::Table(Table::new(rows, cols, 200.0))).unwrap();
for (k, t) in texts.iter().enumerate() {
let mut p = path.to_vec();
p.extend([(k / cols) as u32, (k % cols) as u32, 0]);
d.insert_text(&at(story, &p, 0), t, &CharProps::default()).unwrap();
}
}
/// The story's top-level blocks: a paragraph's text, or `[table]`.
fn shape(d: &Document, story: StoryRef) -> Vec<String> {
d.story(story).unwrap().iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect()
}
fn cell_text(d: &Document, story: StoryRef, table: &[u32], r: u32, c: u32) -> String {
let mut p = table.to_vec();
p.extend([r, c, 0]);
d.para(story, &Path(p)).map(|p| p.text.clone()).unwrap_or_else(|| "<none>".into())
}
#[test]
fn a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table() {
for kind in STORIES {
for (r, c) in [(0, 1), (1, 0)] {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
d.delete_range(&at(st, &[0], 0), &at(st, &[1, r, c, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["", "[table]", "after"], "{kind:?} ({r},{c})");
assert_eq!(cell_text(&d, st, &[1], 1, 1), "d", "{kind:?} ({r},{c})");
}
}
}
#[test]
fn a_range_from_the_start_of_one_table_into_the_middle_of_the_next_removes_only_the_first() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["x", "mid", "y"]);
put_table(&mut d, st, &[2], 2, 2, &["e", "f", "g", "h"]);
put_table(&mut d, st, &[1], 2, 2, &["a", "b", "c", "d"]);
assert_eq!(shape(&d, st), ["x", "[table]", "mid", "[table]", "y"]);
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 0), &at(st, &[3, 0, 1, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["x", "", "[table]", "y"], "{kind:?}");
assert_eq!([cell_text(&d, st, &[2], 0, 0), cell_text(&d, st, &[2], 0, 1), cell_text(&d, st, &[2], 1, 0)], ["", "", "g"], "{kind:?}");
assert!(d.para(st, &to.path).is_some(), "{kind:?}: {to:?}");
}
}
#[test]
fn a_range_from_a_cell_paragraph_to_the_end_of_a_table_nested_in_that_cell_removes_only_the_nested_table() {
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["p"]);
put_table(&mut d, st, &[1, 0, 0, 1], 1, 1, &["n"]);
d.insert_block(st, &Path(vec![1, 0, 0, 2]), Block::Para(Paragraph::with_text("q", CharProps::default()))).unwrap();
let to = d.delete_range(&at(st, &[1, 0, 0, 0], 1), &at(st, &[1, 0, 0, 1, 0, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), ["before", "[table]", "after"], "{kind:?}");
let cell = d.container(st, &Path(vec![1, 0, 0, 0])).unwrap();
let cell: Vec<String> = cell.iter().map(|b| b.as_para().map_or("[table]".to_string(), |p| p.text.clone())).collect();
assert_eq!(cell, ["pq"], "{kind:?}");
assert_eq!(to, at(st, &[1, 0, 0, 0], 1), "{kind:?}");
}
}
#[test]
fn two_whole_tables_at_the_end_of_a_story_leave_one_empty_paragraph() {
for kind in STORIES {
for trailing in [true, false] {
let (mut d, st) = story_doc(kind, &[""]);
put_table(&mut d, st, &[0], 1, 2, &["a", "b"]);
put_table(&mut d, st, &[1], 2, 1, &["c", "d"]);
if !trailing {
// A story that ends in a table (a hostile file).
d.story_mut(st).unwrap().pop();
}
let to = d.delete_range(&at(st, &[0, 0, 0, 0], 0), &at(st, &[1, 1, 0, 0], 1)).unwrap();
assert_eq!(shape(&d, st), [""], "{kind:?} trailing {trailing}");
assert_eq!(to, at(st, &[0], 0), "{kind:?} trailing {trailing}");
}
}
}
/// Content controls pair their markers within one block list (a story, or a cell), so a
/// marker in a table never has its partner outside it: a control inside a table removed whole
/// goes with it, and one the range only enters keeps both markers (the control is emptied).
#[test]
fn removing_a_whole_table_keeps_a_control_the_range_only_partly_covers() {
use crate::control::ContentControl;
let start = || InlineObject::ControlStart { control: Box::new(ContentControl::default()) };
for kind in STORIES {
let (mut d, st) = story_doc(kind, &["before", "after"]);
put_table(&mut d, st, &[1], 1, 1, &["c"]);
// One control around "efo" in "before", one around the cell's text.
d.insert_object(&at(st, &[0], 4), InlineObject::ControlEnd, &CharProps::default()).unwrap();
let in_before = d.insert_object(&at(st, &[0], 1), start(), &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 1), InlineObject::ControlEnd, &CharProps::default()).unwrap();
d.insert_object(&at(st, &[1, 0, 0, 0], 0), start(), &CharProps::default()).unwrap();
assert_eq!(d.control_ranges(st).len(), 2, "setup");
let end = d.para(st, &Path(vec![1, 0, 0, 0])).unwrap().len();
// From inside the first control to the end of the table.
d.delete_range(&Pos { off: in_before.off + 1, ..in_before }, &at(st, &[1, 0, 0, 0], end)).unwrap();
assert_eq!(shape(&d, st), ["b\u{FFFC}e\u{FFFC}after"], "{kind:?}: the table goes, the paragraphs join");
let ranges = d.control_ranges(st);
assert_eq!(ranges.len(), 1, "{kind:?}: {ranges:?}");
assert!(ranges.iter().all(|r| r.start.path == Path::top(0) && r.end.path == Path::top(0)), "{kind:?}");
let objects = d.para(st, &Path::top(0)).map_or(0, |p| p.objects.len());
assert_eq!(objects, 2, "{kind:?}: no orphan markers left");
}
}
#[test]
fn a_table_nested_deeper_than_the_paragraph_walk_is_never_removed_whole() {
// 20 tables, each in the first cell of the one around it, ahead of that cell's paragraph:
// the innermost paragraphs come first in the document but the paragraph walk stops short.
let mut t = Table::new(1, 1, 200.0);
if let Some(cell) = t.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks = vec![para_block(Paragraph::with_text("deep", CharProps::default()))];
}
for _ in 0..20 {
let mut outer = Table::new(1, 1, 200.0);
if let Some(cell) = outer.rows.get_mut(0).and_then(|r| r.cells.get_mut(0)) {
cell.blocks.insert(0, Arc::new(Block::Table(t)));
}
t = outer;
}
let mut d = doc(&["before", "after"]);
d.insert_block(StoryRef::Body, &Path::top(1), Block::Table(t)).unwrap();
let first = d.para_paths(StoryRef::Body).into_iter().find(|p| p.0.first() == Some(&1)).unwrap();
d.delete_range(&Pos { story: StoryRef::Body, path: first, off: 0 }, &Pos::body(2, 0)).unwrap();
assert!(d.body.get(1).is_some_and(|b| b.as_table().is_some()), "a table with paragraphs the walk never saw was removed");
}
}
+4 -1
View File
@@ -1448,8 +1448,11 @@ pub fn count_words(s: &str) -> usize {
s.split(|c: char| c.is_whitespace() || c == para::OBJ).filter(|w| w.chars().any(char::is_alphanumeric)).count()
}
/// How deep in nested tables [`Document::para_paths`] goes: cells below this are skipped.
pub(crate) const PARA_WALK_DEPTH: usize = 16;
fn walk(bl: &Blocks, prefix: &mut Vec<u32>, out: &mut Vec<Path>, depth: usize) {
if depth > 16 {
if depth > PARA_WALK_DEPTH {
return;
}
for (i, b) in bl.iter().enumerate() {
+7
View File
@@ -159,6 +159,13 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
}
return super::sel_result(s);
}
// Backspace over whole rows, whole columns or the whole table deletes them, as in Word
// (Delete only clears them, as for any other cells).
if id == "text.backspace"
&& let Some(r) = super::table::backspace_cells(s)
{
return r;
}
let Some(segs) = s.column_segments().map(<[(Pos, Pos)]>::to_vec) else { return run(s, v) };
let cells = s.cell_range().is_some();
match id {
+29
View File
@@ -586,6 +586,35 @@ fn del_col(s: &mut Session, _: &Value) -> CmdResult {
fix_caret(s, &tp, r, c.saturating_sub(1))
}
/// Backspace with whole cells selected (#1844): whole rows go like Delete Rows (every row: the
/// table, like Delete Table), whole columns like Delete Columns, with their undo and Track
/// Changes. `None` for any other block of cells, and for a single cell of a table with more
/// (Backspace then clears them).
pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
let cr = s.cell_range()?.clone();
let (tp, _, _) = s.sel.focus.path.cell()?;
if cr.story != s.sel.focus.story || cr.table != tp {
return None;
}
let t = s.doc.table(cr.story, &tp)?;
// One cell of several is just that cell, also in a one-row or one-column table (Word's cell
// selection doesn't take the end-of-row mark): Backspace clears it.
let cells: usize = t.rows.iter().map(|row| row.cells.len()).sum();
if cells > 1 && cr.cells(t).len() <= 1 {
return None;
}
let (last_row, last_col) = (t.rows.len().saturating_sub(1), t.cols().saturating_sub(1));
let r = if cr.cols == (0, last_col) {
del_row(s, &Value::Null)
} else if cr.rows == (0, last_row) {
del_col(s, &Value::Null)
} else {
return None;
};
s.column = None;
Some(r)
}
fn del_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
let story = s.sel.focus.story;
+187
View File
@@ -1608,3 +1608,190 @@ fn typing_1_dot_in_a_heading_keeps_the_heading_style() {
assert_eq!(got[0].1, style);
}
}
// Backspace and Delete on selected tables, rows and columns (Word for Mac, #1844).
/// "before", a 3×3 table with cells `a1`…`c3` (column letter, row number), "after"; the caret
/// in the cell at row `r`, column `c`.
fn table_between(r: u32, c: u32) -> Session {
let mut s = typed("before\n");
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
typ(&mut s, "a1\tb1\tc1\ta2\tb2\tc2\ta3\tb3\tc3");
run(&mut s, "caret.docEnd", json!({}));
typ(&mut s, "after");
let shape: Vec<&str> = s.doc.body.iter().map(|b| if b.as_table().is_some() { "table" } else { "para" }).collect();
assert_eq!(shape, ["para", "table", "para"], "setup");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, r, c, 0], "off": 0}}));
s
}
fn tables(s: &Session) -> usize {
s.doc.body.iter().filter(|b| b.as_table().is_some()).count()
}
/// The first top-level table's cells, row by row (tracked deletions included).
fn table_cells(s: &Session) -> Vec<Vec<String>> {
s.doc
.body
.iter()
.find_map(|b| b.as_table())
.map(|t| {
t.rows
.iter()
.map(|r| r.cells.iter().map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect()).collect())
.collect()
})
.unwrap_or_default()
}
#[test]
fn backspace_with_the_whole_table_selected_removes_the_table() {
let mut s = table_between(1, 1);
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(tables(&s), 0, "the table stays: {:?}", table_cells(&s));
assert_eq!(texts(&s), ["before", "after"]);
// The caret is in the paragraph that followed the table.
assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(1), off: 0 });
assert!(s.sel.is_collapsed());
// One undo brings it back.
run(&mut s, "edit.undo", json!({}));
assert_eq!(tables(&s), 1);
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]);
}
#[test]
fn delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table() {
let mut s = table_between(0, 0);
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(tables(&s), 1);
assert_eq!(table_cells(&s), [["", "", ""], ["", "", ""], ["", "", ""]]);
assert_eq!(texts(&s), ["before", "after"]);
}
#[test]
fn delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table() {
for key in ["text.delete", "text.backspace"] {
// From the first cell to the end of the document: Word's rule widens it to whole rows.
let mut s = table_between(0, 0);
run(&mut s, "caret.docEnd", json!({"extend": true}));
run(&mut s, key, json!({}));
assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s));
assert_eq!(texts(&s), ["before", ""], "{key}");
}
}
#[test]
fn delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table() {
for key in ["text.delete", "text.backspace"] {
// From the start of "before" down into the last row: the rows are taken whole.
let mut s = table_between(0, 0);
run(&mut s, "caret.docStart", json!({}));
for _ in 0..3 {
run(&mut s, "caret.down", json!({"extend": true}));
}
assert_eq!(s.sel.focus.path.cell().map(|(_, r, _)| r), Some(2), "{key}: the selection reaches the last row");
run(&mut s, key, json!({}));
assert_eq!(tables(&s), 0, "{key}: {:?}", table_cells(&s));
assert_eq!(texts(&s), ["after"], "{key}");
assert_eq!(s.sel.focus, Pos { story: StoryRef::Body, path: wordcraft_doc::Path::top(0), off: 0 }, "{key}");
}
}
#[test]
fn backspace_deletes_selected_rows_and_delete_clears_them() {
let mut s = table_between(1, 0);
run(&mut s, "table.selectRow", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a3", "b3", "c3"]]);
let mut s = table_between(1, 0);
run(&mut s, "table.selectRow", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["", "", ""], ["a3", "b3", "c3"]]);
}
#[test]
fn backspace_deletes_selected_columns_and_delete_clears_them() {
let mut s = table_between(0, 1);
run(&mut s, "table.selectColumn", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(table_cells(&s), [["a1", "c1"], ["a2", "c2"], ["a3", "c3"]]);
let mut s = table_between(0, 1);
run(&mut s, "table.selectColumn", json!({}));
run(&mut s, "text.delete", json!({}));
assert_eq!(table_cells(&s), [["a1", "", "c1"], ["a2", "", "c2"], ["a3", "", "c3"]]);
}
#[test]
fn backspace_on_a_selected_nested_table_removes_only_it() {
let mut s = table_between(1, 1);
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, 1, 1, 0], "off": 2}}));
run(&mut s, "text.newParagraph", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
typ(&mut s, "x");
let cell_blocks = |s: &Session| {
s.doc
.body
.iter()
.find_map(|b| b.as_table())
.and_then(|t| t.rows.get(1)?.cells.get(1))
.map(|c| c.blocks.iter().filter(|b| b.as_table().is_some()).count())
};
assert_eq!(cell_blocks(&s), Some(1), "setup: a table nested in b2");
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(cell_blocks(&s), Some(0), "the nested table stays");
assert_eq!(tables(&s), 1, "the outer table goes too");
// The caret is in the cell's paragraph after where the nested table was.
assert_eq!(s.sel.focus.path.cell().map(|(t, r, c)| (t.0, r, c)), Some((vec![1], 1, 1)));
assert!(s.doc.para_at(&s.sel.focus).is_some());
}
#[test]
fn backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph() {
let mut s = s();
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
typ(&mut s, "x");
s.doc.body.retain(|b| b.as_table().is_some());
assert_eq!(s.doc.body.len(), 1, "setup: the table is the only block");
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
assert_eq!(tables(&s), 0);
assert_eq!(texts(&s), [""]);
assert!(s.doc.para_at(&s.sel.focus).is_some(), "the caret is in a paragraph: {:?}", s.sel.focus);
}
#[test]
fn tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table() {
let mut s = table_between(0, 0);
run(&mut s, "review.trackChanges", json!({"value": true}));
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "text.backspace", json!({}));
// Like Table Layout › Delete Table with tracking on: the structure stays, the text is marked deleted.
assert_eq!(tables(&s), 1);
assert_eq!(table_cells(&s), [["a1", "b1", "c1"], ["a2", "b2", "c2"], ["a3", "b3", "c3"]]);
assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforeafter");
run(&mut s, "review.rejectAll", json!({}));
assert_eq!(s.doc.final_text(StoryRef::Body).replace(['\t', '\n'], ""), "Beforea1b1c1a2b2c2a3b3c3after");
assert!(s.doc.revisions.is_empty());
}
/// One selected cell isn't a whole row or column, even in a one-row or one-column table:
/// Backspace clears it (Word's cell selection has no end-of-row mark).
#[test]
fn backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it() {
for (rows, cols, at) in [(1, 3, [0, 1]), (3, 1, [1, 0])] {
let mut s = typed("before\n");
run(&mut s, "insert.table", json!({"rows": rows, "cols": cols}));
typ(&mut s, "x\ty\tz");
run(&mut s, "caret.set", json!({"pos": {"story": "body", "path": [1, at[0], at[1], 0], "off": 0}}));
run(&mut s, "table.selectCell", json!({}));
run(&mut s, "text.backspace", json!({}));
let want: Vec<Vec<String>> =
if rows == 1 { vec![vec!["x".into(), "".into(), "z".into()]] } else { vec![vec!["x".into()], vec!["".into()], vec!["z".into()]] };
assert_eq!(table_cells(&s), want, "{rows}×{cols}");
}
}
+5 -1
View File
@@ -1,6 +1,6 @@
# Typing parity with Word
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; Move Paragraph Up/Down, Promote/Demote, Go Back and Alt+X keys, #343) · **Target:** Microsoft Word (Microsoft 365) for Mac 16.113.4
> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-18 · **Change:** trivial (Backspace on a selected table, rows or columns deletes them, Delete clears them, #1844; previously trivial: typed text takes its direction from its own letters, #1543; previously trivial: an input method's request to erase text around the caret is honoured, so web typing in Cyrillic and other non-Latin text no longer repeats, #1480; previously trivial: `-->` AutoCorrect replacement, #2223; previously trivial: a heading-list preset keeps Heading styles and makes other paragraphs Heading 1, as in Word, #1793; previously trivial: numbered headings: a heading-linked Multilevel List numbers new headings by their style; numbering keeps a heading a heading, #1793; previously trivial: reversing an automatic capital adds an exception, #1857; previously trivial: smart quotes open after guillemets and low quotes and nest; previously trivial: Enter on an empty list item numbered by its paragraph style moves it up a level, #2143; previously trivial: Backspace after a section break and Delete before it remove the break, also on the last paragraph, #2127; previously trivial: a table inserted in right-to-left text is right to left, so Tab moves left, #2634; previously trivial: Shift+Delete cuts the selection on every platform, #1669; previously trivial: Select All then Delete removes a table the document starts with, #1198; previously trivial: Alt+numpad character codes, #961; previously trivial: Down and Up from a line start beside a table go into it, #1007; previously trivial: sample-text formulas on Enter, #1067; previously trivial: Track Changes: Accept All after a tracked delete from a paragraph end keeps the next paragraph; Reject All after typing into an empty paragraph keeps it (#864); previously minor: Correct TWo INitial CApitals, cAPS LOCK and table-cell capitals, #544; previously trivial: complex-script caret and Backspace, #604; 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
@@ -65,6 +65,9 @@ formatting use the existing editing primitives. Tests: `sample_text_formulas_exp
| Delete a range from the end of one paragraph into a later one (tracking on) | the paragraph marks and text in the range are tracked deletions. Accept All removes exactly those, so the paragraph after the range stays a paragraph of its own; Reject All restores everything. Typing into an empty paragraph and rejecting leaves that paragraph | `tests::accept_all_after_a_tracked_delete_from_a_paragraph_end_keeps_the_next_paragraph`, `tests::reject_all_after_typing_into_an_empty_paragraph_keeps_the_paragraph` |
| Backspace just after a section break / Delete just before it | the break goes and the two paragraphs join; the text before it takes the following section's page setup (Word's documented behaviour: a break holds the setup of the section before it); headers and footers the following section showed by linking to the previous one stay. Delete at the end of the last paragraph removes a break on its mark too, with no paragraph to join (with Track Changes on it still does nothing there). Undo brings the break back. Breaks read from a .docx and breaks inserted here alike (#2127) | `backspace_and_delete_remove_a_section_break`, `delete_removes_a_section_break_on_the_last_paragraph` |
| Select All, then Delete or Backspace | the whole document goes, tables included, also when it starts with a table; one empty paragraph is left | `select_all_then_delete_removes_tables` |
| Backspace / Delete with a whole table selected (Select Table, or every cell dragged over) | Backspace removes the table, like Delete Table (one undo step; with tracking on its text is marked deleted and the structure stays); the caret goes to the paragraph after it. Delete clears the cells and keeps the table. A nested table goes on its own and leaves the cell's paragraph; a table that was the only block leaves one empty paragraph | `backspace_with_the_whole_table_selected_removes_the_table`, `delete_with_the_whole_table_selected_clears_the_cells_and_keeps_the_table`, `backspace_on_a_selected_nested_table_removes_only_it`, `backspace_on_a_selected_table_that_is_the_only_block_leaves_an_empty_paragraph`, `tracked_backspace_on_a_selected_table_marks_its_text_deleted_like_delete_table` |
| Backspace / Delete with a table and the paragraph before or after it selected | both remove the table, whatever end the selection starts in; the text around joins. Any table the selection covers whole goes, at any nesting level | `delete_or_backspace_with_the_table_and_the_paragraph_after_selected_removes_the_table`, `delete_or_backspace_with_the_paragraph_before_and_the_table_selected_removes_the_table` |
| Backspace / Delete with whole rows or columns selected | Backspace deletes the rows or columns (Delete Rows / Delete Columns); Delete clears their cells. One selected cell is just that cell, also in a one-row or one-column table: Backspace clears it. **Differs:** in a 1×1 table, Select Cell then Backspace removes the table (WordCraft has no end-of-row mark to tell the cell from the whole table; the end-of-row caret position is still missing, #1844) | `backspace_deletes_selected_rows_and_delete_clears_them`, `backspace_deletes_selected_columns_and_delete_clears_them`, `backspace_on_one_selected_cell_of_a_one_row_or_one_column_table_clears_it`; `delete_range` edge cases: `edit::tests::a_range_ending_at_the_end_of_a_cell_that_isnt_the_last_keeps_the_table` and five more (other tables, nested tables, story ends, headers and footnotes, content controls, nesting past the paragraph walk) |
| Table Layout › Delete Table / Rows / Columns / Cells (tracking on) | like Word deleting a selected table: the cells' text is marked deleted and the table structure stays (WordCraft has no tracked row or column deletion, `w:trPr/w:del`, so rows and columns keep their cells too, where Word's Accept removes the rows); text you inserted yourself is just removed; Reject All restores the text. A table, row or column with no text at all (empty, or its deletion already accepted) is removed as with tracking off; one whose text is all already marked deleted is left alone with a message (accept or reject first). Delete Columns leaves a merged cell that also spans a kept column alone (it narrows, keeping its text). A content control locked against deletion, or content locked against editing, refuses the delete with tracking on or off | `tests_revision_checks::tracked_table_delete_marks_the_text_deleted_and_reject_all_restores_it`, `tracked_row_column_and_cell_deletes_mark_their_text_deleted`, `tracked_table_delete_over_tracked_insertions_rejects_back_to_the_original`, `table_deletes_refuse_a_control_locked_against_deletion`, `tracked_delete_table_removes_a_table_with_no_live_text`, `delete_columns_leaves_a_merged_cell_that_keeps_a_column_alone`, `tracked_row_and_column_deletes_over_tracked_changes_reject_back_to_the_original` |
| Change Case (Shift+F3, Home › Aa) (tracking on) | Word shows no insertion or deletion markup (checked with Formatting markup set to none, so Word may record it as a hidden formatting revision). **Differs:** WordCraft records the changed letters as a tracked deletion of the old and insertion of the new, since case isn't a run property a formatting revision could hold; Reject All restores the old case, Accept All keeps the new. Your own tracked insertion is rewritten in place; already-deleted text is left alone and doesn't count (with tracking off too), so changing case again (or Shift+F3) works from the visible text. Refused in content locked against editing | `tests_revision_checks::tracked_change_case_rejects_back_to_the_original_case`, `tracked_change_case_of_own_insertion_stays_an_insertion`, `tracked_change_case_twice_reads_only_the_live_text`, `tracked_change_case_handles_sharp_s_and_mixed_formatting`, `change_case_refuses_content_locked_against_editing` |
| Unlink Fields (Ctrl+Shift+F9) (tracking on) | not tracked, like Word: the field's result stays as ordinary text. Refused in content locked against editing | `tests_revision_checks::tracked_unlink_keeps_the_result_as_plain_text`, `unlink_refuses_a_field_in_content_locked_against_editing` |
@@ -277,6 +280,7 @@ Word.
| 2026-10-15 | trivial | Add the missing `-->` → `→` AutoCorrect entry (#2223), with typing, undo and preference regression coverage; existing arrow bullets unchanged |
| 2026-10-15 | trivial | Input methods (#1480 review): the rewritten text keeps its formatting, doesn't overtype, keeps the typing undo group; AutoCorrect/AutoFormat reset the web text field; desktop doesn't receive `DeleteSurrounding` |
| 2026-10-15 | trivial | Input methods: `DeleteSurrounding` erases around the caret, so web typing in non-Latin text no longer repeats; Chrome's caret jump in the hidden field is listed as a known gap (#1480) |
| 2026-10-15 | minor | Backspace with a whole table, whole rows or whole columns selected deletes them; Delete clears them; a selection covering a table and the text beside it removes the table (#1844) |
| 2026-10-14 | trivial | Shift+Delete cuts the selection, and does nothing without one, on macOS and Linux too, as in Word for Windows (#1669); Shift+Backspace already erased (#707) |
| 2026-10-13 | trivial | Select All then Delete or Backspace removes a table the document starts with (#1198) |
| 2026-10-12 | trivial | Editing keys: Down and Up from the start of a line beside a table go into it instead of skipping it (#1007) |