From 427a955c066d19ad417679e7c80035d9c44e372c Mon Sep 17 00:00:00 2001 From: Nikita Rakitin Date: Tue, 13 Oct 2026 23:26:51 +0300 Subject: [PATCH] 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 --- crates/engine/src/cmd/table.rs | 56 ++++++ crates/engine/src/drawtable.rs | 276 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tools.rs | 28 ++- crates/ui-egui/src/canvas.rs | 10 + crates/ui-egui/src/drawtable_tests.rs | 84 ++++++++ crates/ui-egui/src/ribbon.rs | 20 ++ crates/ui-egui/src/widgets.rs | 3 + docs/parity-checklist.md | 6 +- 9 files changed, 480 insertions(+), 4 deletions(-) create mode 100644 crates/engine/src/drawtable.rs create mode 100644 crates/ui-egui/src/drawtable_tests.rs diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index e9f6f164..17f3bbc9 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -61,6 +61,24 @@ pub fn specs() -> Vec { )), cmd!("table.columnWidth", "Width", ["Table Layout", "Cell Size"], None, "{col, width: pt, id?}", has_selection, column_width), cmd!("table.rowHeight", "Height", ["Table Layout", "Cell Size"], None, "{row, height: pt, id?}", has_selection, row_height), + cmd!( + "table.drawLine", + "Draw Table", + ["Table Design", "Draw Borders"], + None, + "{from: [x, y], to: [x, y] slide pt, id?: table (default: the one under `from`)} splits the cells the line crosses", + has_slide, + draw_line + ), + cmd!( + "table.eraseLine", + "Table Eraser", + ["Table Design", "Draw Borders"], + None, + "{at: [x, y] slide pt, id?, tol?: pt (default 6)} merges the two cells on either side of the nearest border", + has_slide, + erase_line + ), cmd!(noundo "table.selectCells", "Select Cells", [], None, "{from: [r,c], to: [r,c], id?}", has_selection, select_cells), ] } @@ -266,3 +284,41 @@ fn select_cells(s: &mut Session, p: &Value) -> Result { })?; ok() } + +fn point_param(p: &Value, key: &str) -> Option<(f64, f64)> { + let a = p.get(key)?; + let (x, y) = (a.get(0)?.as_f64()?, a.get(1)?.as_f64()?); + (x.is_finite() && y.is_finite()).then_some((x, y)) +} + +/// The table under slide point `at` (frontmost), or the one named by `id`, with its origin. +pub(crate) fn table_at(s: &Session, p: &Value, at: (f64, f64)) -> Option<(deckcraft_model::ShapeId, (f64, f64))> { + let st = s.doc().ok()?; + let want = id_param(p, "id"); + st.shapes().iter().rev().find_map(|sh| { + let x = sh.xfrm?; + let ShapeKind::Table(t) = &sh.kind else { return None }; + let inside = at.0 >= x.x && at.1 >= x.y && at.0 <= x.x + t.width() && at.1 <= x.y + t.height(); + (want.map_or(inside, |w| w == sh.id)).then_some((sh.id, (x.x, x.y))) + }) +} + +fn draw_line(s: &mut Session, p: &Value) -> Result { + let from = point_param(p, "from").ok_or_else(|| bad("table.drawLine", "missing `from`"))?; + let to = point_param(p, "to").ok_or_else(|| bad("table.drawLine", "missing `to`"))?; + let (id, o) = table_at(s, p, from).ok_or_else(|| bad("table.drawLine", "no table there"))?; + let (a, b) = ((from.0 - o.0, from.1 - o.1), (to.0 - o.0, to.1 - o.1)); + with_table(s, &json!({"id": id.0}), "table.drawLine", move |t| { + if crate::drawtable::draw_line(t, a, b) { Ok(()) } else { Err(bad("table.drawLine", "the line doesn't divide any cell")) } + }) +} + +fn erase_line(s: &mut Session, p: &Value) -> Result { + let at = point_param(p, "at").ok_or_else(|| bad("table.eraseLine", "missing `at`"))?; + let tol = p.get("tol").and_then(Value::as_f64).filter(|v| v.is_finite()).unwrap_or(6.0).clamp(0.5, 50.0); + let (id, o) = table_at(s, p, at).ok_or_else(|| bad("table.eraseLine", "no table there"))?; + let q = (at.0 - o.0, at.1 - o.1); + with_table(s, &json!({"id": id.0}), "table.eraseLine", move |t| { + if crate::drawtable::erase_at(t, q, tol) { Ok(()) } else { Err(bad("table.eraseLine", "no border to erase there")) } + }) +} diff --git a/crates/engine/src/drawtable.rs b/crates/engine/src/drawtable.rs new file mode 100644 index 00000000..2b49b7ac --- /dev/null +++ b/crates/engine/src/drawtable.rs @@ -0,0 +1,276 @@ +//! 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 { + 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) { + 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 = 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) -> Vec { + 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::() + }) + .collect::>() + .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()); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index c2b56c7f..cbff1848 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -9,6 +9,7 @@ pub mod cmd; pub mod connect; +pub mod drawtable; pub mod fonts; pub mod guard; pub mod keys; diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index bf8b5c31..4847c601 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -144,6 +144,10 @@ enum Drag { /// Shape-gallery names that draw a freeform path instead of a preset. pub const FREEFORM_TOOLS: [(&str, &str); 3] = [("curve", "Curve"), ("freeform", "Freeform: Shape"), ("scribble", "Freeform: Scribble")]; +/// Draw Table's pencil and eraser, as `ToolKind::Shape` presets (Insert › Table › Draw Table). +pub const DRAW_TABLE: &str = "drawTable"; +pub const TABLE_ERASER: &str = "tableEraser"; + pub fn is_freeform_tool(name: &str) -> bool { FREEFORM_TOOLS.iter().any(|f| f.0 == name) } @@ -346,6 +350,10 @@ impl Session { self.tool.path_closing = false; return Ok(Value::Null); } + ToolKind::Shape { preset } if preset == TABLE_ERASER => { + // Click a border to merge the cells on either side; elsewhere does nothing. + return Ok(self.execute("table.eraseLine", &json!({"at": [p.x, p.y], "tol": tol.max(4.0)})).unwrap_or(Value::Null)); + } ToolKind::Shape { .. } | ToolKind::TextBox => { self.tool.drag = Some(Drag::Create { start: p }); self.tool.preview = Some(Xfrm::new(p.x, p.y, 0.0, 0.0)); @@ -613,6 +621,13 @@ impl Session { } let s0 = self.glue_at(start, tol).map(|g| g.2).unwrap_or(start); line_xfrm(s0, q, 0.0) + } else if matches!(&self.tool.kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) + && crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() + { + // The pencil inside a table draws a straight border, across or down. + let q = if (p.x - start.x).abs() >= (p.y - start.y).abs() { Point::new(p.x, start.y) } else { Point::new(start.x, p.y) }; + let r = Rect::from_points(start, q); + Xfrm::new(r.x0, r.y0, r.width(), r.height()) } else { let mut r = Rect::from_points(start, p); if mods.shift { @@ -708,6 +723,16 @@ impl Session { let preview = self.tool.preview.take(); let dragged = (p - start).hypot() > 2.0; let res = match &kind { + ToolKind::Shape { preset } if preset == DRAW_TABLE => { + // Inside a table: split cells along the line. Elsewhere: a one-cell table. + if crate::cmd::table::table_at(self, &Value::Null, (start.x, start.y)).is_some() { + self.execute("table.drawLine", &json!({"from": [start.x, start.y], "to": [p.x, p.y]})).unwrap_or(Value::Null) + } else if let Some(x) = preview.filter(|x| dragged && x.w >= 8.0 && x.h >= 8.0) { + self.execute("insert.table", &json!({"rows": 1, "cols": 1, "rect": [x.x, x.y, x.w, x.h]}))? + } else { + Value::Null + } + } ToolKind::Shape { preset } => { let line = deckcraft_geom::preset::is_line_like(preset); let rect = match preview.filter(|_| dragged) { @@ -741,7 +766,8 @@ impl Session { } _ => Value::Null, }; - if !self.tool.sticky { + // The table pencil stays on until you pick another tool, as in PowerPoint. + if !self.tool.sticky && !matches!(&kind, ToolKind::Shape { preset } if preset == DRAW_TABLE) { self.tool.kind = ToolKind::Select; } out = res; diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 253765bf..46f6dd5f 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -608,6 +608,16 @@ fn draw_preview(app: &SlideApp, p: &egui::Painter, xf: Xf, x: Xfrm, t: &Tokens) ToolKind::Shape { preset } => preset.clone(), _ => "rect".into(), }; + if preset == deckcraft_engine::tools::DRAW_TABLE { + // A border inside a table (flat), or the outline of a new table. + let (a, b) = (xf.to_screen(Point::new(x.x, x.y)), xf.to_screen(Point::new(x.x + x.w, x.y + x.h))); + if x.w < 0.5 || x.h < 0.5 { + p.line_segment([a, b], Stroke::new(1.5, t.accent)); + } else { + p.rect_stroke(Rect::from_two_pos(a, b), 0.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + return; + } if deckcraft_geom::preset::is_line_like(&preset) { let a = x.affine(); p.line_segment([xf.to_screen(a * Point::new(0.0, 0.0)), xf.to_screen(a * Point::new(x.w, x.h))], Stroke::new(1.5, t.accent)); diff --git a/crates/ui-egui/src/drawtable_tests.rs b/crates/ui-egui/src/drawtable_tests.rs new file mode 100644 index 00000000..98866b2d --- /dev/null +++ b/crates/ui-egui/src/drawtable_tests.rs @@ -0,0 +1,84 @@ +//! Draw Table driven like a person: Insert › Table › Draw Table, drag out a table, draw a border +//! across it, then the Table Eraser merges the cells back. + +use deckcraft_engine::Session; +use egui::{Modifiers, PointerButton}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +use crate::{Services, SlideApp}; + +fn drag(h: &mut Harness<'_, SlideApp>, a: egui::Pos2, b: egui::Pos2) { + h.hover_at(a); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: a, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.run_steps(1); + for i in 1..=10 { + h.event(egui::Event::PointerMoved(a + (b - a) * (i as f32 / 10.0))); + h.run_steps(1); + } + h.event(egui::Event::PointerButton { pos: b, button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(2); +} + +fn table(h: &Harness<'_, SlideApp>) -> Option<(deckcraft_model::Table, deckcraft_geom::Xfrm)> { + h.state().session.active()?.shapes().iter().find_map(|s| match &s.kind { + deckcraft_model::ShapeKind::Table(t) => Some((t.clone(), s.xfrm?)), + _ => None, + }) +} + +#[test] +fn draw_a_table_split_it_and_erase_the_border() { + let mut s = Session::with_new(); + s.execute("slide.new", &json!({"layout": "blank"})).unwrap(); + let mut app = SlideApp::new(s, Services::default()); + app.ui.tab = "Insert".into(); + let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_ui_state( + |ui, app: &mut SlideApp| { + app.logic(ui.ctx()); + app.ui(ui); + }, + app, + ); + h.run_steps(3); + h.get_by_label("Table").click(); + h.run_steps(2); + h.get_by_label("Draw Table").click(); + h.run_steps(2); + let (slide, k) = (h.state().slide_rect.unwrap(), h.state().canvas_scale); + let at = |x: f32, y: f32| slide.min + egui::vec2(x * k, y * k); + + drag(&mut h, at(200.0, 100.0), at(600.0, 300.0)); + let (t, x) = table(&h).expect("the pencil drew a table"); + assert_eq!((t.rows.len(), t.cols.len()), (1, 1)); + assert!((x.w - 400.0).abs() < 3.0 && (x.h - 200.0).abs() < 3.0, "{x:?}"); + + // Still the pencil: a border across, then one down the bottom half only. + drag(&mut h, at(205.0, 180.0), at(610.0, 180.0)); + drag(&mut h, at(350.0, 190.0), at(350.0, 299.0)); + let (t, _) = table(&h).unwrap(); + assert_eq!((t.rows.len(), t.cols.len()), (2, 2), "{t:?}"); + assert_eq!(t.cell(0, 0).unwrap().grid_span, 2, "the top row stays one cell"); + if let Ok(img) = h.render() { + let _ = img.save("/tmp/draw_table.png"); + } + + // The eraser on the vertical border merges the bottom cells again. + let ids: Vec = h.state().session.active().unwrap().shapes().iter().map(|s| s.id.0).collect(); + h.state_mut().session.execute("edit.select", &json!({"ids": ids})).unwrap(); + h.state_mut().ui.tab = "Table Design".into(); + h.run_steps(3); + h.get_by_label("Table Eraser").click(); + h.run_steps(2); + h.hover_at(at(350.0, 250.0)); + h.run_steps(1); + h.event(egui::Event::PointerButton { pos: at(350.0, 250.0), button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); + h.event(egui::Event::PointerButton { pos: at(350.0, 250.0), button: PointerButton::Primary, pressed: false, modifiers: Modifiers::NONE }); + h.run_steps(3); + let (t, _) = table(&h).unwrap(); + assert_eq!(t.cell(1, 0).unwrap().grid_span, 2, "{t:?}"); + h.state_mut().session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table(&h).unwrap().0.cell(1, 0).unwrap().grid_span, 1); +} diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 428eff0b..0f4a4727 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1027,6 +1027,10 @@ fn insert(app: &mut SlideApp, ui: &mut Ui) { app.dialog = Some(crate::dialogs::Dialog::new("table")); ui.close(); } + if ui.button("Draw Table").clicked() { + app.session.set_tool(deckcraft_engine::ToolKind::Shape { preset: deckcraft_engine::tools::DRAW_TABLE.into() }); + ui.close(); + } }); }); group(ui, |ui| { @@ -2224,6 +2228,18 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) { } }); }); + // Draw Borders: the pencil splits cells along a line, the eraser merges across a border. + group(ui, |ui| { + use deckcraft_engine::ToolKind; + use deckcraft_engine::tools::{DRAW_TABLE, TABLE_ERASER}; + let cur = app.session.tool.kind.clone(); + for (icon, label, preset) in [(Icon::Pencil, "Draw Table", DRAW_TABLE), (Icon::Eraser, "Table Eraser", TABLE_ERASER)] { + let on = matches!(&cur, ToolKind::Shape { preset: p } if p == preset); + if big_button_ex(ui, icon, label, true, false, on).0.clicked() { + app.session.set_tool(if on { ToolKind::Select } else { ToolKind::Shape { preset: preset.into() } }); + } + } + }); } pub fn paint_table_style(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::ColorScheme) { @@ -2420,3 +2436,7 @@ fn paint_freeform_icon(p: &egui::Painter, r: Rect, name: &str, ink: Color32) { }; p.add(egui::Shape::line(pts, egui::Stroke::new(1.3, ink))); } + +#[cfg(test)] +#[path = "drawtable_tests.rs"] +mod drawtable_tests; diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 5827b396..97480599 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -39,6 +39,9 @@ pub fn big_button_ex(ui: &mut Ui, icon: Icon, label: &str, enabled: bool, split: let text_w = lines.iter().map(|l| ui.fonts_mut(|f| f.layout_no_wrap(l.to_string(), font.clone(), t.text).size().x)).fold(0.0f32, f32::max); let w = (text_w + 10.0 + if split && lines.len() == 1 { 10.0 } else { 0.0 }).max(40.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, RIBBON_H - 2.0), if enabled { Sense::click() } else { Sense::hover() }); + // Named for screen readers and UI tests ("Ink to\nShape" reads "Ink to Shape"). + let spoken = label.replace('\n', " "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, &spoken)); let painter = ui.painter_at(rect.expand(1.0)); let (main_rect, arrow_rect) = if split { (Rect::from_min_max(rect.min, pos2(rect.max.x, rect.min.y + 38.0)), Some(Rect::from_min_max(pos2(rect.min.x, rect.min.y + 38.0), rect.max))) diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 2a3d450c..4a1c6a8f 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -9,7 +9,7 @@ The summary between ` |---|---|---|---|---|---| | P0 | 72 | 61 | 11 | 0 | 92% | | P1 | 65 | 38 | 21 | 6 | 75% | -| P2 | 54 | 13 | 15 | 26 | 38% | +| P2 | 54 | 13 | 16 | 25 | 39% | | Area | Parity | |---|---| @@ -22,7 +22,7 @@ The summary between ` | Format | 83% | | Arrange | 100% | | Text | 80% | -| Tables | 89% | +| Tables | 92% | | Charts | 65% | | SmartArt | 33% | | Pictures | 48% | @@ -164,7 +164,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou | Text | Right-to-left and complex scripts | P1 | P | | Text | East Asian text (CJK fonts, line breaking) | P1 | P | | Tables | Insert table (grid picker, dialog) | P0 | D | -| Tables | Draw table | P2 | M | +| Tables | Draw table | P2 | P | | Tables | Table styles and style options | P0 | D | | Tables | Insert/delete rows and columns | P0 | D | | Tables | Merge and split cells | P1 | D |