diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index b4a0e31f..e401a394 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -55,6 +55,33 @@ pub fn specs() -> Vec { 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 { }) } -fn cell_fill(s: &mut Session, p: &Value) -> Result { - 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 Vec<(usize, usize)> + use<>> { let cells: Option> = 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 { + 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 { }) } +const LEFT: usize = 0; +const RIGHT: usize = 1; +const TOP: usize = 2; +const BOTTOM: usize = 3; + +fn borders(s: &mut Session, p: &Value) -> Result { + 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 { + // 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 { + 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 { with_table(s, p, "table.distribute", move |t| { if rows { diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 26dc7ee1..691c39a1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bf6e4e9e..d8f75cb1 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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 { diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 0860ebef..0a6b70ee 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -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(); + } + } + }); }); }); }); diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 6ff7b230..4470df1a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -24,7 +24,7 @@ The summary between ` | 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 |