mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-31 22:16:59 -06:00
Merge PR #544 from NikitkaRa: Tables: Draw Table pencil and Table Eraser
# Conflicts: # crates/ui-egui/src/ribbon.rs
This commit is contained in:
@@ -88,6 +88,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
)),
|
||||
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),
|
||||
]
|
||||
}
|
||||
@@ -433,3 +451,41 @@ fn select_cells(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
})?;
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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")) }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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<Anchor> {
|
||||
let mut v = vec![];
|
||||
for (r, row) in t.rows.iter().enumerate() {
|
||||
for (c, cell) in row.cells.iter().enumerate() {
|
||||
if !cell.is_covered() {
|
||||
let rs = (cell.row_span.max(1) as usize).min(t.rows.len().saturating_sub(r)).max(1);
|
||||
let cs = (cell.grid_span.max(1) as usize).min(t.cols.len().saturating_sub(c)).max(1);
|
||||
v.push(Anchor { r, c, rs, cs, cell: cell.clone() });
|
||||
}
|
||||
}
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// Lay `anchors` out into `t`'s grid (its rows and columns already sized).
|
||||
fn rebuild(t: &mut Table, anchors: Vec<Anchor>) {
|
||||
let (nr, nc) = (t.rows.len(), t.cols.len());
|
||||
for row in &mut t.rows {
|
||||
row.cells = (0..nc).map(|_| Cell::new("")).collect();
|
||||
}
|
||||
for a in anchors {
|
||||
for r in a.r..(a.r + a.rs).min(nr) {
|
||||
for c in a.c..(a.c + a.cs).min(nc) {
|
||||
if let Some(x) = t.cell_mut(r, c) {
|
||||
x.h_merge = c > a.c;
|
||||
x.v_merge = r > a.r;
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(x) = t.cell_mut(a.r, a.c) {
|
||||
*x = Cell { grid_span: a.cs as u32, row_span: a.rs as u32, h_merge: false, v_merge: false, ..a.cell };
|
||||
}
|
||||
}
|
||||
t.normalize();
|
||||
}
|
||||
|
||||
/// Swap rows and columns, so the vertical case reuses the horizontal one.
|
||||
fn transpose(t: &mut Table) {
|
||||
let (nr, nc) = (t.rows.len(), t.cols.len());
|
||||
let heights: Vec<f64> = t.rows.iter().map(|r| r.height).collect();
|
||||
let mut a = anchors(t);
|
||||
for x in &mut a {
|
||||
std::mem::swap(&mut x.r, &mut x.c);
|
||||
std::mem::swap(&mut x.rs, &mut x.cs);
|
||||
let [l, r, tp, b] = std::mem::take(&mut x.cell.borders);
|
||||
x.cell.borders = [tp, b, l, r];
|
||||
}
|
||||
let widths = std::mem::take(&mut t.cols);
|
||||
t.cols = heights;
|
||||
t.rows = widths.into_iter().map(|h| deckcraft_model::table::Row { height: h, cells: vec![] }).collect();
|
||||
debug_assert_eq!((t.rows.len(), t.cols.len()), (nc, nr));
|
||||
rebuild(t, a);
|
||||
}
|
||||
|
||||
fn edges(sizes: impl Iterator<Item = f64>) -> Vec<f64> {
|
||||
let mut e = vec![0.0];
|
||||
let mut acc = 0.0;
|
||||
for s in sizes {
|
||||
acc += s;
|
||||
e.push(acc);
|
||||
}
|
||||
e
|
||||
}
|
||||
|
||||
/// Draw a line from `a` to `b` (table points). Mostly-horizontal lines split rows, mostly-vertical
|
||||
/// ones split columns; only the cells the line crosses are divided. Returns whether anything
|
||||
/// changed.
|
||||
pub fn draw_line(t: &mut Table, a: (f64, f64), b: (f64, f64)) -> bool {
|
||||
if ![a.0, a.1, b.0, b.1].iter().all(|v| v.is_finite()) || t.rows.is_empty() || t.cols.is_empty() {
|
||||
return false;
|
||||
}
|
||||
if (b.0 - a.0).abs() >= (b.1 - a.1).abs() {
|
||||
split_rows(t, (a.1 + b.1) / 2.0, a.0.min(b.0), a.0.max(b.0))
|
||||
} else {
|
||||
transpose(t);
|
||||
let changed = split_rows(t, (a.0 + b.0) / 2.0, a.1.min(b.1), a.1.max(b.1));
|
||||
transpose(t);
|
||||
changed
|
||||
}
|
||||
}
|
||||
|
||||
/// A horizontal line at `y` from `x0` to `x1`.
|
||||
fn split_rows(t: &mut Table, y: f64, x0: f64, x1: f64) -> bool {
|
||||
let ys = edges(t.rows.iter().map(|r| r.height));
|
||||
let xs = edges(t.cols.iter().copied());
|
||||
let total = ys.last().copied().unwrap_or(0.0);
|
||||
if y <= SNAP || y >= total - SNAP || x1 - x0 < SNAP {
|
||||
return false;
|
||||
}
|
||||
let mut a = anchors(t);
|
||||
// Boundary index b: an existing grid line near y, or a new one splitting row k.
|
||||
let b = match ys.iter().position(|e| (e - y).abs() <= SNAP) {
|
||||
Some(b) => b,
|
||||
None => {
|
||||
if t.rows.len() >= MAX_GRID {
|
||||
return false;
|
||||
}
|
||||
let Some(k) = ys.windows(2).position(|w| w[0] < y && y < w[1]) else { return false };
|
||||
let top = ys[k];
|
||||
let (h0, h1) = (y - top, ys[k + 1] - y);
|
||||
if let Some(r) = t.rows.get_mut(k) {
|
||||
r.height = h0;
|
||||
}
|
||||
t.rows.insert(k + 1, deckcraft_model::table::Row { height: h1, cells: vec![] });
|
||||
for x in &mut a {
|
||||
if x.r > k {
|
||||
x.r += 1;
|
||||
} else if x.r + x.rs > k {
|
||||
x.rs += 1;
|
||||
}
|
||||
}
|
||||
k + 1
|
||||
}
|
||||
};
|
||||
let mut changed = false;
|
||||
let mut out = Vec::with_capacity(a.len() + 4);
|
||||
for x in a {
|
||||
let (l, r) = (xs.get(x.c).copied().unwrap_or(0.0), xs.get(x.c + x.cs).copied().unwrap_or(0.0));
|
||||
// The line divides a cell it crosses for more than half of the cell's width.
|
||||
let overlap = (r.min(x1) - l.max(x0)).max(0.0);
|
||||
if x.r < b && b < x.r + x.rs && overlap * 2.0 > r - l {
|
||||
let below =
|
||||
Anchor { r: b, c: x.c, rs: x.r + x.rs - b, cs: x.cs, cell: Cell { text: deckcraft_model::TextBody::default(), ..x.cell.clone() } };
|
||||
out.push(Anchor { rs: b - x.r, ..x });
|
||||
out.push(below);
|
||||
changed = true;
|
||||
} else {
|
||||
out.push(x);
|
||||
}
|
||||
}
|
||||
rebuild(t, out);
|
||||
changed
|
||||
}
|
||||
|
||||
/// Erase the border nearest `p` (within `tol` points): the two cells it separates merge, when
|
||||
/// together they make a rectangle. Returns whether a border was erased.
|
||||
pub fn erase_at(t: &mut Table, p: (f64, f64), tol: f64) -> bool {
|
||||
if !(p.0.is_finite() && p.1.is_finite()) {
|
||||
return false;
|
||||
}
|
||||
let ys = edges(t.rows.iter().map(|r| r.height));
|
||||
let xs = edges(t.cols.iter().copied());
|
||||
let a = anchors(t);
|
||||
let rect = |x: &Anchor| (xs[x.c], ys[x.r], xs[x.c + x.cs], ys[x.r + x.rs]);
|
||||
let mut best: Option<(f64, usize, usize)> = None;
|
||||
for (i, u) in a.iter().enumerate() {
|
||||
for (j, v) in a.iter().enumerate() {
|
||||
let (ul, ut, ur, ub) = rect(u);
|
||||
// v directly right of u with the same rows, or directly below with the same columns.
|
||||
let d = if v.c == u.c + u.cs && v.r == u.r && v.rs == u.rs && p.1 > ut && p.1 < ub {
|
||||
(p.0 - ur).abs()
|
||||
} else if v.r == u.r + u.rs && v.c == u.c && v.cs == u.cs && p.0 > ul && p.0 < ur {
|
||||
(p.1 - ub).abs()
|
||||
} else {
|
||||
continue;
|
||||
};
|
||||
if d <= tol && best.is_none_or(|b| d < b.0) {
|
||||
best = Some((d, i, j));
|
||||
}
|
||||
}
|
||||
}
|
||||
let Some((_, i, j)) = best else { return false };
|
||||
let mut a = a;
|
||||
let v = a[j].clone();
|
||||
let u = &mut a[i];
|
||||
if v.r == u.r {
|
||||
u.cs += v.cs;
|
||||
} else {
|
||||
u.rs += v.rs;
|
||||
}
|
||||
let mut more = v.cell.text.paragraphs.clone();
|
||||
if !v.cell.text.is_empty() {
|
||||
if u.cell.text.is_empty() {
|
||||
u.cell.text.paragraphs = more;
|
||||
} else {
|
||||
u.cell.text.paragraphs.append(&mut more);
|
||||
}
|
||||
}
|
||||
a.remove(j);
|
||||
rebuild(t, a);
|
||||
true
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn grid(t: &Table) -> String {
|
||||
t.rows
|
||||
.iter()
|
||||
.map(|r| {
|
||||
r.cells
|
||||
.iter()
|
||||
.map(|c| {
|
||||
if c.is_covered() {
|
||||
"."
|
||||
} else if c.grid_span > 1 || c.row_span > 1 {
|
||||
"M"
|
||||
} else {
|
||||
"o"
|
||||
}
|
||||
})
|
||||
.collect::<String>()
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("/")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pencil_splits_only_the_cells_it_crosses() {
|
||||
let mut t = Table::new(1, 1, 300.0, 100.0);
|
||||
assert!(draw_line(&mut t, (0.0, 40.0), (300.0, 40.0)), "full-width line adds a row");
|
||||
assert_eq!((grid(&t), t.rows[0].height, t.rows[1].height), ("o/o".into(), 40.0, 60.0));
|
||||
assert!(draw_line(&mut t, (120.0, 40.0), (120.0, 100.0)), "a vertical line in the bottom row only");
|
||||
assert_eq!(grid(&t), "M./oo");
|
||||
assert_eq!(t.cols, vec![120.0, 180.0]);
|
||||
assert!(draw_line(&mut t, (120.0, 0.0), (120.0, 40.0)), "on the existing grid line: splits the merged top cell");
|
||||
assert_eq!(grid(&t), "oo/oo");
|
||||
assert!(!draw_line(&mut t, (0.0, 1.0), (300.0, 1.0)), "on the outer border");
|
||||
assert!(!draw_line(&mut t, (f64::NAN, 1.0), (300.0, 1.0)));
|
||||
assert_eq!(t.width(), 300.0);
|
||||
assert_eq!(t.height(), 100.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eraser_merges_neighbours_and_keeps_text() {
|
||||
let mut t = Table::new(2, 2, 200.0, 50.0);
|
||||
t.cell_mut(0, 0).unwrap().text = Cell::new("A").text;
|
||||
t.cell_mut(0, 1).unwrap().text = Cell::new("B").text;
|
||||
assert!(erase_at(&mut t, (100.0, 25.0), 5.0), "the vertical border in row 0");
|
||||
assert_eq!(grid(&t), "M./oo");
|
||||
assert_eq!(t.cell(0, 0).unwrap().text.paragraphs.len(), 2);
|
||||
assert!(!erase_at(&mut t, (50.0, 75.0), 5.0), "nowhere near a border");
|
||||
let mut t = Table::new(2, 2, 200.0, 50.0);
|
||||
t.merge(0, 0, 0, 1);
|
||||
assert!(!erase_at(&mut t, (50.0, 50.0), 3.0), "the merged top cell and one bottom cell aren't a rectangle");
|
||||
assert!(erase_at(&mut t, (100.0, 75.0), 3.0));
|
||||
assert!(erase_at(&mut t, (50.0, 50.0), 3.0));
|
||||
assert_eq!(grid(&t), "M./..");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn grid_is_capped() {
|
||||
let mut t = Table::new(1, 1, 300.0, 4000.0);
|
||||
let mut n = 0;
|
||||
for i in 1..400 {
|
||||
if draw_line(&mut t, (0.0, i as f64 * 9.9), (300.0, i as f64 * 9.9)) {
|
||||
n += 1;
|
||||
}
|
||||
}
|
||||
assert!(t.rows.len() <= MAX_GRID && n > 100, "{} rows", t.rows.len());
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
pub mod cmd;
|
||||
pub mod connect;
|
||||
pub mod drawtable;
|
||||
pub mod fonts;
|
||||
pub mod guard;
|
||||
pub mod icons;
|
||||
|
||||
@@ -171,6 +171,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)
|
||||
}
|
||||
@@ -402,6 +406,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));
|
||||
@@ -713,6 +721,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 {
|
||||
@@ -822,6 +837,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) {
|
||||
@@ -855,7 +880,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;
|
||||
|
||||
@@ -716,6 +716,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));
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//! 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");
|
||||
|
||||
// The eraser on the vertical border merges the bottom cells again.
|
||||
let ids: Vec<u32> = 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);
|
||||
}
|
||||
@@ -1187,6 +1187,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| {
|
||||
@@ -2883,6 +2887,18 @@ fn paint_recolor(p: &egui::Painter, r: Rect, id: &str, sc: &deckcraft_color::Col
|
||||
p.rect_filled(Rect::from_min_size(pos2(r.left() + k as f32 * w, r.top()), vec2(w + 0.5, r.height())), CornerRadius::ZERO, c);
|
||||
}
|
||||
p.rect_stroke(r, CornerRadius::ZERO, Stroke::new(0.5, Color32::from_gray(160)), egui::StrokeKind::Outside);
|
||||
// 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) {
|
||||
@@ -3356,3 +3372,7 @@ mod title_bar_tests {
|
||||
assert_eq!(title_geometry(false), (TITLE_H, 10.0, 26.0));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "drawtable_tests.rs"]
|
||||
mod drawtable_tests;
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -16,6 +16,8 @@ The summary between `<!-- SUMMARY -->
|
||||
| P2 | 54 | 14 | 15 | 25 | 40% |
|
||||
| P1 | 65 | 39 | 21 | 5 | 76% |
|
||||
| P2 | 54 | 14 | 17 | 23 | 42% |
|
||||
| P1 | 65 | 38 | 21 | 6 | 75% |
|
||||
| P2 | 54 | 13 | 16 | 25 | 39% |
|
||||
|
||||
| Area | Parity |
|
||||
|---|---|
|
||||
@@ -34,6 +36,7 @@ The summary between `<!-- SUMMARY -->
|
||||
| Arrange | 100% |
|
||||
| Text | 80% |
|
||||
| Tables | 95% |
|
||||
| Tables | 92% |
|
||||
| Charts | 65% |
|
||||
| SmartArt | 33% |
|
||||
| Pictures | 52% |
|
||||
@@ -177,7 +180,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 |
|
||||
|
||||
Reference in New Issue
Block a user