//! Table Layout › Draw: Draw Table and Eraser. A pen stroke on a page either draws a new one-cell //! table (a rectangle outside any table) or splits the cells a straight line crosses; the eraser //! removes the border nearest a point by merging the two cells beside it. Coordinates are page //! points, like the layout's; the app turns the pen and eraser on as canvas modes and sends each //! finished stroke or click here, so every stroke is one undo step. use serde_json::Value; use wordcraft_doc::props::{HeightRule, VMerge}; use wordcraft_doc::table::{MAX_COLS, MAX_ROWS}; use wordcraft_doc::{Block, Cell, Path, Pos, StoryRef, Table, para_block}; use wordcraft_geom::Rect; use wordcraft_layout::{DocLayout, Placed}; use super::sel_result; use crate::{CmdError, CmdResult, CommandSpec, Selection, Session, p}; /// A drag shorter than this (points) both ways isn't a rectangle. pub const MIN_RECT: f32 = 12.0; /// How near (points) the eraser has to be to a border. pub const ERASER_REACH: f32 = 4.0; /// A line closer than this (points) to an existing border lands on it. const SNAP: f32 = 3.0; /// The shortest stroke (points) that counts as a line. const MIN_LINE: f32 = 6.0; pub fn specs() -> Vec { vec![ CommandSpec::new("table.draw", "Draw Table", "Insert › Tables", draw).params( r#"{"page": n, "x0": pt, "y0": pt, "x1": pt, "y1": pt, "story"?: StoryRef} (a pen stroke in page points, 0-based page: a rectangle at least 12 pt each way outside any table inserts a one-cell table of that size at the paragraph under its start; a straight vertical or horizontal line inside a table splits the cells it crosses there. In the app the button turns the pen on.)"#, ), CommandSpec::new("table.eraser", "Eraser", "Table Layout › Draw", eraser).params( r#"{"page": n, "x": pt, "y": pt} (removes the border between two cells within 4 pt of the point: cells that make one rectangle merge, including cells merged down the same rows; otherwise the border is hidden along the whole edge. In the app the button turns the eraser on.)"#, ), ] } /// What a pen stroke does, judged from the layout (also the app's live preview). #[derive(Clone, Copy, Debug, PartialEq)] pub enum Stroke { /// Draw a new table in this rectangle (page points). Rect(Rect), /// Split the cells the vertical line at `x` crosses between `y0` and `y1`. Vertical { x: f32, y0: f32, y1: f32 }, /// Split the cells the horizontal line at `y` crosses between `x0` and `x1`. Horizontal { y: f32, x0: f32, x1: f32 }, } /// A table cell's area on a page. #[derive(Clone, Debug)] struct CellBox { rect: Rect, table: Path, story: StoryRef, row: usize, cell: usize, } fn cell_boxes(l: &DocLayout, page: usize) -> Vec { let Some(pg) = l.pages.get(page) else { return Vec::new() }; pg.items .iter() .filter_map(|it| match it { Placed::Cell { rect, table, row, cell, story, .. } => { Some(CellBox { rect: *rect, table: table.clone(), story: *story, row: *row, cell: *cell }) } _ => None, }) .collect() } fn inside(r: &Rect, x: f32, y: f32) -> bool { x >= r.x && x <= r.right() && y >= r.y && y <= r.bottom() } /// The innermost table with a cell under the point. fn table_at(boxes: &[CellBox], x: f32, y: f32) -> Option<(StoryRef, Path)> { boxes.iter().filter(|b| inside(&b.rect, x, y)).max_by_key(|b| b.table.0.len()).map(|b| (b.story, b.table.clone())) } /// Classify a pen stroke: a straight line inside a table, a rectangle outside one, else `None`. pub fn stroke(l: &DocLayout, page: usize, x0: f32, y0: f32, x1: f32, y1: f32) -> Option { if ![x0, y0, x1, y1].iter().all(|v| v.is_finite()) { return None; } let (dx, dy) = ((x1 - x0).abs(), (y1 - y0).abs()); let boxes = cell_boxes(l, page); let (mx, my) = ((x0 + x1) / 2.0, (y0 + y1) / 2.0); if table_at(&boxes, x0, y0).is_some() || table_at(&boxes, mx, my).is_some() { if dy >= dx && dy >= MIN_LINE && dx <= MIN_RECT.max(dy * 0.2) { return Some(Stroke::Vertical { x: mx, y0: y0.min(y1), y1: y0.max(y1) }); } if dx > dy && dx >= MIN_LINE && dy <= MIN_RECT.max(dx * 0.2) { return Some(Stroke::Horizontal { y: my, x0: x0.min(x1), x1: x0.max(x1) }); } return None; } (dx >= MIN_RECT && dy >= MIN_RECT).then(|| Stroke::Rect(Rect::new(x0.min(x1), y0.min(y1), dx, dy))) } fn page_param(v: &Value) -> Result { p::u64(v, "page").map(|n| usize::try_from(n).unwrap_or(usize::MAX)).ok_or_else(|| CmdError::Params("`page` (number) is required".into())) } fn draw(s: &mut Session, v: &Value) -> CmdResult { let page = page_param(v)?; let (x0, y0, x1, y1) = (p::req_f32(v, "x0")?, p::req_f32(v, "y0")?, p::req_f32(v, "x1")?, p::req_f32(v, "y1")?); let l = s.layout(); if l.pages.get(page).is_none() { return Err(CmdError::Params(format!("no page {page}"))); } match stroke(&l, page, x0, y0, x1, y1) { Some(Stroke::Rect(r)) => draw_table(s, v, &l, page, (x0, y0), r), Some(Stroke::Vertical { x, y0, y1 }) => split_columns(s, &l, page, x, (y0, y1)), Some(Stroke::Horizontal { y, x0, x1 }) => split_rows(s, &l, page, y, (x0, x1)), None => Err(CmdError::Failed( "draw a rectangle (at least 12 pt each way) outside a table, or a straight horizontal or vertical line across table cells".into(), )), } } /// A one-cell table the size of `r`, at the paragraph under the stroke's start. fn draw_table(s: &mut Session, v: &Value, l: &DocLayout, page: usize, start: (f32, f32), r: Rect) -> CmdResult { let story = super::story_param(s, v); let pos = l.hit(page, start.0, start.1, story).ok_or_else(|| CmdError::Failed("there's no text to put a table at there".into()))?; let pos = s.doc.clamp(&pos); if pos.path.cell().is_some() { return Err(CmdError::Failed("draw new tables outside a table; inside one, draw a line to split cells".into())); } let len = s.doc.para(pos.story, &pos.path).map(|q| q.len()).ok_or_else(|| CmdError::Failed("there's no paragraph there".into()))?; let tw = super::page::sect(s).text_width().max(18.0); let w = r.w.clamp(18.0, tw); let mut t = Table::new(1, 1, w); t.props.width = Some(w); let body_x = l.pages.get(page).map_or(0.0, |pg| pg.body.x); let indent = r.x - body_x; if indent > 1.0 { t.props.indent = Some(indent.min((tw - w).max(0.0))); } if let Some(row) = t.rows.first_mut() { row.props.height = Some(r.h.clamp(4.0, 1584.0)); row.props.height_rule = HeightRule::AtLeast; } // Before the paragraph when the stroke starts at its beginning (or it's empty), else after it. let at = if pos.off == 0 || len == 0 { pos.path.clone() } else { s.doc.split_paragraph(&Pos { off: len, ..pos.clone() })?.path }; s.doc.insert_block(pos.story, &at, Block::Table(t))?; let mut first = at.0.clone(); first.extend([0, 0, 0]); s.sel = Selection::caret(Pos { story: pos.story, path: Path(first), off: 0 }); sel_result(s) } /// The cells of one table a stroke crosses, as (row, cell) with their areas, and that table. fn crossed(boxes: &[CellBox], at: (f32, f32), hit: impl Fn(&Rect) -> bool) -> Option<(StoryRef, Path, Vec<(usize, usize, Rect)>)> { let (story, table) = table_at(boxes, at.0, at.1) .or_else(|| boxes.iter().filter(|b| hit(&b.rect)).max_by_key(|b| b.table.0.len()).map(|b| (b.story, b.table.clone())))?; let mut cells: Vec<(usize, usize, Rect)> = boxes.iter().filter(|b| b.story == story && b.table == table && hit(&b.rect)).map(|b| (b.row, b.cell, b.rect)).collect(); cells.sort_by_key(|c| (c.0, c.1)); cells.dedup_by_key(|c| (c.0, c.1)); Some((story, table, cells)) } /// Grid column offsets: `cum[i]` is where grid column `i` starts. fn offsets(t: &Table) -> Vec { let mut cum = Vec::with_capacity(t.grid.len() + 1); let mut x = 0.0f32; cum.push(0.0); for w in &t.grid { x += w.max(0.0); cum.push(x); } cum } /// The rows of the vertically merged group cell `c` of row `r` belongs to (just `r` when none). fn vgroup(t: &Table, r: usize, c: usize) -> (usize, usize) { let g = t.grid_col(r, c); let starts_at = |row: usize| t.cell_at_grid(row, g).filter(|&ci| t.grid_col(row, ci) == g).and_then(|ci| t.rows.get(row)?.cells.get(ci)); let Some(cell) = starts_at(r) else { return (r, r) }; if cell.props.vmerge == VMerge::None { return (r, r); } let mut r0 = r; while r0 > 0 && starts_at(r0).is_some_and(|x| x.props.vmerge == VMerge::Continue) { r0 -= 1; } let mut r1 = r; while starts_at(r1 + 1).is_some_and(|x| x.props.vmerge == VMerge::Continue) { r1 += 1; } (r0, r1) } /// A cell as the grid rectangle it covers: rows `top..=bot`, grid columns `g0..g1`. #[derive(Clone, Copy, Debug)] struct Logical { top: usize, bot: usize, g0: usize, g1: usize, } fn logical(t: &Table, r: usize, c: usize) -> Logical { let g0 = t.grid_col(r, c); let span = t.rows.get(r).and_then(|row| row.cells.get(c)).map_or(1, Cell::span); let (top, bot) = vgroup(t, r, c); Logical { top, bot, g0, g1: g0 + span } } /// The rows (`top`, `bot`) two cells side by side share when erasing the border between them /// leaves one rectangle: every row of both has exactly their cells there, merged the same way. fn aligned_rows(t: &Table, a: &Logical, b: &Logical) -> Option<(usize, usize)> { if a.top != b.top || a.bot != b.bot || a.g1 != b.g0 { return None; } for row in a.top..=a.bot { let ci = t.cell_at_grid(row, a.g0).filter(|&ci| t.grid_col(row, ci) == a.g0)?; let cells = &t.rows.get(row)?.cells; let (x, y) = (cells.get(ci)?, cells.get(ci + 1)?); if x.span() != a.g1 - a.g0 || y.span() != b.g1 - b.g0 || x.props.vmerge != y.props.vmerge { return None; } } Some((a.top, a.bot)) } /// Hide the border line on both sides along an edge: set no border on every cell beside it. /// `vertical`: the line sits left of grid column `line` and the edge covers rows `lo..=hi`; /// otherwise it sits above row `line` and covers grid columns `lo..hi`. fn hide_edge(t: &mut Table, vertical: bool, line: usize, (lo, hi): (usize, usize)) { let nil = wordcraft_doc::props::Border { style: wordcraft_doc::props::BorderStyle::None, width: 0.0, color: None, space: 0.0 }; // Only a cell whose edge lies on the line is touched. let set_v = |t: &mut Table, r: usize, g: usize, before: bool| { let Some(ci) = t.cell_at_grid(r, g) else { return }; let start = t.grid_col(r, ci); let Some(cell) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(ci)) else { return }; let end = start + cell.span(); let b = cell.props.borders.get_or_insert_with(Default::default); if before && end == line { b.right = Some(nil); } else if !before && start == line { b.left = Some(nil); } }; let set_h = |t: &mut Table, r: usize, g: usize, above: bool| { let Some(ci) = t.cell_at_grid(r, g) else { return }; let Some(cell) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(ci)) else { return }; let b = cell.props.borders.get_or_insert_with(Default::default); if above { b.bottom = Some(nil); } else { b.top = Some(nil); } }; if vertical { for r in lo..=hi.min(t.rows.len().saturating_sub(1)) { if let Some(l) = line.checked_sub(1) { set_v(t, r, l, true); } set_v(t, r, line, false); } } else { let below_cont = |t: &Table, g: usize| { t.cell_at_grid(line, g).and_then(|ci| t.rows.get(line)?.cells.get(ci)).is_some_and(|c| c.props.vmerge == VMerge::Continue) }; for g in lo..hi.min(MAX_COLS) { if below_cont(t, g) { continue; } if let Some(above) = line.checked_sub(1) { set_h(t, above, g, true); } set_h(t, line, g, false); } } } /// A vertical line at page `x`: split each crossed cell into two columns there. fn split_columns(s: &mut Session, l: &DocLayout, page: usize, x: f32, (ya, yb): (f32, f32)) -> CmdResult { let boxes = cell_boxes(l, page); let hit = |r: &Rect| { let overlap = yb.min(r.bottom()) - ya.max(r.y); x > r.x + SNAP && x < r.right() - SNAP && overlap >= r.h * 0.5 }; let Some((story, tp, cells)) = crossed(&boxes, (x, (ya + yb) / 2.0), hit) else { return Err(CmdError::Failed("the line doesn't cross a table cell".into())); }; let Some(&(r0, c0, rect0)) = cells.first() else { return Err(CmdError::Failed("the line doesn't cross a table cell".into())) }; let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("the table isn't there any more".into()))?; // Where the line falls in the table's grid, measured in the first crossed cell. let cum = offsets(t); let a = t.grid_col(r0, c0); let span = t.rows.get(r0).and_then(|row| row.cells.get(c0)).map_or(1, Cell::span); let (start, end) = (cum.get(a).copied().unwrap_or(0.0), cum.get(a + span).or(cum.last()).copied().unwrap_or(0.0)); let g = start + (x - rect0.x) / rect0.w.max(1.0) * (end - start); if !g.is_finite() || g <= 0.0 { return Err(CmdError::Failed("the line doesn't cross a table cell".into())); } let crossed_set: std::collections::BTreeSet<(usize, usize)> = cells.iter().map(|c| (c.0, c.1)).collect(); // A vertically merged cell splits only as a whole. for &(r, c, _) in &cells { let (g0, g1) = vgroup(t, r, c); let gc = t.grid_col(r, c); for rr in g0..=g1 { let ci = t.cell_at_grid(rr, gc).unwrap_or(usize::MAX); if !crossed_set.contains(&(rr, ci)) { return Err(CmdError::Failed("the line crosses only part of a merged cell; draw it across the whole cell".into())); } } } // Snap to an existing grid line, else a new one inside grid column `k`. let existing = (1..t.grid.len()).find(|&j| cum.get(j).is_some_and(|c| (c - g).abs() < SNAP)); let k = (0..t.grid.len()).find(|&k| cum.get(k + 1).is_some_and(|&e| g < e)).unwrap_or(t.grid.len().saturating_sub(1)); if existing.is_none() && t.grid.len() >= MAX_COLS { return Err(CmdError::Failed(format!("a table can't have more than {MAX_COLS} columns"))); } let t = s.doc.table_mut(story, &tp)?; let mut changed = false; match existing { Some(j) => { for &(r, c) in crossed_set.iter().rev() { let a = t.grid_col(r, c); let Some(row) = t.rows.get_mut(r) else { continue }; let Some(cell) = row.cells.get_mut(c) else { continue }; let span = cell.span(); if a < j && j < a + span { cell.props.span = (j - a) as u32; let mut nc = Cell::empty(); nc.props = cell.props.clone(); nc.props.span = (a + span - j) as u32; row.cells.insert(c + 1, nc); changed = true; } } } None => { let ks = cum.get(k).copied().unwrap_or(0.0); let ke = cum.get(k + 1).copied().unwrap_or(ks); let (left, right) = (g - ks, ke - g); if left < SNAP || right < SNAP { return Err(CmdError::Failed("there's already a border there".into())); } if let Some(w) = t.grid.get_mut(k) { *w = left; } t.grid.insert((k + 1).min(t.grid.len()), right); for r in 0..t.rows.len() { let Some(ci) = t.cell_at_grid(r, k) else { continue }; let a = t.grid_col(r, ci); let Some(row) = t.rows.get_mut(r) else { continue }; let Some(cell) = row.cells.get_mut(ci) else { continue }; if crossed_set.contains(&(r, ci)) { let span = cell.span(); cell.props.span = (k + 1 - a) as u32; let mut nc = Cell::empty(); nc.props = cell.props.clone(); nc.props.span = (a + span - k) as u32; row.cells.insert(ci + 1, nc); } else { cell.props.span = cell.props.span.saturating_add(1); } } changed = true; } } if !changed { return Err(CmdError::Failed("there's already a border there".into())); } super::table::sync_cell_widths(t); finish(s, story, &tp, r0, c0 + 1) } /// A horizontal line at page `y`: split the row it crosses there. Crossed cells become two; /// the row's other cells span both rows (a vertical merge). fn split_rows(s: &mut Session, l: &DocLayout, page: usize, y: f32, (xa, xb): (f32, f32)) -> CmdResult { let boxes = cell_boxes(l, page); let hit = |r: &Rect| { let overlap = xb.min(r.right()) - xa.max(r.x); y > r.y + SNAP && y < r.bottom() - SNAP && overlap >= r.w * 0.5 }; let Some((story, tp, cells)) = crossed(&boxes, ((xa + xb) / 2.0, y), hit) else { return Err(CmdError::Failed("the line doesn't cross a table cell".into())); }; let Some(&(r, c0, _)) = cells.first() else { return Err(CmdError::Failed("the line doesn't cross a table cell".into())) }; if cells.iter().any(|c| c.0 != r) { return Err(CmdError::Failed("draw the line within one row".into())); } // The row's extent on this page. let row_boxes: Vec<&Rect> = boxes.iter().filter(|b| b.story == story && b.table == tp && b.row == r).map(|b| &b.rect).collect(); let top = row_boxes.iter().map(|b| b.y).fold(f32::INFINITY, f32::min); let bottom = row_boxes.iter().map(|b| b.bottom()).fold(f32::NEG_INFINITY, f32::max); let t = s.doc.table(story, &tp).ok_or_else(|| CmdError::Failed("the table isn't there any more".into()))?; if t.rows.len() >= MAX_ROWS { return Err(CmdError::Failed("the table has too many rows".into())); } // An exact height leaves out the row's bottom margin (its cells' largest), which the layout // adds below it: take it off each half so the two rows draw where the line went. let row_bottom = t.rows.get(r).map_or(0.0, |row| (0..row.cells.len()).map(|ci| wordcraft_layout::cell_margins(&s.doc, t, r, ci)[2]).fold(0.0f32, f32::max)); let crossed_set: std::collections::BTreeSet = cells.iter().map(|c| c.1).collect(); let merged = |ci: &usize| t.rows.get(r).and_then(|row| row.cells.get(*ci)).is_some_and(|c| c.props.vmerge != VMerge::None); if crossed_set.iter().any(merged) { return Err(CmdError::Failed("splitting a vertically merged cell across rows isn't supported; erase its merge first".into())); } let t = s.doc.table_mut(story, &tp)?; let Some(row) = t.rows.get_mut(r) else { return Err(CmdError::Failed("the row isn't there any more".into())) }; let mut new_row = wordcraft_doc::Row { props: row.props.clone(), cells: Vec::with_capacity(row.cells.len()), controls: Default::default() }; new_row.props.header = false; if top.is_finite() && bottom.is_finite() && bottom > top { let rule = if row.props.height_rule == HeightRule::Exact { HeightRule::Exact } else { HeightRule::AtLeast }; let less = if rule == HeightRule::Exact { row_bottom } else { 0.0 }; row.props.height = Some((y - top - less).clamp(1.0, 1584.0)); row.props.height_rule = rule; new_row.props.height = Some((bottom - y - less).clamp(1.0, 1584.0)); new_row.props.height_rule = rule; } for (ci, cell) in row.cells.iter_mut().enumerate() { let mut nc = Cell::empty(); nc.props = cell.props.clone(); if crossed_set.contains(&ci) { nc.props.vmerge = VMerge::None; } else { if cell.props.vmerge == VMerge::None { cell.props.vmerge = VMerge::Restart; } nc.props.vmerge = VMerge::Continue; } new_row.cells.push(nc); } t.rows.insert(r + 1, new_row); finish(s, story, &tp, r + 1, c0) } /// Put the caret in cell (`r`, `c`) of the table (clamped) and re-lay its paragraphs out. fn finish(s: &mut Session, story: StoryRef, tp: &Path, r: usize, c: usize) -> CmdResult { let Some(t) = s.doc.table(story, tp) else { return sel_result(s) }; let r = r.min(t.rows.len().saturating_sub(1)); let c = c.min(t.rows.get(r).map_or(0, |x| x.cells.len().saturating_sub(1))); let mut path = tp.0.clone(); path.extend([r as u32, c as u32, 0]); s.sel = Selection::caret(s.doc.clamp(&Pos { story, path: Path(path), off: 0 })); super::table::touch_cells(s, tp)?; sel_result(s) } /// What the eraser would remove at a point: the border's two ends (page points) and the merge. #[derive(Clone, Debug, PartialEq)] pub struct EraseTarget { pub from: (f32, f32), pub to: (f32, f32), story: StoryRef, table: Path, /// (row, cell) of the cell left of / above the border. first: (usize, usize), /// (row, cell) of the cell right of / below it. second: (usize, usize), across: bool, } /// The border between two cells nearest page point (`x`, `y`), within [`ERASER_REACH`]. pub fn erase_target(l: &DocLayout, page: usize, x: f32, y: f32) -> Option { if !x.is_finite() || !y.is_finite() { return None; } let boxes = cell_boxes(l, page); let mut best: Option<(f32, usize, EraseTarget)> = None; let mut offer = |d: f32, depth: usize, e: EraseTarget| { if d <= ERASER_REACH && best.as_ref().is_none_or(|(bd, bdepth, _)| d < *bd - 0.01 || (d <= *bd + 0.01 && depth > *bdepth)) { best = Some((d, depth, e)); } }; for a in &boxes { let (ar, ab) = (a.rect.right(), a.rect.bottom()); let same = |b: &&CellBox| b.story == a.story && b.table == a.table; // The border on a's right, shared with the next cell of its row. if (x - ar).abs() <= ERASER_REACH && y >= a.rect.y && y <= ab && let Some(b) = boxes.iter().filter(same).find(|b| b.row == a.row && b.cell == a.cell + 1 && (b.rect.x - ar).abs() <= 1.5) { let e = EraseTarget { from: (ar, a.rect.y.max(b.rect.y)), to: (ar, ab.min(b.rect.bottom())), story: a.story, table: a.table.clone(), first: (a.row, a.cell), second: (b.row, b.cell), across: true, }; offer((x - ar).abs(), a.table.0.len(), e); } // The border below a, shared with a cell of the next row. if (y - ab).abs() <= ERASER_REACH && x >= a.rect.x && x <= ar && let Some(b) = boxes.iter().filter(same).find(|b| b.row == a.row + 1 && (b.rect.y - ab).abs() <= 1.5 && x >= b.rect.x && x <= b.rect.right()) { let e = EraseTarget { from: (a.rect.x.max(b.rect.x), ab), to: (ar.min(b.rect.right()), ab), story: a.story, table: a.table.clone(), first: (a.row, a.cell), second: (b.row, b.cell), across: false, }; offer((y - ab).abs(), a.table.0.len(), e); } } best.map(|b| b.2) } fn eraser(s: &mut Session, v: &Value) -> CmdResult { let page = page_param(v)?; let (x, y) = (p::req_f32(v, "x")?, p::req_f32(v, "y")?); let l = s.layout(); if l.pages.get(page).is_none() { return Err(CmdError::Params(format!("no page {page}"))); } let e = erase_target(&l, page, x, y).ok_or_else(|| CmdError::Failed("there's no border between two cells there".into()))?; let (story, tp) = (e.story, e.table.clone()); let t = s.doc.table_mut(story, &tp)?; let ((r, c), (r2, c2)) = (e.first, e.second); let cell = |t: &Table, r: usize, c: usize| t.rows.get(r).and_then(|row| row.cells.get(c)).map(|x| (x.props.vmerge, x.span())); let (Some((_, sa)), Some((vb, sb))) = (cell(t, r, c), cell(t, r2, c2)) else { return Err(CmdError::Failed("the cells aren't there any more".into())); }; let (la, lb) = (logical(t, r, c), logical(t, r2, c2)); if e.across { // Both sides merged down the same rows (or plain single cells): they become one cell. if let Some(rows) = aligned_rows(t, &la, &lb) { let ci0 = t.cell_at_grid(rows.0, la.g0).unwrap_or(0); for row in rows.0..=rows.1 { if let Some(ci) = t.cell_at_grid(row, la.g0) { t.merge_right(row, ci, ci + 1); } } super::table::sync_cell_widths(t); return finish(s, story, &tp, rows.0, ci0); } // Two sides that can't make one rectangle: the border is only hidden. hide_edge(t, true, la.g1, (la.top.min(lb.top), la.bot.max(lb.bot))); let ci0 = t.cell_at_grid(la.top, la.g0).unwrap_or(0); return finish(s, story, &tp, la.top, ci0); } let (ga, gb) = (t.grid_col(r, c), t.grid_col(r2, c2)); if ga != gb || sa != sb { hide_edge(t, false, la.bot + 1, (la.g0.min(lb.g0), la.g1.max(lb.g1))); let ci0 = t.cell_at_grid(la.top, la.g0).unwrap_or(0); return finish(s, story, &tp, la.top, ci0); } if vb == VMerge::Continue { return Err(CmdError::Failed("those cells are already merged".into())); } let (top, _) = vgroup(t, r, c); if let Some(a) = t.rows.get_mut(r).and_then(|row| row.cells.get_mut(c)) && a.props.vmerge == VMerge::None { a.props.vmerge = VMerge::Restart; } let mut moved = Vec::new(); if let Some(b) = t.rows.get_mut(r2).and_then(|row| row.cells.get_mut(c2)) { b.props.vmerge = VMerge::Continue; let blocks = std::mem::replace(&mut b.blocks, vec![para_block(wordcraft_doc::Paragraph::new())]); moved.extend(blocks.into_iter().filter(|x| x.as_para().is_none_or(|q| !q.is_empty()))); } let tc = t.cell_at_grid(top, ga).unwrap_or(0); if !moved.is_empty() && let Some(dst) = t.rows.get_mut(top).and_then(|row| row.cells.get_mut(tc)) { if dst.blocks.len() == 1 && dst.blocks.first().and_then(|b| b.as_para()).is_some_and(wordcraft_doc::Paragraph::is_empty) { dst.blocks.clear(); } dst.blocks.extend(moved); } finish(s, story, &tp, top, tc) } #[cfg(test)] mod tests { use super::*; use serde_json::json; /// Each row's cell spans. fn shape(t: &Table) -> Value { json!(t.rows.iter().map(|r| r.cells.iter().map(|c| c.span()).collect::>()).collect::>()) } fn first_table(s: &Session) -> Table { s.doc.body.iter().find_map(|b| b.as_table()).cloned().unwrap() } fn cell_rect(s: &mut Session, row: usize, cell: usize) -> Rect { s.layout().pages[0] .items .iter() .find_map(|it| match it { Placed::Cell { rect, row: r, cell: c, .. } if *r == row && *c == cell => Some(*rect), _ => None, }) .unwrap() } /// A rectangle drawn on the page inserts a one-cell table of its size; undo takes it out. #[test] fn a_drawn_rectangle_inserts_a_table() { let mut s = Session::new(wordcraft_doc::Document::new()); s.run("document.setText", &json!({"text": "Above the table"})).unwrap(); let before = s.doc.clone(); let l = s.layout(); let body = l.pages[0].body; let (x0, y0) = (body.x + 40.0, body.y + 2.0); s.run("table.draw", &json!({"page": 0, "x0": x0, "y0": y0, "x1": x0 + 200.0, "y1": y0 + 60.0})).unwrap(); let t = first_table(&s); assert_eq!((t.rows.len(), t.rows[0].cells.len()), (1, 1)); assert_eq!(t.props.width, Some(200.0)); assert_eq!(t.rows[0].props.height, Some(60.0)); assert!(t.props.indent.is_some_and(|i| (i - 40.0).abs() < 0.5)); assert!(s.sel.focus.path.cell().is_some(), "the caret goes into the new cell"); let r = cell_rect(&mut s, 0, 0); assert!((r.w - 200.0).abs() < 1.0 && r.h >= 59.5, "{r:?}"); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(s.doc.body, before.body, "one undo step"); // Too small, or nowhere: errors, never panics. assert!(s.run("table.draw", &json!({"page": 0, "x0": x0, "y0": y0, "x1": x0 + 5.0, "y1": y0 + 5.0})).is_err()); assert!(s.run("table.draw", &json!({"page": 9, "x0": 1, "y0": 1, "x1": 100, "y1": 100})).is_err()); assert!(s.run("table.draw", &json!({})).is_err()); } /// A line drawn through an exact-height row splits it into two exact rows that draw where /// the line went: the bottom margin the layout adds below each isn't counted twice. #[test] fn a_line_through_an_exact_row_keeps_its_drawn_height() { let mut s = Session::new(wordcraft_doc::Document::new()); s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); let t = s.doc.body.iter_mut().find_map(|b| match std::sync::Arc::make_mut(b) { Block::Table(t) => Some(t), _ => None, }); let t = t.unwrap(); t.props.cell_margins = Some([4.0, 5.4, 4.0, 5.4]); t.rows[0].props.height = Some(60.0); t.rows[0].props.height_rule = HeightRule::Exact; s.touch(); let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 0, 1)); assert!((a.h - 64.0).abs() < 0.5, "an exact 60pt row with a 4pt bottom margin: {a:?}"); let y = a.y + 25.0; s.run("table.draw", &json!({"page": 0, "x0": b.x + 1.0, "y0": y, "x1": b.right() - 1.0, "y1": y})).unwrap(); let t = first_table(&s); assert_eq!((t.rows[0].props.height_rule, t.rows[1].props.height_rule), (HeightRule::Exact, HeightRule::Exact)); let (top, low) = (cell_rect(&mut s, 0, 1), cell_rect(&mut s, 1, 1)); assert!((top.h - 25.0).abs() < 0.5 && (top.y - a.y).abs() < 0.01, "{top:?}"); assert!((low.bottom() - a.bottom()).abs() < 0.5, "the pair keeps the row's height: {low:?} vs {a:?}"); } /// Erasing a horizontal border merges the cells into one laid-out cell (#640): the lower /// row's box is `covered`, the merged cell's box spans both rows, and erasing the border /// below the merge extends it. #[test] fn erasing_a_horizontal_border_lays_the_merge_out_as_one_cell() { let mut s = Session::new(wordcraft_doc::Document::new()); s.run("insert.table", &json!({"rows": 3, "cols": 2})).unwrap(); let (a, b) = (cell_rect(&mut s, 0, 0), cell_rect(&mut s, 1, 0)); s.run("table.eraser", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.bottom() + 0.5})).unwrap(); let t = first_table(&s); assert_eq!((t.rows[0].cells[0].props.vmerge, t.rows[1].cells[0].props.vmerge), (VMerge::Restart, VMerge::Continue)); let merged = cell_rect(&mut s, 0, 0); assert!((merged.bottom() - b.bottom()).abs() < 0.5 && merged.h > a.h * 1.9, "{merged:?}"); let covered = s.layout().pages[0].items.iter().any(|it| matches!(it, Placed::Cell { row: 1, cell: 0, covered: true, .. })); assert!(covered, "the continuation row's box is covered"); let hit = s.layout().cell_at(0, merged.x + 3.0, b.y + b.h / 2.0).map(|c| (c.2, c.3)); assert_eq!(hit, Some((0, 0)), "a click in the lower half is in the merged cell"); // The border below the merge: the merge grows to three rows. s.run("table.eraser", &json!({"page": 0, "x": merged.x + merged.w / 2.0, "y": merged.bottom() + 0.5})).unwrap(); let t = first_table(&s); assert_eq!(t.rows[2].cells[0].props.vmerge, VMerge::Continue); } /// A vertical line through a cell splits it into two columns; a horizontal one splits a row; /// the eraser merges the cells back; each is one undo step. #[test] fn lines_split_cells_and_the_eraser_merges_them() { let mut s = Session::new(wordcraft_doc::Document::new()); s.run("insert.table", &json!({"rows": 2, "cols": 2})).unwrap(); s.run("text.insert", &json!({"text": "A"})).unwrap(); let start = first_table(&s); let r = cell_rect(&mut s, 0, 0); let x = r.x + r.w * 0.25; s.run("table.draw", &json!({"page": 0, "x0": x, "y0": r.y + 1.0, "x1": x + 1.0, "y1": r.bottom() - 1.0})).unwrap(); let t = first_table(&s); assert_eq!(t.grid.len(), 3); assert_eq!(shape(&t), json!([[1, 1, 1], [2, 1]]), "the crossed cell splits, the other row's cell spans the new column"); assert!((t.grid[0] - start.grid[0] * 0.25).abs() < 1.0, "{:?}", t.grid); let text = t.rows[0].cells[0].blocks.iter().filter_map(|b| b.as_para().map(|q| q.plain_text())).collect::(); assert_eq!(text, "A", "the text stays in the left part"); // The eraser on the new border merges the two cells again. let r0 = cell_rect(&mut s, 0, 0); s.run("table.eraser", &json!({"page": 0, "x": r0.right() + 1.0, "y": r0.y + r0.h / 2.0})).unwrap(); let t = first_table(&s); assert_eq!(shape(&t), json!([[2, 1], [2, 1]])); // A horizontal line across the second column's first cell splits its row. let r1 = cell_rect(&mut s, 0, 1); let y = r1.y + r1.h / 2.0; s.run("table.draw", &json!({"page": 0, "x0": r1.x + 1.0, "y0": y, "x1": r1.right() - 1.0, "y1": y})).unwrap(); let t = first_table(&s); assert_eq!(t.rows.len(), 3); assert_eq!(t.rows[0].cells[0].props.vmerge, VMerge::Restart, "the uncrossed cell spans both rows"); assert_eq!(t.rows[1].cells[0].props.vmerge, VMerge::Continue); assert_eq!(t.rows[1].cells[1].props.vmerge, VMerge::None); // Erasing that line merges the split cells (a vertical merge). let a = cell_rect(&mut s, 0, 1); s.run("table.eraser", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.bottom() + 0.5})).unwrap(); let t = first_table(&s); assert_eq!(t.rows[0].cells[1].props.vmerge, VMerge::Restart); assert_eq!(t.rows[1].cells[1].props.vmerge, VMerge::Continue); // Four steps, four undos back to the start. for _ in 0..4 { s.run("edit.undo", &json!({})).unwrap(); } assert_eq!(first_table(&s), start); // Nothing near a border, or a hostile point: an error. let c = cell_rect(&mut s, 0, 0); assert!(s.run("table.eraser", &json!({"page": 0, "x": c.x + c.w / 2.0, "y": c.y + c.h / 2.0})).is_err()); assert!(s.run("table.eraser", &json!({"page": 0, "x": 1e30, "y": -1e30})).is_err()); assert_eq!(first_table(&s), start); } /// A 3x3 table with columns 1 and 2 merged down rows 0-1 (made with the eraser itself). fn merged_columns_table() -> Session { let mut s = Session::new(wordcraft_doc::Document::new()); s.run("insert.table", &json!({"rows": 3, "cols": 3})).unwrap(); for cell in [2, 1] { let a = cell_rect(&mut s, 0, cell); s.run("table.eraser", &json!({"page": 0, "x": a.x + a.w / 2.0, "y": a.bottom() + 0.5})).unwrap(); } s } /// The border between two cells merged down the same rows merges them into one cell that is /// still merged down those rows; one undo step. #[test] fn erasing_between_cells_merged_down_the_same_rows_merges_them() { let mut s = merged_columns_table(); let start = first_table(&s); assert_eq!(shape(&start), json!([[1, 1, 1], [1, 1, 1], [1, 1, 1]])); let a = cell_rect(&mut s, 0, 1); s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + 3.0})).unwrap(); let t = first_table(&s); assert_eq!(shape(&t), json!([[1, 2], [1, 2], [1, 1, 1]])); assert_eq!((t.rows[0].cells[1].props.vmerge, t.rows[1].cells[1].props.vmerge), (VMerge::Restart, VMerge::Continue)); let merged = cell_rect(&mut s, 0, 1); assert!(merged.w > a.w * 1.9 && (merged.h - a.h).abs() < 0.5, "{merged:?}"); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(first_table(&s), start); } /// A border whose two sides can't make one rectangle (a one-row cell beside a cell merged /// down two rows) is hidden on both sides instead of refused; nothing merges; one undo step. #[test] fn erasing_between_cells_that_dont_line_up_hides_the_border() { let mut s = merged_columns_table(); let start = first_table(&s); let a = cell_rect(&mut s, 0, 0); s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + a.h / 2.0})).unwrap(); let t = first_table(&s); assert_eq!(shape(&t), shape(&start), "no merge"); for r in 0..2 { let right = t.rows[r].cells[0].props.borders.and_then(|b| b.right); let left = t.rows[r].cells[1].props.borders.and_then(|b| b.left); assert!(right.is_some_and(|b| !b.is_visible()) && left.is_some_and(|b| !b.is_visible()), "row {r}"); } assert!(t.rows[2].cells[0].props.borders.is_none(), "rows outside the edge are untouched"); s.run("edit.undo", &json!({})).unwrap(); assert_eq!(first_table(&s), start); // A cell spanning two columns above single cells: the horizontal border is hidden too. let a = cell_rect(&mut s, 0, 1); s.run("table.eraser", &json!({"page": 0, "x": a.right() + 0.5, "y": a.y + 3.0})).unwrap(); let merged = cell_rect(&mut s, 0, 1); let before = first_table(&s); s.run("table.eraser", &json!({"page": 0, "x": merged.x + 3.0, "y": merged.bottom() + 0.5})).unwrap(); let t = first_table(&s); assert_eq!(shape(&t), shape(&before), "no merge"); let vis = |r: usize, c: usize, top: bool| { let b = t.rows[r].cells[c].props.borders; b.and_then(|b| if top { b.top } else { b.bottom }).is_some_and(|b| !b.is_visible()) }; assert!(vis(1, 1, false) && vis(2, 1, true) && vis(2, 2, true), "both sides along the whole edge"); assert!(!vis(2, 0, true), "the first column is not part of the edge"); } }