Files
figcraft/crates/engine/src/board_table.rs
T
BFlat d3782fdc43 M12: board templates and tables (#1363)
* M12: board templates and tables

Five built-in board templates (retrospective, brainstorm, weekly planner,
flow chart, kanban board) placed as ordinary board objects in one undo step
(board.template, board.templates) from a toolbar picker with pictures, and
board tables made of auto layout frames (board.table, board.tableEdit; up to
50 x 20), typed into with a double-click and edited by row and column from
the bar over the selection. Docs: boards-parity, gap 24, ROADMAP, mcp.

* M12: board templates picker on egui 0.37

Id::new and Ui::id are deprecated in egui 0.37: the templates picker's area uses Id::unique, its buttons' interaction ids come from ui.scope_id(), and its test reads the board bar's area by Id::unique.
2026-10-26 12:52:39 +07:00

301 lines
13 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Board tables: a frame of row frames holding cell frames with text, all ordinary auto layout
//! layers. The table hugs its rows' height, the rows share its width and the cells share their
//! row's width, so a cell that wraps more text makes its row taller. Each row but the last draws
//! a line under itself and the column lines are thin rectangles over the table that stretch with
//! it (they are rebuilt after every edit, like the first row's header tint).
use figcraft_color::{Color, Paint};
use figcraft_doc::{
AutoLayout, BoardItem, Constraints, Document, HConstraint, LayoutMode, Node, NodeId, NodeKind, Sizing, StrokeAlign, TextAlignH, VConstraint,
};
use figcraft_geom::Affine;
use serde_json::{Value, json};
use crate::board::{INK, add_text, require};
use crate::cmd::CommandSpec;
use crate::params::{get, id, num, num_or, opt_index, req_string};
use crate::{EngineError, Result, Session, bad, spec, textedit};
/// The most rows and columns a table has.
pub const MAX_ROWS: usize = 50;
pub const MAX_COLUMNS: usize = 20;
/// A new column's width, and a cell's height before its text is measured.
const CELL_W: f64 = 160.0;
const CELL_H: f64 = 40.0;
/// Room around a cell's text: [top, right, bottom, left].
const CELL_PAD: [f64; 4] = [10.0, 12.0, 10.0, 12.0];
const TEXT_SIZE: f64 = 14.0;
const LINE_W: f64 = 1.0;
const GRID: Color = Color::from_rgb8_const(0xCF, 0xCB, 0xC4);
const HEADER: Color = Color::from_rgb8_const(0xF1, 0xEF, 0xEB);
pub fn specs() -> Vec<CommandSpec> {
vec![
spec!(
"board.table",
"Table",
"{rows?, columns? (default 3 × 3, or the size of `cells`; at most 50 × 20), cells? ([[text…]…] row by row), x?, y? (its top-left on the page; default centred in the view)} → {id, rows, columns, cells: [[text id…]…]}",
true,
table_cmd
),
spec!(
"board.tableEdit",
"Edit table",
"{id? (a table or a layer in one; default the selection), action: addRow|addColumn|removeRow|removeColumn, index? (where the new one goes, or which one goes; default the last)} → {id, rows, columns, cells}",
true,
edit_cmd
),
]
}
/// A count from 1 to `max` (`default` when missing).
fn count(cmd: &str, p: &Value, key: &str, default: usize, max: usize) -> Result<usize> {
match num(p, key) {
None if get(p, key).is_none() => Ok(default),
Some(v) if v.fract() == 0.0 && (1.0..=max as f64).contains(&v) => Ok(v as usize),
_ => Err(bad(cmd, format!("`{key}` is a whole number from 1 to {max}"))),
}
}
/// A frame for the table at `index` in `parent`, made by `f`.
fn frame(doc: &mut Document, parent: NodeId, index: Option<usize>, name: &str, size: (f64, f64), f: impl FnOnce(&mut Node)) -> Result<NodeId> {
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Frame, name, size.0, size.1);
n.fills.clear();
n.clip = false;
f(&mut n);
doc.insert(n, parent, index)?;
Ok(id)
}
fn make_cell(doc: &mut Document, row: NodeId, index: Option<usize>, width: f64, text: &str) -> Result<NodeId> {
let cell = frame(doc, row, index, "Cell", (width, CELL_H), |n| {
n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: CELL_PAD, ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.child_layout.min_h = Some(CELL_H);
})?;
add_text(doc, cell, text, TEXT_SIZE, INK, TextAlignH::Left, (width - CELL_PAD[1] - CELL_PAD[3]).max(1.0))?;
Ok(cell)
}
fn make_row(doc: &mut Document, table: NodeId, index: Option<usize>, width: f64, texts: &[&str]) -> Result<NodeId> {
let row = frame(doc, table, index, "Row", (width, CELL_H), |n| {
n.layout = Some(AutoLayout { mode: LayoutMode::Horizontal, gap: 0.0, padding: [0.0; 4], ..AutoLayout::default() });
n.child_layout.sizing_h = Sizing::Fill;
n.child_layout.sizing_v = Sizing::Hug;
n.stroke.weight = LINE_W;
n.stroke.align = StrokeAlign::Inside;
n.stroke.sides = Some([0.0, 0.0, LINE_W, 0.0]);
})?;
let cw = width / texts.len().max(1) as f64;
for t in texts {
make_cell(doc, row, None, cw, t)?;
}
Ok(row)
}
/// A table of `texts` (rows of cell texts, all as long) at `at` on `parent`.
pub(crate) fn make_table(doc: &mut Document, parent: NodeId, at: (f64, f64), texts: &[Vec<&str>]) -> Result<NodeId> {
let columns = texts.first().map_or(1, Vec::len).max(1);
let w = CELL_W * columns as f64;
let table = frame(doc, parent, None, "Table", (w, CELL_H * texts.len() as f64), |n| {
n.transform = Affine::translate(at);
n.fills = vec![Paint::solid(Color::WHITE)];
n.strokes = vec![Paint::solid(GRID)];
n.stroke.weight = LINE_W;
n.stroke.align = StrokeAlign::Inside;
n.corners.radii = [4.0; 4];
n.clip = true;
// The outline draws over the rows' edges rather than taking room from them.
n.layout = Some(AutoLayout { mode: LayoutMode::Vertical, gap: 0.0, padding: [0.0; 4], strokes_included: false, ..AutoLayout::default() });
n.child_layout.sizing_v = Sizing::Hug;
n.board = Some(BoardItem::Table);
})?;
for row in texts {
make_row(doc, table, None, w, row)?;
}
restyle(doc, table)?;
Ok(table)
}
/// Table `table`'s rows (its laid-out children) and column lines (the others).
fn parts(doc: &Document, table: NodeId) -> (Vec<NodeId>, Vec<NodeId>) {
let kids = doc.node(table).map(|n| n.children.clone()).unwrap_or_default();
kids.into_iter().partition(|c| doc.node(*c).is_some_and(|n| !n.child_layout.absolute))
}
fn cells(doc: &Document, row: NodeId) -> Vec<NodeId> {
doc.node(row).map(|n| n.children.clone()).unwrap_or_default()
}
/// The first row tinted, a line under every row but the last, and the column lines over it all.
fn restyle(doc: &mut Document, table: NodeId) -> Result<()> {
let (rows, lines) = parts(doc, table);
for l in lines {
doc.remove(l)?;
}
let last = rows.len().saturating_sub(1);
for (i, r) in rows.iter().enumerate() {
let n = doc.get_mut(*r)?;
n.fills = if i == 0 { vec![Paint::solid(HEADER)] } else { Vec::new() };
n.strokes = if i < last { vec![Paint::solid(GRID)] } else { Vec::new() };
}
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
let (w, h) = doc.get(table).map(|t| (t.width, t.height))?;
for i in 1..columns {
let x = w * i as f64 / columns as f64 - LINE_W / 2.0;
let id = doc.alloc_id();
let mut n = Node::new(id, NodeKind::Rectangle, "Column line", LINE_W, h);
n.fills = vec![Paint::solid(GRID)];
n.transform = Affine::translate((x, 0.0));
n.child_layout.absolute = true;
n.constraints = Constraints { horizontal: HConstraint::Scale, vertical: VConstraint::TopBottom };
n.locked = true;
doc.insert(n, table, None)?;
}
Ok(())
}
/// Each cell's text layer, row by row (null for a cell without one).
fn cell_texts(doc: &Document, table: NodeId) -> Value {
let text = |cell: NodeId| {
doc.node(cell).and_then(|n| n.children.iter().copied().find(|c| doc.node(*c).is_some_and(|k| matches!(k.kind, NodeKind::Text(_)))))
};
let (rows, _) = parts(doc, table);
Value::Array(rows.iter().map(|r| Value::Array(cells(doc, *r).into_iter().map(|c| json!(text(c))).collect())).collect())
}
fn answer(doc: &Document, table: NodeId) -> Value {
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len());
json!({"id": table, "rows": rows.len(), "columns": columns, "cells": cell_texts(doc, table)})
}
fn table_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.table";
require(s, cmd)?;
let given: Vec<Vec<String>> = match get(p, "cells") {
None | Some(Value::Null) => Vec::new(),
Some(Value::Array(rows)) => rows
.iter()
.map(|r| match r {
Value::Array(c) => c.iter().map(|t| t.as_str().map(str::to_string).ok_or_else(|| bad(cmd, "`cells` holds text"))).collect(),
_ => Err(bad(cmd, "`cells` is a list of rows, each a list of texts")),
})
.collect::<Result<_>>()?,
Some(_) => return Err(bad(cmd, "`cells` is a list of rows, each a list of texts")),
};
let widest = given.iter().map(Vec::len).max().unwrap_or(0);
let rows = count(cmd, p, "rows", if given.is_empty() { 3 } else { given.len() }, MAX_ROWS)?;
let columns = count(cmd, p, "columns", if widest == 0 { 3 } else { widest }, MAX_COLUMNS)?;
if given.len() > rows || widest > columns {
return Err(bad(cmd, format!("`cells` doesn't fit in {rows} × {columns}")));
}
let texts: Vec<Vec<&str>> =
(0..rows).map(|r| (0..columns).map(|c| given.get(r).and_then(|row| row.get(c)).map_or("", String::as_str)).collect()).collect();
let (w, h) = (CELL_W * columns as f64, CELL_H * rows as f64);
let view = &s.ds()?.view;
let at = (num_or(p, "x", view.cx - w / 2.0), num_or(p, "y", view.cy - h / 2.0));
if !(at.0.is_finite() && at.1.is_finite()) {
return Err(bad(cmd, "`x` and `y` are finite numbers"));
}
let page = s.page()?;
let doc = s.doc_mut()?;
let table = make_table(doc, page, at, &texts)?;
let out = answer(doc, table);
s.set_selection(vec![table]);
Ok(out)
}
/// The table `id` is or is in.
fn table_of(doc: &Document, id: NodeId) -> Option<NodeId> {
std::iter::once(id).chain(doc.ancestors(id)).find(|a| doc.node(*a).is_some_and(|n| n.board == Some(BoardItem::Table)))
}
fn edit_cmd(s: &mut Session, p: &Value) -> Result<Value> {
let cmd = "board.tableEdit";
require(s, cmd)?;
let target = id(p, "id").or_else(|| s.selection().first().copied()).ok_or_else(|| EngineError::Disabled(cmd.into(), "select a table".into()))?;
let doc = s.doc()?;
doc.get(target)?;
let table = table_of(doc, target).ok_or_else(|| bad(cmd, "that isn't a table"))?;
let action = req_string(cmd, p, "action")?;
let index = opt_index(cmd, p, "index")?;
let (rows, _) = parts(doc, table);
let columns = rows.first().map_or(0, |r| cells(doc, *r).len()).max(1);
let width = doc.get(table)?.width;
let doc = s.doc_mut()?;
// `n` things: `index` (default `last`) must be at most `end`.
let at = |end: usize, last: usize| match index {
Some(i) if i > end => Err(bad(cmd, format!("`index` is from 0 to {end}"))),
Some(i) => Ok(i),
None => Ok(last),
};
match action.as_str() {
"addRow" => {
if rows.len() >= MAX_ROWS {
return Err(bad(cmd, format!("a table has at most {MAX_ROWS} rows")));
}
let i = at(rows.len(), rows.len())?;
// Rows come before the column lines among the table's layers.
let child = rows.get(i).or(rows.last()).and_then(|r| doc.node(table).and_then(|t| t.children.iter().position(|c| c == r)));
let child = child.map(|c| if i >= rows.len() { c + 1 } else { c });
make_row(doc, table, child, width, &vec![""; columns])?;
}
"removeRow" => {
if rows.len() <= 1 {
return Err(bad(cmd, "a table keeps at least one row"));
}
let i = at(rows.len() - 1, rows.len() - 1)?;
if let Some(r) = rows.get(i) {
doc.remove(*r)?;
}
}
"addColumn" => {
if columns >= MAX_COLUMNS {
return Err(bad(cmd, format!("a table has at most {MAX_COLUMNS} columns")));
}
let i = at(columns, columns)?;
let cw = width / columns as f64;
doc.get_mut(table)?.width = width + cw;
for r in &rows {
make_cell(doc, *r, Some(i), cw, "")?;
}
}
"removeColumn" => {
if columns <= 1 {
return Err(bad(cmd, "a table keeps at least one column"));
}
let i = at(columns - 1, columns - 1)?;
doc.get_mut(table)?.width = width - width / columns as f64;
for r in &rows {
if let Some(c) = cells(doc, *r).get(i) {
doc.remove(*c)?;
}
}
}
other => return Err(bad(cmd, format!("`action` is addRow, addColumn, removeRow or removeColumn, not `{other}`"))),
}
restyle(doc, table)?;
let out = answer(doc, table);
s.set_selection(vec![table]);
Ok(out)
}
/// A double-click on a table: type in the cell under the pointer (`leaf`). False when it hit no
/// cell (a column line, the table's edge).
pub(crate) fn edit_cell(s: &mut Session, doc: &Document, leaf: NodeId) -> Result<bool> {
let in_table = |c: &NodeId| {
let row = doc.node(*c).and_then(|n| n.parent);
row.and_then(|r| doc.node(r)).and_then(|r| r.parent).and_then(|t| doc.node(t)).is_some_and(|t| t.board == Some(BoardItem::Table))
};
let Some(cell) = std::iter::once(leaf).chain(doc.ancestors(leaf)).find(in_table) else { return Ok(false) };
let Some(text) = cells(doc, cell).into_iter().find(|c| doc.node(*c).is_some_and(|n| matches!(n.kind, NodeKind::Text(_)))) else {
return Ok(false);
};
textedit::begin(s, text, None, false)?;
textedit::key(s, "SelectAll", false, false, false)?;
Ok(true)
}