Tables can be copied and cut: whole tables, rows and cells (#1744) (#1784)

* #1744: WIP

* #1744: Select Table copies and cuts the whole table

Copy of a whole-table selection used to keep only the cells' paragraphs
(copy_blocks flattened a cross-cell range), and Cut only cleared the text.
Now Copy puts the table on the clipboard and Cut removes the table.

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

* #1744: selected table rows and cells copy as a table; Cut clears their text

Copy of whole rows, cells of a row or a block of cells puts a table of just
those cells on the clipboard (Table::slice). Cut of rows or cells clears their
text and keeps the rows and cells, as in Word (Word check A).

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

* #1744: review fixes

Table::slice widens every row to a merged cell partly selected (no ragged
copy); an empty selected table or cell copies and cuts; a text selection over
exactly a nested table copies it; one selected_table per Cut; tests for
slice merges/widths/controls, tracked and locked cuts, and the text route.

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

* #1744: Cut clears every cell Copy takes; Spike stays off for empty cell selections

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

* #1744: a copied part of a percentage-width table keeps a point width

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

* ROADMAP: tidy merged header

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

* ROADMAP: keep only the #1744 rows over main's version

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

* ROADMAP: remove conflict markers; keep only the #1744 rows over main's version

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

* #1744: rename the range Cut helper clear_range_cells (main added clear_cells)

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

* Remove conflict markers from ROADMAP.md; leave main's header line alone

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

* cargo fmt

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

* Import Path in table structure after merging main

---------

Co-authored-by: Dmitrii Khlapov <19991457+dkhlapov@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-10-29 21:47:27 -04:00
1 parent f658c872a2
commit 22e99fc2b7
9 files changed
+585 -9

No files matched your search

+4
View File
@@ -255,6 +255,8 @@ Imported Word 97–2003 `.doc`/`.dot` files now ask for a supported DOCX target
| 2026-10-16 | DOCX: a `HYPERLINK` field (complex or simple) reads as a link, as a `w:hyperlink` does: its result stays ordinary text carrying the link (`url`, `url#bookmark` or `#bookmark`), so a tab in it reaches its tab stop and a `PAGEREF` in it stays a field (#2174). A table of contents from Word for the web, whose entries are such fields, showed a box for each tab and no dot leaders; the entries now open as in Word and their links work |
| 2026-10-17 | The document's right-click menu offers the ribbon's Paste menu, item for item (#2583): Paste (the clipboard's formatting kept, Word's Keep Source Formatting), Keep Text Only, Merge Formatting and Paste Special…, where it had a single Paste. Same commands and shortcuts, with the system clipboard |
| 2026-10-17 | SmartArt Text Pane: Tab demotes and Shift+Tab promotes the focused item, as Word's pane does (#2701). The row keeps its focus, so a second Tab acts on the same item; an item that can't go down a level is left alone |
| 2026-10-15 | Selected table rows and cells copy as a table (#1744): Copy of whole rows, of cells in a row, or of a block of cells over several rows, then Paste outside the table, inserts a table of just those cells; Cut of rows or cells clears their text and leaves the rows and cells in place (the clipboard still gets the table), as Word does. Copy used to take only the cells' paragraphs |
| 2026-10-14 | Tables can be copied and cut (#1744): with the whole table selected (Select Table, or a drag over all of it), Copy puts the table itself on the clipboard, so Paste outside it inserts a copy of the table with its formatting (the plain text is tab and newline separated); Cut removes the table in one undo step, as in Word. Copy used to keep only the cells' paragraphs and Cut left the table in place |
| 2026-10-14 | DOCX row table border exceptions render and survive saving. Explicit cell borders win; a row exception does not change other rows. |
| 2026-10-19 | Typing no longer starts render threads on every repaint (#3027): page, area and icon renders share one kept vello_cpu context, reset to each size, instead of building a multi-threaded one (and its thread pool) per render. Typing about 150 characters in 15 s created 295–388 threads before and none now; CPU time while typing fell by about a third (Windows, debug build). |
| 2026-10-14 | Tab stops edited on the ruler: the corner selector picks Left/Center/Right/Decimal/Bar, a click sets a stop (Alt skips the snap), dragging moves it with a guide line and position tooltip, dragging off the ruler clears it; markers draw per kind and mirror for RTL; one `para.tabs` call and one undo step per gesture |
@@ -803,6 +805,8 @@ Footer page-table height and body wrapping are corrected. Other pagination gaps
| 2026-10-16 | trivial | Progress log: a text frame in the paragraph defaults frames nothing (#2360) |
| 2026-10-16 | trivial | Progress log: a text file saved over itself closes without asking (#2376) |
| 2026-10-15 | trivial | Progress log: recovery copies and .json files store pictures as base64 (#586 follow-up) |
| 2026-10-15 | trivial | Progress log: selected table rows and cells copy as a table; Cut clears their text (#1744) |
| 2026-10-14 | trivial | Progress log: Select Table copies and cuts the whole table (#1744) |
| 2026-10-17 | trivial | Progress log: Scale Font Sizes command (#1475) |
| 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 |
+64
View File
@@ -792,6 +792,63 @@ impl Document {
Fragment { blocks, parts }
}
/// A copy of the block at `path` (a whole table, say) as a fragment, with its text boxes.
pub fn copy_block(&self, story: crate::StoryRef, path: &Path) -> Option<Fragment> {
Some(self.fragment_of(vec![self.block(story, path)?.clone()]))
}
/// A copy of rows `rows` of the table at `tp`, with the cells of each that reach into grid
/// columns `cols` (see [`crate::Table::slice`]), as a fragment holding that table.
pub fn copy_table_cells(&self, story: crate::StoryRef, tp: &Path, rows: (usize, usize), cols: (usize, usize)) -> Option<Fragment> {
let t = self.table(story, tp)?.slice(rows, cols);
if t.rows.is_empty() {
return None;
}
Some(self.fragment_of(vec![Block::Table(t)]))
}
/// `blocks` as a fragment: half content controls dropped, text boxes copied along.
fn fragment_of(&self, mut blocks: Vec<Block>) -> Fragment {
crate::control::balance_blocks(&mut blocks);
let parts = self.text_box_parts(&blocks);
Fragment { blocks, parts }
}
/// The table whose whole text `a..b` covers when the range runs across its cells: from the
/// start of its first paragraph to the end of its last. Of the tables holding both ends, the
/// outermost one covered whole (a table nested in a cell when the range covers just it).
/// `None` for a range inside one cell, or one that doesn't cover a whole table.
pub fn whole_table(&self, a: &Pos, b: &Pos) -> Option<Path> {
let (a, b) = order(a, b);
if a.story != b.story || a.off != 0 || a.path.parent() == b.path.parent() {
return None;
}
let mut paths: Option<Vec<Path>> = None;
// Table block paths are the prefixes of a cell paragraph's path one block deep, then
// every three steps (block, row, cell) deeper.
let mut n = 1usize;
while n.saturating_add(3) <= a.path.0.len() {
let pre = a.path.0.get(..n)?;
if !b.path.0.starts_with(pre) {
return None;
}
let tp = Path(pre.to_vec());
if self.table(a.story, &tp).is_some() {
let paths = paths.get_or_insert_with(|| self.para_paths(a.story));
let mut inside = paths.iter().filter(|p| p.0.starts_with(pre));
if let Some(first) = inside.next() {
let last = inside.next_back().unwrap_or(first);
let end = self.para(a.story, last).map_or(0, Paragraph::len);
if *first == a.path && *last == b.path && b.off >= end {
return Some(tp);
}
}
}
n = n.saturating_add(3);
}
None
}
/// The whole body as a fragment (with its text boxes), to insert into another document.
pub fn body_fragment(&self) -> Fragment {
let blocks: Vec<Block> = self.body.iter().map(|b| (**b).clone()).collect();
@@ -846,6 +903,13 @@ impl Document {
}
return out;
}
// Across the cells of a whole table: the table itself, not its cells' paragraphs (#1744).
if let Some(tp) = self.whole_table(&a, &b)
&& let Some(blk) = self.block(a.story, &tp)
{
out.push(blk.clone());
return out;
}
for p in self.paths_between(&a, &b) {
if let Some(para) = self.para(a.story, &p) {
let mut q = para.clone();
+137
View File
@@ -77,6 +77,80 @@ pub const MAX_COLS: usize = 63;
pub const MAX_TABLE_DEPTH: usize = 24;
impl Table {
/// Grid columns `lo..hi` that [`Table::slice`] copies for `rows`/`cols` (widened over merged cells),
/// and whether any cell touches them (Cut clears the same cells it copies).
pub fn slice_bounds(&self, rows: (usize, usize), cols: (usize, usize)) -> (usize, usize, bool) {
let ((r0, r1), (g0, g1)) = (rows, cols);
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
// Grid columns `lo..hi` the cells touching them cover: a cell merged across that is partly
// in them widens them, for every row, so the copy isn't ragged. Each round only widens
// (at most once per grid column), so a bound of the grid's width plus one round is enough.
let (mut lo, mut hi) = (g0, g1.saturating_add(1));
let mut any = false;
for _ in 0..=MAX_COLS.saturating_add(1) {
let (mut nlo, mut nhi, mut found) = (lo, hi, false);
for row in kept() {
let mut g = 0usize;
for cell in &row.cells {
let end = g.saturating_add(cell.span());
if g < hi && end > lo {
(nlo, nhi, found) = (nlo.min(g), nhi.max(end), true);
}
g = end;
}
}
any = found;
if (nlo, nhi) == (lo, hi) {
break;
}
(lo, hi) = (nlo, nhi);
}
(lo, hi, any)
}
/// A copy of rows `rows` of this table holding, of each row, the cells that reach into grid
/// columns `cols` (both inclusive): what Copy takes from selected rows or cells. Content
/// controls around rows and the table aren't copied (they'd lose their other end); a cell
/// that continues a vertical merge from a row left out starts the merge instead.
pub fn slice(&self, rows: (usize, usize), cols: (usize, usize)) -> Table {
let (r0, r1) = rows;
let kept = || self.rows.iter().take(r1.saturating_add(1)).skip(r0);
let (lo, hi, any) = self.slice_bounds(rows, cols);
let mut out_rows = Vec::new();
for row in kept() {
let mut g = 0usize;
let mut cells = Vec::new();
for cell in &row.cells {
let end = g.saturating_add(cell.span());
if any && g < hi && end > lo {
cells.push(cell.clone());
}
g = end;
}
if !cells.is_empty() {
out_rows.push(Row { props: row.props.clone(), cells, controls: ControlWrap::default() });
}
}
if let Some(first) = out_rows.first_mut() {
for c in &mut first.cells {
if c.props.vmerge == VMerge::Continue {
c.props.vmerge = VMerge::Restart;
}
}
}
let whole = lo == 0 && hi >= self.grid.len();
let grid = if whole { self.grid.clone() } else { self.grid.get(lo..hi.min(self.grid.len())).map(<[f32]>::to_vec).unwrap_or_default() };
let mut props = self.props.clone();
if !whole {
// Part of the columns: as wide as they are, not the whole table's width.
if props.width.is_some() || props.width_pct.is_some() {
props.width = Some(grid.iter().sum());
}
props.width_pct = None;
}
Table { props, grid, rows: out_rows, controls: ControlWrap::default() }
}
/// A `rows × cols` table spanning `width` points, "Table Grid" style.
pub fn new(rows: usize, cols: usize, width: f32) -> Table {
let rows = rows.clamp(1, MAX_ROWS);
@@ -566,6 +640,69 @@ impl Table {
mod tests {
use super::*;
/// A 3×3 table, 100 pt columns: (0,0)–(0,1) merged across, (1,2)–(2,2) merged down.
fn merged() -> Table {
let mut t = Table::new(3, 3, 300.0);
t.merge(0, 0, 0, 1);
t.merge(1, 2, 2, 2);
assert_eq!(t.rows[0].cells.iter().map(Cell::span).collect::<Vec<_>>(), [2, 1]);
assert_eq!((t.rows[1].cells[2].props.vmerge, t.rows[2].cells[2].props.vmerge), (VMerge::Restart, VMerge::Continue));
t
}
fn spans(t: &Table) -> Vec<Vec<usize>> {
t.rows.iter().map(|r| r.cells.iter().map(Cell::span).collect()).collect()
}
/// #1744 review: a cell merged across that is partly selected takes its whole width, and the
/// other rows widen with it, so the copy isn't ragged.
#[test]
fn slice_widens_to_a_merged_cell_in_every_row() {
let t = merged().slice((0, 1), (1, 2));
assert_eq!(t.grid, [100.0, 100.0, 100.0]);
assert_eq!(spans(&t), [vec![2, 1], vec![1, 1, 1]]);
// Rows without the merged cell: just the selected columns.
let t = merged().slice((1, 2), (1, 2));
assert_eq!(t.grid, [100.0, 100.0]);
assert_eq!(spans(&t), [vec![1, 1], vec![1, 1]]);
}
/// #1744 review: a cell continuing a vertical merge from a row left out starts it instead.
#[test]
fn slice_starts_a_vertical_merge_whose_first_row_is_left_out() {
let t = merged().slice((2, 2), (0, 2));
assert_eq!(t.rows.len(), 1);
assert_eq!(t.rows[0].cells[2].props.vmerge, VMerge::Restart);
// Both rows kept: the merge is kept as it is.
let t = merged().slice((1, 2), (2, 2));
assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue));
}
/// #1744 review: part of the columns is as wide as they are; content controls around rows
/// and the table aren't copied.
#[test]
fn slice_width_and_controls() {
let mut t = Table::new(3, 3, 300.0);
(t.props.width, t.props.width_pct) = (Some(300.0), Some(50.0));
let wrap = ControlWrap { open: vec![crate::control::ContentControl::default()], close: 1 };
t.controls = wrap.clone();
t.rows[1].controls = wrap;
let part = t.slice((0, 2), (2, 2));
assert_eq!((part.grid.len(), part.props.width, part.props.width_pct), (1, Some(100.0), None));
let whole = t.slice((0, 2), (0, 2));
assert_eq!((whole.props.width, whole.props.width_pct), (Some(300.0), Some(50.0)), "all columns keep the table's width");
let row = t.slice((1, 1), (0, 2));
assert!(row.controls.is_empty() && row.rows[0].controls.is_empty());
// A table sized only by a percentage keeps a width for the part.
let mut pct = Table::new(3, 3, 300.0);
(pct.props.width, pct.props.width_pct) = (None, Some(50.0));
let part = pct.slice((0, 2), (2, 2));
assert_eq!((part.props.width, part.props.width_pct), (Some(100.0), None));
// Nothing in range: no rows.
assert!(t.slice((5, 6), (0, 2)).rows.is_empty());
assert!(t.slice((0, 2), (9, 9)).rows.is_empty());
}
#[test]
fn grid_before_shifts_the_row() {
let mut t = Table::new(2, 3, 300.0);
+2
View File
@@ -180,6 +180,8 @@ pub(crate) fn dispatch(s: &mut Session, id: &str, mutates: bool, run: fn(&mut Se
// Whole cells: table commands take the rectangle between the corners, and Tab moves on
// from it without clearing it, as in Word.
_ if cells && (id.starts_with("table.") || matches!(id, "text.tab" | "text.backTab")) => run(s, v),
// Copy and Cut of whole cells take a table of them (#1744).
"edit.copy" | "edit.cut" if cells => run(s, v),
"edit.copy" => copy(s, &segs),
"edit.cut" => {
let r = copy(s, &segs)?;
+47 -9
View File
@@ -2,7 +2,7 @@
use serde_json::{Value, json};
use wordcraft_doc::edit::Fragment;
use wordcraft_doc::{Pos, StoryRef};
use wordcraft_doc::{Path, Pos, StoryRef};
use super::{delete_selection, find_pattern, pos_json, sel_result};
use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p};
@@ -27,10 +27,16 @@ fn undo_to(s: &mut Session, v: &Value) -> CmdResult {
Ok(r)
}
fn has_sel(s: &Session) -> Option<&'static str> {
/// Something the Spike can collect: text (it doesn't take empty cells).
fn has_text_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() { Some("nothing is selected") } else { None }
}
/// Something to cut: text, or whole table cells (an empty cell's selection has no length, #1744).
fn has_sel(s: &Session) -> Option<&'static str> {
if s.sel.is_collapsed() && s.cell_range().is_none() { Some("nothing is selected") } else { None }
}
pub fn specs() -> Vec<CommandSpec> {
vec![
CommandSpec::new("edit.undo", "Undo", "Quick Access Toolbar", |s, _| {
@@ -63,7 +69,7 @@ pub fn specs() -> Vec<CommandSpec> {
CommandSpec::new("edit.copy", "Copy", "Home › Clipboard", copy).key("Mod+C / Mod+Insert").pure(),
// Word's Spike (Ctrl+F3, Ctrl+Shift+F3; ⌘ on the Mac): the AutoText entry "Spike", so Quick
// Parts and AutoText insert it without emptying it, as in Word.
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_sel),
CommandSpec::new("edit.spike", "Spike", "Editing", spike).key("Mod+F3").when(has_text_sel),
CommandSpec::new("edit.insertSpike", "Insert Spike", "Editing", insert_spike).key("Mod+Shift+F3").when(|s| {
if s.math.is_some() {
Some("leave the equation first")
@@ -167,11 +173,25 @@ pub fn specs() -> Vec<CommandSpec> {
}
fn copy(s: &mut Session, _: &Value) -> CmdResult {
let whole = super::table::selected_table(s);
copy_with(s, whole.as_ref())
}
/// Copy, `whole` being the table the selection covers whole, if it does.
fn copy_with(s: &mut Session, whole: Option<&Path>) -> CmdResult {
let (a, b) = s.sel.ordered();
if a == b {
return Ok(json!({"text": ""}));
}
let f = s.doc.copy_range(&a, &b);
// Whole table cells selected copy as a table, even empty ones (their selection has no
// length): the whole table, or a table of just the selected rows or cells (#1744).
let table = match (whole, s.cell_range()) {
(Some(tp), _) => s.doc.copy_block(a.story, tp),
(None, Some(r)) => s.doc.copy_table_cells(r.story, &r.table, r.rows, r.cols),
(None, None) => None,
};
let f = match table {
Some(f) => f,
None if a == b => return Ok(json!({"text": ""})),
None => s.doc.copy_range(&a, &b),
};
s.clipboard_text = f.clipboard_text();
s.clip_history.push(&f, &s.clipboard_text);
s.clipboard = Some(f);
@@ -200,8 +220,26 @@ pub(crate) fn copy_result(s: &mut Session) -> Value {
r
}
fn cut(s: &mut Session, v: &Value) -> CmdResult {
let r = copy(s, v)?;
fn cut(s: &mut Session, _: &Value) -> CmdResult {
let whole = super::table::selected_table(s);
let r = copy_with(s, whole.as_ref())?;
// A whole table selected: Cut removes the table, as Delete Table does (#1744). With Track
// Changes on, the cut below marks its text deleted (a tracked cut may become a move).
if !s.doc.settings.track_changes
&& let Some(tp) = &whole
{
super::table::delete_table_at(s, tp)?;
super::controls::restore_placeholder(s)?;
return Ok(r);
}
// Whole rows or some cells: their text goes, the rows and cells stay, as in Word.
if whole.is_none()
&& let Some(range) = s.cell_range().cloned()
{
super::table::clear_range_cells(s, &range)?;
super::controls::restore_placeholder(s)?;
return Ok(r);
}
// A tracked cut of a paragraph or sentence may become a move when pasted.
if !super::moves::cut_tracked(s)? {
delete_selection(s)?;
+2
View File
@@ -20,6 +20,7 @@ pub(super) use cells::grid_cols;
pub use cells::selected_cells;
use cells::{cell, with_cells, with_table};
pub(crate) use cells::{cell_text, row_ends};
pub(super) use clear::clear_range_cells;
pub(crate) use clear::{backspace_cells, clear_cells};
use clear::{cell_prefix, guard_delete, text_spans};
use convert::{formula, from_text, quick_table, sort_table, to_text};
@@ -31,6 +32,7 @@ use select::{select_cells, select_column};
pub use structure::split_targets;
pub(super) use structure::{col, row};
use structure::{del_col, del_row, del_table, merge, split, split_table};
pub(super) use structure::{delete_table_at, selected_table};
fn in_table(s: &Session) -> Option<&'static str> {
if s.sel.focus.path.cell().is_some() { None } else { Some("the cursor isn't in a table") }
+24
View File
@@ -30,6 +30,30 @@ pub(crate) fn backspace_cells(s: &mut Session) -> Option<CmdResult> {
Some(r)
}
/// Clear the text of the cells `range` selects, leaving the cells (Cut of rows or cells, #1744);
/// tracked when Track Changes is on. The caret goes to the first cell.
pub(in crate::cmd) fn clear_range_cells(s: &mut Session, range: &crate::CellRange) -> Result<(), CmdError> {
let Some(t) = s.doc.table(range.story, &range.table) else { return Ok(()) };
// The cells Copy takes: columns widened over merged cells, as `Table::slice` does.
let (lo, hi, any) = t.slice_bounds(range.rows, range.cols);
let cells = if any { crate::CellRange { cols: (lo, hi.saturating_sub(1)), ..range.clone() }.cells(t) } else { Vec::new() };
let prefixes: Vec<Vec<u32>> = cells.into_iter().map(|(r, c)| cell_prefix(&range.table, r, c)).collect();
let spans = text_spans(s, range.story, &prefixes);
let first = spans.first().map(|(a, _)| a.clone());
if guard_delete(s, &spans)?.is_none() {
for (a, b) in spans.iter().rev() {
if a != b {
s.doc.delete_range(a, b)?;
}
}
}
s.column = None;
if let Some(a) = first {
s.sel = Selection::caret(s.doc.clamp(&a));
}
Ok(())
}
/// Delete with whole cells selected (#2802): each cell is emptied to one empty paragraph, as in
/// Word, nested tables included. That paragraph keeps the formatting of the cell's last one (its
/// mark is the end-of-cell mark). `None` without whole cells, and with Track Changes on (the text
+20
View File
@@ -2,6 +2,7 @@ use super::cells::{cell, selected_cells};
use super::clear::{cell_prefix, guard_delete, text_spans};
use super::layout::{fix_caret, sync_cell_widths};
use super::{Block, CmdError, CmdResult, Paragraph, Pos, Selection, Session, Value, json, p, sel_result};
use wordcraft_doc::Path;
/// Insert Above / Below: rows like the caret's, as many as the selection spans; the caret goes into the first.
pub(in crate::cmd) fn row(s: &mut Session, below: bool) -> CmdResult {
@@ -104,6 +105,25 @@ pub(super) fn del_col(s: &mut Session, _: &Value) -> CmdResult {
pub(super) fn del_table(s: &mut Session, _: &Value) -> CmdResult {
let (tp, _, _) = cell(s)?;
delete_table_at(s, &tp)
}
/// The table the selection covers whole (Select Table, a drag over all of it), if it does (#1744).
pub(in crate::cmd) fn selected_table(s: &Session) -> Option<Path> {
if let Some(r) = s.cell_range()
&& let Some(t) = s.doc.table(r.story, &r.table)
&& r.rows == (0, t.rows.len().saturating_sub(1))
&& r.cols == (0, t.cols().saturating_sub(1))
{
return Some(r.table.clone());
}
let (a, b) = s.sel.ordered();
s.doc.whole_table(&a, &b)
}
/// Delete Table for the table at `tp` (in the selection's story): tracked when Track Changes is on.
pub(in crate::cmd) fn delete_table_at(s: &mut Session, tp: &Path) -> CmdResult {
let tp = tp.clone();
let story = s.sel.focus.story;
let spans = text_spans(s, story, std::slice::from_ref(&tp.0));
if let Some(done) = guard_delete(s, &spans)? {
+285
View File
@@ -1943,6 +1943,291 @@ fn tables_commands() {
assert!(s.run("table.merge", &json!({})).is_err());
}
/// A 2×2 table "A1 B1 / A2 B2" after an "Intro" paragraph, the caret in its first cell.
fn table_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
for (i, t) in ["A1", "B1", "A2", "B2"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": t}));
}
s
}
fn body_table_cells(s: &Session) -> Vec<Vec<String>> {
s.doc
.body
.iter()
.filter_map(|b| b.as_table())
.map(|t| {
t.rows
.iter()
.flat_map(|r| r.cells.iter())
.map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.plain_text()).collect::<String>())
.collect()
})
.collect()
}
/// #1744: Select Table → Copy → Paste outside the table pastes a copy of the whole table.
#[test]
fn copy_whole_table_pastes_a_table() {
let mut s = table_doc();
run(&mut s, "table.selectTable", json!({}));
let r = run(&mut s, "edit.copy", json!({}));
assert!(r["text"].as_str().unwrap().contains("A1\tB1"), "clipboard text is tab separated: {r}");
assert!(matches!(s.clipboard.as_ref().unwrap().blocks.as_slice(), [wordcraft_doc::Block::Table(_)]), "the copy is the table");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
let want = vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()];
assert_eq!(body_table_cells(&s), vec![want.clone(), want]);
}
/// #1744: Select Table → Cut removes the whole table, and one Undo brings it back.
#[test]
fn cut_whole_table_removes_it_and_undoes() {
let mut s = table_doc();
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
assert!(body_table_cells(&s).is_empty(), "the table is gone");
assert!(s.sel.focus.path.cell().is_none(), "the caret left the table");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![vec!["A1".to_string(), "B1".into(), "A2".into(), "B2".into()]]);
// Paste puts it back as a table.
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s).len(), 1);
}
/// A 3×3 table "A1 B1 C1 / A2 B2 C2 / A3 B3 C3" after an "Intro" paragraph (#1744).
fn table3_doc() -> Session {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 3, "cols": 3}));
for (i, t) in ["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"].iter().enumerate() {
if i > 0 {
run(&mut s, "text.tab", json!({}));
}
run(&mut s, "text.insert", json!({"text": t}));
}
s
}
/// Select whole cells of the body's table: rows and grid columns, both inclusive.
fn select_table_cells(s: &mut Session, rows: (usize, usize), cols: (usize, usize)) {
let tp = wordcraft_doc::Path(vec![1]);
crate::cmd::table::select_range(s, StoryRef::Body, &tp, rows, cols).unwrap();
assert_eq!(s.cell_range().map(|r| (r.rows, r.cols)), Some((rows, cols)));
}
fn row_cells(texts: &[&str]) -> Vec<String> {
texts.iter().map(|t| t.to_string()).collect()
}
/// #1744 (Word check A): copying whole rows, two cells of a row, or a block of cells over
/// several rows, then pasting outside the table, pastes a table of just those cells.
#[test]
fn copy_rows_and_cells_pastes_a_table_of_them() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
for (rows, cols, want) in [
((1, 1), (0, 2), row_cells(&["A2", "B2", "C2"])),
((0, 0), (0, 1), row_cells(&["A1", "B1"])),
((1, 2), (1, 2), row_cells(&["B2", "C2", "B3", "C3"])),
] {
let mut s = table3_doc();
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.copy", json!({}));
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else {
panic!("{rows:?} {cols:?}: the copy is a table, not {:?}", s.clipboard_text);
};
assert_eq!(t.rows.len(), rows.1 - rows.0 + 1, "{rows:?} {cols:?}");
assert_eq!(t.grid.len(), cols.1 - cols.0 + 1, "{rows:?} {cols:?}: the grid has the copied columns");
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s), vec![want, full.clone()], "{rows:?} {cols:?}");
}
}
/// #1744 (Word check A): Cut of whole rows, or of some cells, clears their text and leaves
/// the rows and cells; the clipboard still gets a table of them. One Undo brings the text back.
#[test]
fn cut_rows_and_cells_clears_their_text() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
for (rows, cols, left, copied) in [
((1, 1), (0, 2), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]), row_cells(&["A2", "B2", "C2"])),
((0, 0), (0, 1), row_cells(&["", "", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]), row_cells(&["A1", "B1"])),
((1, 2), (1, 2), row_cells(&["A1", "B1", "C1", "A2", "", "", "A3", "", ""]), row_cells(&["B2", "C2", "B3", "C3"])),
] {
let mut s = table3_doc();
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.cut", json!({}));
assert_eq!(body_table_cells(&s), vec![left.clone()], "{rows:?} {cols:?}: rows and cells stay");
assert!(s.sel.is_collapsed(), "{rows:?} {cols:?}");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![full.clone()], "{rows:?} {cols:?}: one undo step");
select_table_cells(&mut s, rows, cols);
run(&mut s, "edit.cut", json!({}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "edit.paste", json!({}));
assert_eq!(body_table_cells(&s), vec![copied, left], "{rows:?} {cols:?}: the cut pastes as a table");
}
}
/// #1744 review: Cut clears every cell Copy takes: a cell merged across the selected columns
/// widens them, so the cell below the merge's second column is copied and cleared too.
#[test]
fn cut_clears_the_cells_a_merged_cell_widens_the_copy_to() {
let mut s = table3_doc();
select_table_cells(&mut s, (0, 0), (0, 1));
run(&mut s, "table.merge", json!({}));
select_table_cells(&mut s, (0, 1), (0, 0));
run(&mut s, "edit.copy", json!({}));
run(&mut s, "edit.cut", json!({}));
let cells = body_table_cells(&s).remove(0);
assert_eq!(cells.len(), 8, "one merged cell and seven others: {cells:?}");
assert_eq!(cells[1], "C1");
assert!(cells[2..4].iter().all(String::is_empty), "A2 and B2 are cleared: {cells:?}");
assert_eq!(cells[4..], ["C2", "A3", "B3", "C3"], "the rest stays");
}
/// #1744 review: an empty cell's selection enables Cut but not the Spike, which takes text only.
#[test]
fn spike_stays_disabled_on_an_empty_cell_selection() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
select_table_cells(&mut s, (0, 0), (0, 0));
let r = s.run("edit.spike", &json!({}));
assert!(matches!(r, Err(crate::CmdError::Disabled(_))), "{r:?}");
}
fn clip_is_table(s: &Session) -> bool {
matches!(s.clipboard.as_ref().map(|f| f.blocks.as_slice()), Some([wordcraft_doc::Block::Table(_)]))
}
/// The body table's cells as they read with every change accepted.
fn body_table_final(s: &Session) -> Vec<String> {
let t = s.doc.body.iter().find_map(|b| b.as_table()).expect("a table");
t.rows.iter().flat_map(|r| r.cells.iter()).map(|c| c.blocks.iter().filter_map(|b| b.as_para()).map(|p| p.final_text()).collect()).collect()
}
/// #1744 review: an empty table or cell selected whole still copies, and Cut of the empty
/// table removes it, as in Word.
#[test]
fn copy_and_cut_of_an_empty_table_or_cell() {
let mut s = s();
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 1, "cols": 1}));
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s), "an empty 1×1 table copies: {:?}", s.clipboard);
run(&mut s, "edit.cut", json!({}));
assert!(body_table_cells(&s).is_empty(), "Cut removes the empty table");
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_cells(&s), vec![vec![String::new()]]);
let mut s = Session::new(wordcraft_doc::Document::new());
run(&mut s, "document.setText", json!({"text": "Intro"}));
run(&mut s, "caret.docEnd", json!({}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
select_table_cells(&mut s, (0, 0), (0, 0));
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s), "an empty cell copies as a one-cell table");
run(&mut s, "edit.cut", json!({}));
assert_eq!(body_table_cells(&s).len(), 1, "Cut of an empty cell leaves the table");
}
/// #1744 review: a text selection over exactly a whole table (no whole-cell record) copies the
/// table; so does one over exactly a table nested in a cell. Other users of the copied range
/// read it as a table too: the selected text is tab separated, a Quick Part saved from it
/// inserts a table.
#[test]
fn text_selection_of_a_whole_table_copies_the_table() {
let mut s = table_doc();
let at = |path: Vec<u32>, off: usize| json!({"story": "body", "path": path, "off": off});
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 0), "focus": at(vec![1, 1, 1, 0], 2)}));
assert!(s.cell_range().is_none(), "a text selection");
assert_eq!(s.selected_text(), "A1\tB1\nA2\tB2");
run(&mut s, "edit.copy", json!({}));
assert!(clip_is_table(&s));
run(&mut s, "insert.quickParts", json!({"save": "grid"}));
run(&mut s, "caret.docStart", json!({}));
run(&mut s, "insert.quickParts", json!({"insert": "grid"}));
assert_eq!(body_table_cells(&s).len(), 2, "the Quick Part is a table");
// A table nested in cell A1: selecting its text whole copies just it.
let mut s = table_doc();
run(&mut s, "select.range", json!({"anchor": at(vec![1, 0, 0, 0], 2), "focus": at(vec![1, 0, 0, 0], 2)}));
run(&mut s, "insert.table", json!({"rows": 2, "cols": 1}));
run(&mut s, "text.insert", json!({"text": "N1"}));
run(&mut s, "text.tab", json!({}));
run(&mut s, "text.insert", json!({"text": "N2"}));
let (inner, _, _) = s.sel.focus.path.cell().unwrap();
assert_eq!(inner.0.len(), 4, "nested in a cell");
let first = [inner.0.clone(), vec![0, 0, 0]].concat();
let last = [inner.0.clone(), vec![1, 0, 0]].concat();
run(&mut s, "select.range", json!({"anchor": at(first, 0), "focus": at(last, 2)}));
assert!(s.cell_range().is_none(), "a text selection");
run(&mut s, "edit.copy", json!({}));
let Some([wordcraft_doc::Block::Table(t)]) = s.clipboard.as_ref().map(|f| f.blocks.as_slice()) else { panic!("{:?}", s.clipboard_text) };
assert_eq!(t.rows.len(), 2, "the nested table, not the outer one");
}
/// #1744 review: with Track Changes on, Cut of the whole table or of a row marks the text
/// deleted and keeps the table; one Undo takes the marks back.
#[test]
fn tracked_cut_of_a_table_or_row_marks_text_deleted() {
let full = row_cells(&["A1", "B1", "C1", "A2", "B2", "C2", "A3", "B3", "C3"]);
let mut s = table3_doc();
s.doc.settings.track_changes = true;
run(&mut s, "table.selectTable", json!({}));
run(&mut s, "edit.cut", json!({}));
assert!(clip_is_table(&s));
assert_eq!(body_table_cells(&s), vec![full.clone()], "the table and its text stay, marked");
assert!(body_table_final(&s).iter().all(String::is_empty), "{:?}", body_table_final(&s));
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_final(&s), full);
select_table_cells(&mut s, (1, 1), (0, 2));
run(&mut s, "edit.cut", json!({}));
assert!(clip_is_table(&s));
assert_eq!(body_table_cells(&s), vec![full.clone()]);
assert_eq!(body_table_final(&s), row_cells(&["A1", "B1", "C1", "", "", "", "A3", "B3", "C3"]));
run(&mut s, "edit.undo", json!({}));
assert_eq!(body_table_final(&s), full);
}
/// #1744 review: Cut of a table, or of cells, holding a content control locked against deletion
/// is refused and changes nothing in the document; the copy is made first, so the clipboard
/// holds the table, as for any refused Cut.
#[test]
fn cut_of_cells_with_a_locked_control_is_refused() {
for whole in [true, false] {
let mut s = table3_doc();
run(&mut s, "edit.find", json!({"text": "B2"}));
run(&mut s, "developer.richText", json!({"tag": "keep"}));
run(&mut s, "developer.properties", json!({"control": "keep", "lockDelete": true}));
if whole {
run(&mut s, "table.selectTable", json!({}));
} else {
select_table_cells(&mut s, (1, 1), (0, 2));
}
let before = s.doc.clone();
assert!(s.run("edit.cut", &json!({})).is_err(), "whole table: {whole}");
assert_eq!(s.doc, before, "whole table: {whole}");
assert!(s.status.contains("can't be deleted"), "{}", s.status);
assert!(clip_is_table(&s), "whole table: {whole}");
}
}
#[test]
fn inserting_a_column_keeps_cell_widths_in_step_with_the_grid() {
let mut s = s();