mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-27 03:47:09 -06:00
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com> Co-authored-by: Brandon Thomas <echelon@gmail.com>
792 lines
38 KiB
Rust
792 lines
38 KiB
Rust
//! 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<CommandSpec> {
|
||
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<CellBox> {
|
||
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<Stroke> {
|
||
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<usize, CmdError> {
|
||
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<f32> {
|
||
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<usize> = 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<EraseTarget> {
|
||
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::<Vec<_>>()).collect::<Vec<_>>())
|
||
}
|
||
|
||
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::<String>();
|
||
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");
|
||
}
|
||
}
|