diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 91ac7ac70..7ea66acfd 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -1,7 +1,7 @@ //! Table Design and Table Layout tabs. use deckcraft_geom::{Affine, Point, Rect}; -use deckcraft_model::{Shape, ShapeKind, Table}; +use deckcraft_model::{Cell, Shape, ShapeKind, Table}; use serde_json::{Value, json}; use super::{ @@ -24,18 +24,24 @@ pub fn specs() -> Vec { "rowBelow", "table.insertRowBelow" )), - cmd!("table.insertColumnLeft", "Insert Left", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols( - s, - p, - "colLeft", - "table.insertColumnLeft" - )), - cmd!("table.insertColumnRight", "Insert Right", ["Table Layout", "Rows & Columns"], None, "{col?, id?}", has_selection, |s, p| rows_cols( - s, - p, - "colRight", - "table.insertColumnRight" - )), + cmd!( + "table.insertColumnLeft", + "Insert Left", + ["Table Layout", "Rows & Columns"], + None, + "{col?, keepWidth?: bool (default false, as in PowerPoint: the table grows by the new column and the other widths stay; true: the other columns shrink in proportion so the table keeps its width), id?} the new column takes the width and cell formatting (fill, margins, anchor, text properties) of the column it is inserted beside; a merged row that spans that column grows to span the new one", + has_selection, + |s, p| rows_cols(s, p, "colLeft", "table.insertColumnLeft") + ), + cmd!( + "table.insertColumnRight", + "Insert Right", + ["Table Layout", "Rows & Columns"], + None, + "{col?, keepWidth?: bool (default false, as in PowerPoint: the table grows by the new column and the other widths stay; true: the other columns shrink in proportion so the table keeps its width), id?} the new column takes the width and cell formatting (fill, margins, anchor, text properties) of the column it is inserted beside; a merged row that spans that column grows to span the new one", + has_selection, + |s, p| rows_cols(s, p, "colRight", "table.insertColumnRight") + ), cmd!("table.deleteRow", "Delete Rows", ["Table Layout", "Rows & Columns"], None, "{row?, id?}", has_selection, |s, p| rows_cols( s, p, @@ -315,13 +321,22 @@ fn with_table(s: &mut Session, p: &Value, cmd: &str, f: impl Fn(&mut Table) -> R fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result { let (r, c) = at(s, p, name)?; + // Absent: the table grows, as in PowerPoint; present, it must be a bool, so a mistyped value is refused + // rather than silently taken as the default. + let keep_width = match (op, p.get("keepWidth")) { + (_, None) | (_, Some(Value::Null)) => false, + (_, Some(Value::Bool(b))) => *b, + ("colLeft", Some(_)) => return Err(bad("table.insertColumnLeft", "`keepWidth` must be true or false")), + ("colRight", Some(_)) => return Err(bad("table.insertColumnRight", "`keepWidth` must be true or false")), + _ => false, + }; let op = op.to_string(); with_table(s, p, name, move |t| { match op.as_str() { "rowAbove" => t.insert_row(r), "rowBelow" => t.insert_row(r.saturating_add(1)), - "colLeft" => t.insert_col(c), - "colRight" => t.insert_col(c.saturating_add(1)), + "colLeft" => insert_col_like(t, c, c, keep_width), + "colRight" => insert_col_like(t, c.saturating_add(1), c, keep_width), "delRow" => { if !t.delete_row(r) { return Err(bad(name, "can't delete the last row")); @@ -337,6 +352,65 @@ fn rows_cols(s: &mut Session, p: &Value, op: &str, name: &'static str) -> Result }) } +/// Insert a column before `at` that looks like column `neighbour` (the one it is inserted beside): +/// its width, and in every row the neighbour cell's explicit formatting with no text. With +/// `keep_width` the columns then shrink in proportion so the table keeps its total width; +/// otherwise (PowerPoint's behaviour) the table grows by the new column and the widths stay. +fn insert_col_like(t: &mut Table, at: usize, neighbour: usize, keep_width: bool) { + let total = t.width(); + let neighbour = neighbour.min(t.cols.len().saturating_sub(1)); + let w = t.cols.get(neighbour).copied(); + let templates: Vec> = t.rows.iter().map(|r| r.cells.get(neighbour).cloned()).collect(); + t.insert_col_beside(at, Some(neighbour)); + let at = at.min(t.cols.len().saturating_sub(1)); + if let (Some(w), Some(slot)) = (w, t.cols.get_mut(at)) { + *slot = w; + } + for (row, tpl) in t.rows.iter_mut().zip(templates) { + let Some(tpl) = tpl else { continue }; + // Inserted left of a merge's anchor, the merge now starts at the new cell, which took the + // anchor's cell whole, text and formatting (Table::insert_col_beside); the old anchor, + // covered now, is the empty cell that gets the neighbour's formatting. Read off the table + // as it is now: a span the file lets run past the last column is no merge to the model. + let anchor_moved = row.cells.get(at).is_some_and(|c| c.grid_span > 1 && !c.is_covered()); + if let Some(cell) = row.cells.get_mut(if anchor_moved { at + 1 } else { at }) { + copy_cell_format(cell, &tpl); + } + } + let grown = t.width(); + if keep_width && total > 1e-9 && grown > 1e-9 { + let k = total / grown; + for cw in &mut t.cols { + *cw *= k; + } + } +} + +/// `dst` takes `src`'s formatting (fill, borders, margins, anchor, text direction and the text +/// properties of its first paragraph, kept on the end mark of an empty paragraph) and keeps its +/// own spans and merge cover. +fn copy_cell_format(dst: &mut Cell, src: &Cell) { + dst.fill.clone_from(&src.fill); + dst.borders.clone_from(&src.borders); + dst.diag_down.clone_from(&src.diag_down); + dst.diag_up.clone_from(&src.diag_up); + dst.margins = src.margins; + dst.anchor = src.anchor; + dst.vertical = src.vertical; + let para = src.text.paragraphs.first().map(|p0| deckcraft_model::text::Paragraph { + level: p0.level, + props: p0.props.clone(), + runs: vec![], + def_props: p0.def_props.clone(), + end_props: p0.runs.first().map_or_else(|| p0.end_props.clone(), |r| r.props.clone()), + }); + dst.text = deckcraft_model::text::TextBody { + body: src.text.body.clone(), + list_style: src.text.list_style.clone(), + paragraphs: vec![para.unwrap_or_default()], + }; +} + fn merge(s: &mut Session, p: &Value) -> Result { let sel = s.doc()?.selection.cells; let from = cell_param(p, "from", "table.merge")?.or(sel.map(|c| c.1)).ok_or_else(|| bad("table.merge", "missing `from`"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 85cbe9d8c..2fd3c23ec 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -5956,3 +5956,144 @@ fn invalid_slide_index_never_falls_back_to_the_selection_in_export_commands() { } let _ = std::fs::remove_dir_all(&dir); } + +/// A `keepWidth` that is not a bool is refused, naming it, and the table is left alone; it used +/// to be read as absent and the columns shrunk as if `true` had been asked. +#[test] +fn table_insert_column_refuses_a_keep_width_that_is_not_a_bool() { + let mut s = session(); + let id = table_at(&mut s, &[280.0, 280.0, 280.0], json!([["a", "b", "c"]])); + for (cmd, bad) in [("table.insertColumnRight", json!("false")), ("table.insertColumnLeft", json!(0))] { + let err = s.execute(cmd, &json!({"id": id.0, "col": 1, "keepWidth": bad})).unwrap_err().to_string(); + assert!(err.contains("keepWidth") && err.contains(cmd), "{err}"); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 3, "{cmd} changed the table: {:?}", t.cols); + assert!((t.width() - 840.0).abs() < 1e-6, "width {}", t.width()); + } +} + +#[test] +fn table_insert_column_extends_a_merge_that_spans_the_neighbour() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["title", "", ""], ["a", "b", "c"]])); + s.execute("table.merge", &json!({"id": id.0, "from": [0, 0], "to": [0, 2]})).unwrap(); + s.execute("table.insertColumnRight", &json!({"id": id.0, "col": 2})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.cell(0, 0).unwrap().grid_span, 4, "the title now spans the new column"); + assert!(t.cell(0, 3).unwrap().h_merge); + assert_eq!(t.cell(1, 0).unwrap().grid_span, 1); + s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": 0})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.cell(0, 0).unwrap().grid_span, 5, "left of the first column too"); + assert_eq!(t.cell(0, 0).unwrap().text.text(), "title"); +} + +/// A span a file lets run past the last column is no merge to the model, so inserting left of +/// that cell adds a plain column: the cell keeps its text and the new one gets its formatting. +#[test] +fn table_insert_column_left_of_an_overflowing_span_keeps_the_text() { + let mut s = session(); + let id = table_at(&mut s, &[100.0, 100.0, 100.0], json!([["a", "b", "wide"], ["d", "e", "f"]])); + { + let doc = std::sync::Arc::make_mut(&mut s.doc_mut().unwrap().doc); + let slide = std::sync::Arc::make_mut(&mut doc.slides[0]); + let sh = deckcraft_model::find_shape_mut(&mut slide.shapes, id).unwrap(); + let deckcraft_model::ShapeKind::Table(t) = &mut sh.kind else { panic!("a table") }; + t.cell_mut(0, 2).unwrap().grid_span = 4; + } + s.execute("table.cellFill", &json!({"id": id.0, "color": "#FF0000", "cells": [[0, 2]]})).unwrap(); + s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": 2})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 4); + assert_eq!(t.cell(0, 3).unwrap().text.text(), "wide", "the cell keeps its text"); + assert_eq!(t.cell(0, 2).unwrap().text.text(), ""); + assert_eq!(t.cell(0, 2).unwrap().fill, t.cell(0, 3).unwrap().fill, "the new cell takes the neighbour's fill"); +} + +#[test] +fn table_insert_column_with_keep_width_keeps_the_table_width_and_copies_the_neighbour_format() { + let mut s = session(); + let id = table_at(&mut s, &[280.0, 280.0, 280.0], json!([["a", "b", "c"], ["d", "e", "f"]])); + s.execute("table.cellFill", &json!({"id": id.0, "color": "#FF0000", "cells": [[0, 2], [1, 2]]})).unwrap(); + s.execute("table.cellMargins", &json!({"id": id.0, "left": 20, "right": 20, "top": 5, "bottom": 5, "cells": [[0, 2]]})).unwrap(); + s.execute("table.cellAlign", &json!({"id": id.0, "anchor": "bottom", "cells": [[0, 2]]})).unwrap(); + s.execute("edit.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("table.selectCells", &json!({"from": [0, 2], "to": [1, 2]})).unwrap(); + s.execute("format.bold", &json!({"on": true})).unwrap(); + // Right of the last column: four columns in the same 840 pt, the new one as wide as its + // neighbour, the others shrunk to make room. + s.execute("table.insertColumnRight", &json!({"id": id.0, "col": 2, "keepWidth": true})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 4); + assert!((t.width() - 840.0).abs() < 1e-6, "width {}", t.width()); + assert!(t.cols.iter().all(|w| (w - 210.0).abs() < 1e-6), "{:?}", t.cols); + let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap(); + assert!((x.w - 840.0).abs() < 1e-6, "frame {}", x.w); + // The new cells take the neighbour's explicit formatting. + let new = t.cell(0, 3).unwrap(); + assert_eq!(new.fill, Some(deckcraft_model::Fill::solid(deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(255, 0, 0))))); + assert_eq!(new.margins, Some([20.0, 20.0, 5.0, 5.0])); + assert_eq!(new.anchor, Some(deckcraft_model::text::Anchor::Bottom)); + assert_eq!(new.text.text(), "", "the new cell is empty"); + let bold = new.text.paragraphs.first().map(|p| p.end_props.bold.or(p.def_props.bold)); + assert_eq!(bold, Some(Some(true)), "the neighbour's bold carries over: {:?}", new.text); + assert_eq!(t.cell(1, 3).unwrap().fill, t.cell(1, 2).unwrap().fill); + // Left of the first column: the neighbour is the old first column. + s.execute("table.insertColumnLeft", &json!({"id": id.0, "col": 0, "keepWidth": true})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 5); + assert!((t.width() - 840.0).abs() < 1e-6, "width {}", t.width()); + assert_eq!(t.cell(0, 1).unwrap().text.text(), "a"); + assert_eq!(t.cell(0, 0).unwrap().fill, None); + // keepWidth: false grows the table by the neighbour's width, as before. + s.execute("table.insertColumnRight", &json!({"id": id.0, "col": 4, "keepWidth": false})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 6); + assert!((t.width() - 840.0 - 168.0).abs() < 1e-6, "width {}", t.width()); + // The insertion is journaled as called, and each one is a single undo step back to the table + // as it was: the columns, their widths and the frame. + let (jid, jp) = s.journal.last().cloned().unwrap(); + assert_eq!(jid, "table.insertColumnRight"); + assert_eq!(jp["keepWidth"], json!(false), "{jp}"); + s.execute("edit.undo", &json!({})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 5); + assert!((t.width() - 840.0).abs() < 1e-6, "width {}", t.width()); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + let t = table_of(&s, id); + assert_eq!(t.n_cols(), 3); + assert!(t.cols.iter().all(|w| (w - 280.0).abs() < 1e-6), "{:?}", t.cols); + assert_eq!(t.cell(0, 2).unwrap().text.text(), "c"); + let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap(); + assert!((x.w - 840.0).abs() < 1e-6, "frame {}", x.w); +} + +/// PowerPoint inserts a column as wide as its neighbour and widens the table by that amount; the +/// other widths stay. Deleting a column narrows the table by its width, the others untouched. +#[test] +fn table_insert_and_delete_column_leave_other_widths_alone() { + let mut s = session(); + let id = table_at(&mut s, &[160.0, 234.0, 235.0, 235.0], json!([["a", "b", "c", "d"]])); + for (cmd, col, at, wide) in [("table.insertColumnRight", 0, 1, 160.0), ("table.insertColumnLeft", 3, 3, 235.0)] { + let before = table_of(&s, id).cols.clone(); + let w0 = table_of(&s, id).width(); + s.execute(cmd, &json!({"id": id.0, "col": col})).unwrap(); + let t = table_of(&s, id); + let mut want = before.clone(); + want.insert(at, wide); + assert_eq!(t.cols, want, "{cmd}"); + assert!((t.width() - w0 - wide).abs() < 1e-6, "{cmd} width {}", t.width()); + let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap(); + assert!((x.w - t.width()).abs() < 1e-6, "{cmd} frame {}", x.w); + } + let before = table_of(&s, id).cols.clone(); + s.execute("table.deleteColumn", &json!({"id": id.0, "col": 1})).unwrap(); + let t = table_of(&s, id); + let mut want = before.clone(); + let gone = want.remove(1); + assert_eq!(t.cols, want); + assert!((t.width() - (before.iter().sum::() - gone)).abs() < 1e-6); + let x = s.doc().unwrap().shape(id).unwrap().xfrm.unwrap(); + assert!((x.w - t.width()).abs() < 1e-6, "frame {}", x.w); +} diff --git a/crates/model/src/table.rs b/crates/model/src/table.rs index 0705d1a08..ce0f0434a 100644 --- a/crates/model/src/table.rs +++ b/crates/model/src/table.rs @@ -116,6 +116,12 @@ impl Table { } /// Insert an empty column before `at`. A merge the new column falls inside grows to cover it. pub fn insert_col(&mut self, at: usize) { + self.insert_col_beside(at, None); + } + /// Like [`Table::insert_col`], for a column inserted beside column `neighbour` (as before the + /// insertion): a horizontal merge that spans the neighbour also grows over the new column, even when the + /// insertion point is at its edge. + pub fn insert_col_beside(&mut self, at: usize, neighbour: Option) { let at = at.min(self.cols.len()); let merges = self.merges(); let w = self.cols.get(at.saturating_sub(1)).copied().unwrap_or(80.0); @@ -124,7 +130,13 @@ impl Table { let i = at.min(r.cells.len()); r.cells.insert(i, Cell::new("")); } - self.set_merges(merges.into_iter().map(|m| m.insert_col(at))); + let grows = |m: &Merge| matches!(neighbour, Some(n) if m.gs > 1 && m.c <= n && n < m.c + m.gs && (at == m.c || at == m.c + m.gs)); + // A merge that grows to the left starts at the new cell, which takes the anchor's text and + // formatting; the old anchor becomes a covered, empty cell. + for m in merges.iter().filter(|m| grows(m) && at == m.c) { + self.move_anchor((m.r, at + 1), (m.r, at)); + } + self.set_merges(merges.into_iter().map(|m| if grows(&m) { Merge { gs: m.gs + 1, ..m } } else { m.insert_col(at) })); } /// Delete row `at`. Merges through it shrink; a merge anchored on it moves its anchor (and /// text) to the next row it covers. @@ -638,4 +650,24 @@ mod tests { assert!(t.split(0, 0)); assert_eq!(t.cell(0, 0).map(|c| (c.grid_span, c.row_span)), Some((1, 1))); } + + /// A column inserted left of a merge's anchor grows the merge, which now starts at the new + /// cell: that cell holds the text, and the old anchor is a covered, empty cell. + #[test] + fn a_column_inserted_left_of_a_merge_keeps_the_text_in_its_anchor() { + let mut t = merged(); + t.insert_col_beside(1, Some(1)); + assert_topology(&t); + let a = t.cell(1, 1).unwrap(); + assert_eq!((a.grid_span, a.row_span), (3, 2)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + assert!(t.cell(1, 2).unwrap().is_covered()); + assert_eq!(anchor_text(&t, 1, 2), ""); + // Right of the merge, the anchor stays where it is. + let mut t = merged(); + t.insert_col_beside(3, Some(2)); + assert_topology(&t); + assert_eq!(t.cell(1, 1).map(|c| c.grid_span), Some(3)); + assert_eq!(anchor_text(&t, 1, 1), "M"); + } }