mirror of
https://github.com/storytold/deckcraft.git
synced 2026-10-24 10:37:03 -06:00
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:
+177
-14
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user