Files
deckcraft/crates/engine/src/drawtable.rs
T
Nikita RakitinandCursor 427a955c06 Tables: Draw Table pencil and Table Eraser
Insert > Table > Draw Table turns on a table pencil. Dragging a box on an
empty part of the slide makes a one-cell table. Drawing a line inside a
table splits the cells it crosses, across or down, and adds a row or
column only where needed, so the cells it doesn't cross stay whole.
Table Design > Draw Borders has the pencil and a Table Eraser. A click
on a border merges the two cells on either side when together they
form a rectangle, and their text is kept.

The new table.drawLine and table.eraseLine commands are each one undo
step. The grid work is pure (engine/src/drawtable.rs). It rebuilds the
table from its anchor cells, so existing merges survive, and it caps
the grid at 200 rows and columns. Ribbon big buttons now carry an
accessibility label.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-13 23:26:51 +03:00

277 lines
10 KiB
Rust

//! Draw Table: the pencil splits cells along the line you draw, the table eraser merges the two
//! cells on either side of the border you click. Pure operations on a [`Table`] in its own
//! coordinates (points from its top-left corner), unit-tested without a session.
//!
//! Both work on the table's *anchors* (each visible cell with its row and column span), edit
//! that list, then lay the grid back out, so merges anywhere in the table survive.
use deckcraft_model::table::{Cell, Table};
/// A line closer than this to an existing grid line reuses it rather than adding a sliver.
const SNAP: f64 = 4.0;
/// Cap on the grid so a runaway pencil can't build an enormous table.
const MAX_GRID: usize = 200;
#[derive(Clone, Debug)]
struct Anchor {
r: usize,
c: usize,
rs: usize,
cs: usize,
cell: Cell,
}
fn anchors(t: &Table) -> Vec<Anchor> {
let mut v = vec![];
for (r, row) in t.rows.iter().enumerate() {
for (c, cell) in row.cells.iter().enumerate() {
if !cell.is_covered() {
let rs = (cell.row_span.max(1) as usize).min(t.rows.len().saturating_sub(r)).max(1);
let cs = (cell.grid_span.max(1) as usize).min(t.cols.len().saturating_sub(c)).max(1);
v.push(Anchor { r, c, rs, cs, cell: cell.clone() });
}
}
}
v
}
/// Lay `anchors` out into `t`'s grid (its rows and columns already sized).
fn rebuild(t: &mut Table, anchors: Vec<Anchor>) {
let (nr, nc) = (t.rows.len(), t.cols.len());
for row in &mut t.rows {
row.cells = (0..nc).map(|_| Cell::new("")).collect();
}
for a in anchors {
for r in a.r..(a.r + a.rs).min(nr) {
for c in a.c..(a.c + a.cs).min(nc) {
if let Some(x) = t.cell_mut(r, c) {
x.h_merge = c > a.c;
x.v_merge = r > a.r;
}
}
}
if let Some(x) = t.cell_mut(a.r, a.c) {
*x = Cell { grid_span: a.cs as u32, row_span: a.rs as u32, h_merge: false, v_merge: false, ..a.cell };
}
}
t.normalize();
}
/// Swap rows and columns, so the vertical case reuses the horizontal one.
fn transpose(t: &mut Table) {
let (nr, nc) = (t.rows.len(), t.cols.len());
let heights: Vec<f64> = t.rows.iter().map(|r| r.height).collect();
let mut a = anchors(t);
for x in &mut a {
std::mem::swap(&mut x.r, &mut x.c);
std::mem::swap(&mut x.rs, &mut x.cs);
let [l, r, tp, b] = std::mem::take(&mut x.cell.borders);
x.cell.borders = [tp, b, l, r];
}
let widths = std::mem::take(&mut t.cols);
t.cols = heights;
t.rows = widths.into_iter().map(|h| deckcraft_model::table::Row { height: h, cells: vec![] }).collect();
debug_assert_eq!((t.rows.len(), t.cols.len()), (nc, nr));
rebuild(t, a);
}
fn edges(sizes: impl Iterator<Item = f64>) -> Vec<f64> {
let mut e = vec![0.0];
let mut acc = 0.0;
for s in sizes {
acc += s;
e.push(acc);
}
e
}
/// Draw a line from `a` to `b` (table points). Mostly-horizontal lines split rows, mostly-vertical
/// ones split columns; only the cells the line crosses are divided. Returns whether anything
/// changed.
pub fn draw_line(t: &mut Table, a: (f64, f64), b: (f64, f64)) -> bool {
if ![a.0, a.1, b.0, b.1].iter().all(|v| v.is_finite()) || t.rows.is_empty() || t.cols.is_empty() {
return false;
}
if (b.0 - a.0).abs() >= (b.1 - a.1).abs() {
split_rows(t, (a.1 + b.1) / 2.0, a.0.min(b.0), a.0.max(b.0))
} else {
transpose(t);
let changed = split_rows(t, (a.0 + b.0) / 2.0, a.1.min(b.1), a.1.max(b.1));
transpose(t);
changed
}
}
/// A horizontal line at `y` from `x0` to `x1`.
fn split_rows(t: &mut Table, y: f64, x0: f64, x1: f64) -> bool {
let ys = edges(t.rows.iter().map(|r| r.height));
let xs = edges(t.cols.iter().copied());
let total = ys.last().copied().unwrap_or(0.0);
if y <= SNAP || y >= total - SNAP || x1 - x0 < SNAP {
return false;
}
let mut a = anchors(t);
// Boundary index b: an existing grid line near y, or a new one splitting row k.
let b = match ys.iter().position(|e| (e - y).abs() <= SNAP) {
Some(b) => b,
None => {
if t.rows.len() >= MAX_GRID {
return false;
}
let Some(k) = ys.windows(2).position(|w| w[0] < y && y < w[1]) else { return false };
let top = ys[k];
let (h0, h1) = (y - top, ys[k + 1] - y);
if let Some(r) = t.rows.get_mut(k) {
r.height = h0;
}
t.rows.insert(k + 1, deckcraft_model::table::Row { height: h1, cells: vec![] });
for x in &mut a {
if x.r > k {
x.r += 1;
} else if x.r + x.rs > k {
x.rs += 1;
}
}
k + 1
}
};
let mut changed = false;
let mut out = Vec::with_capacity(a.len() + 4);
for x in a {
let (l, r) = (xs.get(x.c).copied().unwrap_or(0.0), xs.get(x.c + x.cs).copied().unwrap_or(0.0));
// The line divides a cell it crosses for more than half of the cell's width.
let overlap = (r.min(x1) - l.max(x0)).max(0.0);
if x.r < b && b < x.r + x.rs && overlap * 2.0 > r - l {
let below =
Anchor { r: b, c: x.c, rs: x.r + x.rs - b, cs: x.cs, cell: Cell { text: deckcraft_model::TextBody::default(), ..x.cell.clone() } };
out.push(Anchor { rs: b - x.r, ..x });
out.push(below);
changed = true;
} else {
out.push(x);
}
}
rebuild(t, out);
changed
}
/// Erase the border nearest `p` (within `tol` points): the two cells it separates merge, when
/// together they make a rectangle. Returns whether a border was erased.
pub fn erase_at(t: &mut Table, p: (f64, f64), tol: f64) -> bool {
if !(p.0.is_finite() && p.1.is_finite()) {
return false;
}
let ys = edges(t.rows.iter().map(|r| r.height));
let xs = edges(t.cols.iter().copied());
let a = anchors(t);
let rect = |x: &Anchor| (xs[x.c], ys[x.r], xs[x.c + x.cs], ys[x.r + x.rs]);
let mut best: Option<(f64, usize, usize)> = None;
for (i, u) in a.iter().enumerate() {
for (j, v) in a.iter().enumerate() {
let (ul, ut, ur, ub) = rect(u);
// v directly right of u with the same rows, or directly below with the same columns.
let d = if v.c == u.c + u.cs && v.r == u.r && v.rs == u.rs && p.1 > ut && p.1 < ub {
(p.0 - ur).abs()
} else if v.r == u.r + u.rs && v.c == u.c && v.cs == u.cs && p.0 > ul && p.0 < ur {
(p.1 - ub).abs()
} else {
continue;
};
if d <= tol && best.is_none_or(|b| d < b.0) {
best = Some((d, i, j));
}
}
}
let Some((_, i, j)) = best else { return false };
let mut a = a;
let v = a[j].clone();
let u = &mut a[i];
if v.r == u.r {
u.cs += v.cs;
} else {
u.rs += v.rs;
}
let mut more = v.cell.text.paragraphs.clone();
if !v.cell.text.is_empty() {
if u.cell.text.is_empty() {
u.cell.text.paragraphs = more;
} else {
u.cell.text.paragraphs.append(&mut more);
}
}
a.remove(j);
rebuild(t, a);
true
}
#[cfg(test)]
mod tests {
use super::*;
fn grid(t: &Table) -> String {
t.rows
.iter()
.map(|r| {
r.cells
.iter()
.map(|c| {
if c.is_covered() {
"."
} else if c.grid_span > 1 || c.row_span > 1 {
"M"
} else {
"o"
}
})
.collect::<String>()
})
.collect::<Vec<_>>()
.join("/")
}
#[test]
fn pencil_splits_only_the_cells_it_crosses() {
let mut t = Table::new(1, 1, 300.0, 100.0);
assert!(draw_line(&mut t, (0.0, 40.0), (300.0, 40.0)), "full-width line adds a row");
assert_eq!((grid(&t), t.rows[0].height, t.rows[1].height), ("o/o".into(), 40.0, 60.0));
assert!(draw_line(&mut t, (120.0, 40.0), (120.0, 100.0)), "a vertical line in the bottom row only");
assert_eq!(grid(&t), "M./oo");
assert_eq!(t.cols, vec![120.0, 180.0]);
assert!(draw_line(&mut t, (120.0, 0.0), (120.0, 40.0)), "on the existing grid line: splits the merged top cell");
assert_eq!(grid(&t), "oo/oo");
assert!(!draw_line(&mut t, (0.0, 1.0), (300.0, 1.0)), "on the outer border");
assert!(!draw_line(&mut t, (f64::NAN, 1.0), (300.0, 1.0)));
assert_eq!(t.width(), 300.0);
assert_eq!(t.height(), 100.0);
}
#[test]
fn eraser_merges_neighbours_and_keeps_text() {
let mut t = Table::new(2, 2, 200.0, 50.0);
t.cell_mut(0, 0).unwrap().text = Cell::new("A").text;
t.cell_mut(0, 1).unwrap().text = Cell::new("B").text;
assert!(erase_at(&mut t, (100.0, 25.0), 5.0), "the vertical border in row 0");
assert_eq!(grid(&t), "M./oo");
assert_eq!(t.cell(0, 0).unwrap().text.paragraphs.len(), 2);
assert!(!erase_at(&mut t, (50.0, 75.0), 5.0), "nowhere near a border");
let mut t = Table::new(2, 2, 200.0, 50.0);
t.merge(0, 0, 0, 1);
assert!(!erase_at(&mut t, (50.0, 50.0), 3.0), "the merged top cell and one bottom cell aren't a rectangle");
assert!(erase_at(&mut t, (100.0, 75.0), 3.0));
assert!(erase_at(&mut t, (50.0, 50.0), 3.0));
assert_eq!(grid(&t), "M./..");
}
#[test]
fn grid_is_capped() {
let mut t = Table::new(1, 1, 300.0, 4000.0);
let mut n = 0;
for i in 1..400 {
if draw_line(&mut t, (0.0, i as f64 * 9.9), (300.0, i as f64 * 9.9)) {
n += 1;
}
}
assert!(t.rows.len() <= MAX_GRID && n > 100, "{} rows", t.rows.len());
}
}