Merge PR #540 from NikitkaRa: Table: borders, cell margins and cell text alignment

# Conflicts:
#	crates/engine/src/cmd/table.rs
#	crates/engine/src/tests.rs
This commit is contained in:
Brandon Thomas
2026-10-14 13:36:34 -04:00
5 changed files with 304 additions and 20 deletions
+177 -14
View File
@@ -55,6 +55,33 @@ pub fn specs() -> Vec<CommandSpec> {
has_selection,
cell_fill
),
cmd!(
"table.borders",
"Borders",
["Table Design", "Draw Borders"],
None,
"{which: all|outside|inside|insideH|insideV|top|bottom|left|right|diagDown|diagUp|none, color?, width?: pt, dash?: solid|dash|dot…, cells?: [[r,c],…] (default: selected cells or all), id?}",
has_selection,
borders
),
cmd!(
"table.cellMargins",
"Cell Margins",
["Table Layout", "Alignment"],
None,
"{preset?: normal|none|narrow|wide, left?, right?, top?, bottom?: pt, cells?, id?}",
has_selection,
cell_margins
),
cmd!(
"table.cellAlign",
"Align Cell Text",
["Table Layout", "Alignment"],
None,
"{anchor: top|middle|bottom, cells?, id?}",
has_selection,
cell_align
),
cmd!("table.distributeRows", "Distribute Rows", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(s, p, true)),
cmd!("table.distributeColumns", "Distribute Columns", ["Table Layout", "Cell Size"], None, "{id?}", has_selection, |s, p| distribute(
s, p, false
@@ -195,24 +222,31 @@ fn options(s: &mut Session, p: &Value) -> Result<Value> {
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
/// The cells a command acts on: `cells`, else the selected cell range, else the cell holding the
/// caret, else every cell.
fn target_cells(s: &Session, p: &Value) -> Result<impl Fn(&Table) -> Vec<(usize, usize)> + use<>> {
let cells: Option<Vec<(usize, usize)>> = p
.get("cells")
.and_then(Value::as_array)
.map(|a| a.iter().filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
let sel = s.doc()?.selection.cells;
.map(|a| a.iter().take(100_000).filter_map(|c| Some((c.get(0)?.as_u64()? as usize, c.get(1)?.as_u64()? as usize))).collect());
let st = s.doc()?;
let sel = st.selection.cells.map(|(_, a, b)| (a, b)).or_else(|| st.selection.text.as_ref().and_then(|t| t.cell).map(|c| (c, c)));
Ok(move |t: &Table| match (&cells, sel) {
(Some(c), _) => c.clone(),
(None, Some((a, b))) => {
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
}
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
})
}
fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
let fill = if bool_or(p, "none", false) { Some(deckcraft_model::Fill::None) } else { color_param(p, "color").map(deckcraft_model::Fill::solid) };
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellFill", move |t| {
let list: Vec<(usize, usize)> = match (&cells, sel) {
(Some(c), _) => c.clone(),
(None, Some((_, a, b))) => {
// The selection can name cells outside the table; only walk the ones that exist.
let (r1, c1) = (a.0.max(b.0).min(t.n_rows().saturating_sub(1)), a.1.max(b.1).min(t.n_cols().saturating_sub(1)));
(a.0.min(b.0)..=r1).flat_map(|r| (a.1.min(b.1)..=c1).map(move |c| (r, c))).collect()
}
_ => (0..t.n_rows()).flat_map(|r| (0..t.n_cols()).map(move |c| (r, c))).collect(),
};
for (r, c) in list {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.fill.clone_from(&fill);
}
@@ -221,6 +255,135 @@ fn cell_fill(s: &mut Session, p: &Value) -> Result<Value> {
})
}
const LEFT: usize = 0;
const RIGHT: usize = 1;
const TOP: usize = 2;
const BOTTOM: usize = 3;
fn borders(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::Line;
let which = str_param(p, "which").unwrap_or("all").to_string();
const WHICH: [&str; 12] = ["all", "outside", "inside", "insideH", "insideV", "top", "bottom", "left", "right", "diagDown", "diagUp", "none"];
if !WHICH.contains(&which.as_str()) {
return Err(bad("table.borders", format!("unknown `which` {which:?}; one of {}", WHICH.join(", "))));
}
let color = color_param(p, "color").unwrap_or_else(|| deckcraft_model::ColorRef::rgb(deckcraft_color::Rgba::rgb(0, 0, 0)));
let width = f64_param(p, "width").filter(|w| w.is_finite()).unwrap_or(1.0).clamp(0.0, 100.0);
let mut line = Line::solid(color, width);
if let Some(d) = str_param(p, "dash").filter(|d| *d != "solid") {
if !deckcraft_model::style::Dash::PRESETS.contains(&d) {
return Err(bad("table.borders", format!("unknown dash {d:?}; one of {}", deckcraft_model::style::Dash::PRESETS.join(", "))));
}
line.dash = Some(deckcraft_model::style::Dash::Preset { name: d.to_string() });
}
// "none" clears to no line, so the table style's own lines don't show through either.
let line = if which == "none" { Line::none() } else { line };
let targets = target_cells(s, p)?;
with_table(s, p, "table.borders", move |t| {
let list = targets(t);
let (Some(r0), Some(r1), Some(c0), Some(c1)) =
(list.iter().map(|x| x.0).min(), list.iter().map(|x| x.0).max(), list.iter().map(|x| x.1).min(), list.iter().map(|x| x.1).max())
else {
return Ok(());
};
let (rows, cols) = (t.n_rows(), t.n_cols());
// Set one side of a cell and the facing side of its neighbour, as PowerPoint does.
let set = |t: &mut Table, r: usize, c: usize, side: usize| {
if let Some(cell) = t.cell_mut(r, c) {
cell.borders[side] = Some(line.clone());
}
let n = match side {
LEFT => c.checked_sub(1).map(|c| (r, c, RIGHT)),
RIGHT => (c + 1 < cols).then_some((r, c + 1, LEFT)),
TOP => r.checked_sub(1).map(|r| (r, c, BOTTOM)),
_ => (r + 1 < rows).then_some((r + 1, c, TOP)),
};
if let Some((r, c, side)) = n
&& let Some(cell) = t.cell_mut(r, c)
{
cell.borders[side] = Some(line.clone());
}
};
for (r, c) in list {
if r < r0 || r > r1 || c < c0 || c > c1 {
continue;
}
let outer = [c == c0, c == c1, r == r0, r == r1];
for side in [LEFT, RIGHT, TOP, BOTTOM] {
let horizontal = side == TOP || side == BOTTOM;
let on = match which.as_str() {
"all" | "none" => true,
"outside" => outer[side],
"inside" => !outer[side],
"insideH" => horizontal && !outer[side],
"insideV" => !horizontal && !outer[side],
"top" => side == TOP && outer[side],
"bottom" => side == BOTTOM && outer[side],
"left" => side == LEFT && outer[side],
"right" => side == RIGHT && outer[side],
_ => false,
};
if on {
set(t, r, c, side);
}
}
if let Some(cell) = t.cell_mut(r, c) {
match which.as_str() {
"diagDown" => cell.diag_down = Some(line.clone()),
"diagUp" => cell.diag_up = Some(line.clone()),
"none" => {
cell.diag_down = None;
cell.diag_up = None;
}
_ => {}
}
}
}
Ok(())
})
}
fn cell_margins(s: &mut Session, p: &Value) -> Result<Value> {
// PowerPoint's presets (left/right, top/bottom).
let base = match str_param(p, "preset") {
Some("none") => [0.0; 4],
Some("narrow") => [3.6; 4],
Some("wide") => [14.4, 14.4, 14.4, 14.4],
Some("normal") | None => [7.2, 7.2, 3.6, 3.6],
Some(o) => return Err(bad("table.cellMargins", format!("unknown preset {o:?}; one of normal, none, narrow, wide"))),
};
let side = |k: &str, i: usize| f64_param(p, k).filter(|v| v.is_finite()).map_or(base[i], |v| v.clamp(0.0, 400.0));
let m = [side("left", 0), side("right", 1), side("top", 2), side("bottom", 3)];
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellMargins", move |t| {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.margins = Some(m);
}
}
Ok(())
})
}
fn cell_align(s: &mut Session, p: &Value) -> Result<Value> {
use deckcraft_model::text::Anchor;
let a = match str_param(p, "anchor").unwrap_or("top") {
"middle" | "center" | "ctr" => Anchor::Middle,
"bottom" | "b" => Anchor::Bottom,
"top" | "t" => Anchor::Top,
o => return Err(bad("table.cellAlign", format!("unknown anchor {o:?}; one of top, middle, bottom"))),
};
let targets = target_cells(s, p)?;
with_table(s, p, "table.cellAlign", move |t| {
for (r, c) in targets(t) {
if let Some(cell) = t.cell_mut(r, c) {
cell.anchor = Some(a);
}
}
Ok(())
})
}
fn distribute(s: &mut Session, p: &Value, rows: bool) -> Result<Value> {
with_table(s, p, "table.distribute", move |t| {
if rows {
+63
View File
@@ -1850,3 +1850,66 @@ fn select_all_slides_bad_scope_is_an_error() {
assert!(s.execute("edit.selectAll", &json!({"scope": "everything"})).is_err());
assert!(s.execute("edit.selectAll", &json!({"scope": 3})).is_err());
}
#[test]
fn table_borders_margins_and_cell_alignment() {
let mut s = session();
let id = s.execute("insert.table", &json!({"rows": 3, "cols": 3, "rect": [100.0, 100.0, 300.0, 120.0]})).unwrap()["id"].clone();
let table = |s: &Session| {
let st = s.doc().unwrap();
let sh = st.shapes().iter().find(|sh| json!(sh.id) == id).unwrap().clone();
let deckcraft_model::ShapeKind::Table(t) = sh.kind else { panic!("not a table") };
t
};
// Outside borders round the top-left 2×2 block; neighbours get the facing side too.
let block = json!([[0, 0], [0, 1], [1, 0], [1, 1]]);
s.execute("table.borders", &json!({"id": id, "which": "outside", "cells": block, "color": "#FF0000", "width": 2.0, "dash": "dash"})).unwrap();
let t = table(&s);
let w = |r: usize, c: usize, side: usize| t.rows[r].cells[c].borders[side].as_ref().and_then(|l| l.width);
assert_eq!(w(0, 0, 0), Some(2.0));
assert_eq!(w(0, 0, 2), Some(2.0));
assert_eq!(w(0, 0, 1), None, "inside edge untouched");
assert_eq!(w(1, 1, 1), Some(2.0));
assert_eq!(w(1, 2, 0), Some(2.0), "neighbour's facing side");
assert_eq!(w(2, 1, 2), Some(2.0), "neighbour below");
assert!(matches!(&t.rows[0].cells[0].borders[0].as_ref().unwrap().dash, Some(deckcraft_model::style::Dash::Preset { name }) if name == "dash"));
// Inside horizontal only for the block.
s.execute("table.borders", &json!({"id": id, "which": "insideH", "cells": block})).unwrap();
let t = table(&s);
assert!(t.rows[0].cells[0].borders[3].is_some() && t.rows[1].cells[0].borders[2].is_some());
assert!(t.rows[0].cells[0].borders[1].is_none());
// Diagonal and none.
s.execute("table.borders", &json!({"id": id, "which": "diagDown", "cells": [[2, 2]]})).unwrap();
assert!(table(&s).rows[2].cells[2].diag_down.is_some());
s.execute("table.borders", &json!({"id": id, "which": "none"})).unwrap();
let t = table(&s);
assert!(
t.rows
.iter()
.flat_map(|r| &r.cells)
.all(|c| c.diag_down.is_none() && c.borders.iter().all(|b| b.as_ref().is_some_and(|l| l.fill == Some(deckcraft_model::Fill::None))))
);
assert!(s.execute("table.borders", &json!({"id": id, "which": "sideways"})).is_err());
// Margins and vertical alignment.
s.execute("table.cellMargins", &json!({"id": id, "preset": "wide", "cells": [[0, 0]]})).unwrap();
s.execute("table.cellMargins", &json!({"id": id, "preset": "none", "left": 5.0, "cells": [[0, 1]]})).unwrap();
s.execute("table.cellAlign", &json!({"id": id, "anchor": "middle"})).unwrap();
let t = table(&s);
assert_eq!(t.rows[0].cells[0].margins, Some([14.4; 4]));
assert_eq!(t.rows[0].cells[1].margins, Some([5.0, 0.0, 0.0, 0.0]));
assert!(t.rows.iter().flat_map(|r| &r.cells).all(|c| c.anchor == Some(deckcraft_model::text::Anchor::Middle)));
// Survives PPTX.
let p = s.doc().unwrap().doc.as_ref().clone();
let back = deckcraft_pptx::import(&deckcraft_pptx::export(&p).unwrap()).unwrap();
let bt = back
.slides
.iter()
.flat_map(|sl| &sl.shapes)
.find_map(|sh| if let deckcraft_model::ShapeKind::Table(t) = &sh.kind { Some(t.clone()) } else { None })
.unwrap();
assert_eq!(bt.rows[0].cells[0].margins, Some([14.4; 4]));
assert_eq!(bt.rows[1].cells[1].anchor, Some(deckcraft_model::text::Anchor::Middle));
// One undo step each.
s.execute("edit.undo", &json!({})).unwrap();
assert!(table(&s).rows[0].cells[0].anchor.is_none());
}
+5 -2
View File
@@ -203,6 +203,8 @@ pub struct Perf {
}
pub struct SlideApp {
/// Table Design's border pen (`color`, `width`, `dash` for `table.borders`).
pub table_pen: serde_json::Value,
pub session: Session,
pub services: Services,
pub ui: UiState,
@@ -268,6 +270,7 @@ impl SlideApp {
pub fn new(session: Session, mut services: Services) -> Self {
let audio_out = services.audio_out.take();
SlideApp {
table_pen: serde_json::json!({"width": 1.0}),
session,
services,
ui: UiState::default(),
@@ -1221,11 +1224,11 @@ mod huge_deck_tests;
#[cfg(test)]
mod ime_tests;
#[cfg(test)]
mod replacefonts_tests;
#[cfg(test)]
mod slide_size_tests;
#[cfg(test)]
mod ui_soak_tests;
#[cfg(test)]
mod replacefonts_tests;
#[cfg(test)]
mod tests {
+57 -2
View File
@@ -2584,6 +2584,53 @@ fn table_design(app: &mut SlideApp, ui: &mut Ui) {
}
});
});
group(ui, |ui| {
let r = big_button(ui, Icon::Borders, "Borders", true);
egui::Popup::menu(&r).show(|ui| {
let pen = app.table_pen.clone();
for (label, which) in [
("No Border", "none"),
("All Borders", "all"),
("Outside Borders", "outside"),
("Inside Borders", "inside"),
("Top Border", "top"),
("Bottom Border", "bottom"),
("Left Border", "left"),
("Right Border", "right"),
("Inside Horizontal Border", "insideH"),
("Inside Vertical Border", "insideV"),
("Diagonal Down Border", "diagDown"),
("Diagonal Up Border", "diagUp"),
] {
let mut p = pen.clone();
p["which"] = json!(which);
menu_item(app, ui, Icon::Borders, label, "table.borders", p);
}
ui.separator();
ui.menu_button("Pen Weight", |ui| {
for w in [0.25, 0.5, 0.75, 1.0, 1.5, 2.25, 3.0, 4.5, 6.0] {
let cur = app.table_pen.get("width").and_then(Value::as_f64) == Some(w);
if ui.radio(cur, format!("{w} pt")).clicked() {
app.table_pen["width"] = json!(w);
}
}
});
ui.menu_button("Pen Style", |ui| {
for (l, d) in [("Solid", "solid"), ("Dash", "dash"), ("Dot", "sysDot"), ("Dash Dot", "dashDot"), ("Long Dash", "lgDash")] {
let cur = app.table_pen.get("dash").and_then(Value::as_str).unwrap_or("solid") == d;
if ui.radio(cur, l).clicked() {
app.table_pen["dash"] = json!(d);
}
}
});
ui.menu_button("Pen Color", |ui| {
let sc = scheme(app);
if let Some(Some(c)) = widgets::color_grid(ui, &sc, None) {
app.table_pen["color"] = cref_param(&c);
}
});
});
});
}
/// A Picture Styles gallery tile: a landscape "picture" in the style's shape and frame.
@@ -2721,10 +2768,18 @@ fn table_layout(app: &mut SlideApp, ui: &mut Ui) {
});
ui.horizontal(|ui| {
for (l, a) in [("Top", "top"), ("Center", "middle"), ("Bottom", "bottom")] {
if ui.small_button(l).clicked() {
run(app, "format.anchor", json!({"anchor": a}));
if ui.small_button(l).on_hover_text(format!("Align text to the {l} of the cell").to_lowercase()).clicked() {
run(app, "table.cellAlign", json!({"anchor": a}));
}
}
ui.menu_button("Cell Margins", |ui| {
for (l, preset) in [("Normal", "normal"), ("None", "none"), ("Narrow", "narrow"), ("Wide", "wide")] {
if ui.button(l).clicked() {
run(app, "table.cellMargins", json!({"preset": preset}));
ui.close();
}
}
});
});
});
});
+2 -2
View File
@@ -24,7 +24,7 @@ The summary between `<!-- SUMMARY -->
| Format | 83% |
| Arrange | 100% |
| Text | 80% |
| Tables | 89% |
| Tables | 95% |
| Charts | 65% |
| SmartArt | 33% |
| Pictures | 52% |
@@ -171,7 +171,7 @@ Open P0 items: Contextual tabs (Shape Format, Picture Format, Table Design/Layou
| Tables | Insert/delete rows and columns | P0 | D |
| Tables | Merge and split cells | P1 | D |
| Tables | Distribute rows/columns, sizes | P1 | D |
| Tables | Cell fill, borders, margins, alignment | P1 | P |
| Tables | Cell fill, borders, margins, alignment | P1 | D |
| Tables | Keyboard navigation between cells | P0 | D |
| Charts | Insert chart (column, bar, line, area, pie, doughnut, scatter) | P0 | D |
| Charts | Other chart types (radar, stock, combo, waterfall, funnel, treemap, sunburst, histogram, box) | P2 | P |