From d8f71959d37d8446692bc84a01d39833aa369d5d Mon Sep 17 00:00:00 2001 From: Valentin Klein Date: Sat, 10 Oct 2026 19:45:11 +0200 Subject: [PATCH] Create and modify your own table styles, saved in the .docx (#146) (#256) New Table Style and Modify Table Style (Table Design > Table Styles) make real table styles in the document's style sheet: name, based on, and formatting for the whole table, the header row and banded rows (borders, fill, bold, font colour). The table at the caret takes the new style, it shows in the gallery, and other tools that read table styles by name (pandoc's custom-style, for one) see it. - engine: table.newStyle / table.modifyStyle with documented params (regions take borders, fill, bold, italic, colour, size, font; bandSize; para); table.style only accepts table styles now. - doc/layout: table styles gain header-row and row-band cell borders, row-band character formatting and rows per band, applied by layout. - docx: tblStyleRowBandSize and firstRow/band1Horz tcBorders and rPr are read and written, so custom styles round-trip. - ui: a compact dialog from the Styles menu next to the gallery; table style tiles redraw when a style changes. Co-authored-by: Claude Opus 5.5 Co-authored-by: Brandon Thomas --- ROADMAP.md | 2 +- crates/doc/src/styles.rs | 28 ++-- crates/docx/src/read/mod.rs | 11 +- crates/docx/src/write/mod.rs | 60 ++++---- crates/docx/tests/roundtrip.rs | 8 +- crates/engine/src/catalog.rs | 2 + crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/cmd/table.rs | 6 +- crates/engine/src/cmd/table_style.rs | 183 +++++++++++++++++++++++ crates/engine/src/tests.rs | 63 ++++++++ crates/layout/src/table.rs | 36 +++-- crates/ui-egui/src/dialogs.rs | 212 ++++++++++++++++++++++++++- crates/ui-egui/src/i18n/es.tsv | 7 + crates/ui-egui/src/i18n/ja.tsv | 7 + crates/ui-egui/src/i18n/pt-br.tsv | 7 + crates/ui-egui/src/i18n/uk.tsv | 7 + crates/ui-egui/src/i18n/zh-hans.tsv | 7 + crates/ui-egui/src/i18n/zh-hant.tsv | 7 + crates/ui-egui/src/previews.rs | 4 +- crates/ui-egui/src/ribbon.rs | 16 ++ docs/parity.md | 4 +- 21 files changed, 620 insertions(+), 59 deletions(-) create mode 100644 crates/engine/src/cmd/table_style.rs diff --git a/ROADMAP.md b/ROADMAP.md index 2dd3181..ecc10ce 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -9,7 +9,7 @@ and agent control. This file tracks where we are honestly. Generated numbers com | Measure | Value | |---|---| | Commands (every action, scriptable by CLI/MCP/control channel) | **422** | -| Feature catalog coverage (Word ribbon/menu features with a command) | **378 / 428 (88%)** | +| Feature catalog coverage (Word ribbon/menu features with a command) | **380 / 430 (88%)** | | **Estimated real feature parity** (depth and fidelity, not just a command) | **~62%** | | **Distance to alpha** | **~85% of the way; ≈20–25 h of Claude wall-clock work** | | **Distance to 100% parity** | **≈120–150 h of Claude Opus 5.5 wall-clock work** (with parallel agents) | diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 7a0e201..0844c93 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -52,7 +52,16 @@ pub struct TableStyleParts { pub cell_margins: Option<[f32; 4]>, pub header_fill: Option, pub header_chr: CharProps, + /// Cell borders of the header row (`firstRow` region's `w:tcBorders`): `between` is the + /// border between header rows, `inside_v` between its cells. + pub header_borders: Option, pub band_fill: Option, + /// Character formatting of the odd row bands (`band1Horz` region). + pub band_chr: CharProps, + /// Cell borders of the odd row bands (`band1Horz` region's `w:tcBorders`). + pub band_borders: Option, + /// Rows per band (`w:tblStyleRowBandSize`); one when unset. + pub band_size: Option, pub first_col_chr: CharProps, pub total_chr: CharProps, pub total_border_top: Option, @@ -62,17 +71,18 @@ impl TableStyleParts { /// Apply `patch` (a style further down a based-on chain) over these parts: what it sets wins, /// the rest is inherited; borders merge edge by edge. pub fn overlay(&mut self, patch: &TableStyleParts) { - self.borders = match (self.borders, patch.borders) { - (Some(b), Some(p)) => Some(Borders { - top: p.top.or(b.top), - left: p.left.or(b.left), - bottom: p.bottom.or(b.bottom), - right: p.right.or(b.right), - between: p.between.or(b.between), - inside_v: p.inside_v.or(b.inside_v), - }), + let merge = |b: Option, p: Option| match (b, p) { + (Some(mut b), Some(p)) => { + b.overlay(&p); + Some(b) + } (b, p) => p.or(b), }; + self.borders = merge(self.borders, patch.borders); + self.header_borders = merge(self.header_borders, patch.header_borders); + self.band_borders = merge(self.band_borders, patch.band_borders); + self.band_size = patch.band_size.or(self.band_size); + self.band_chr.overlay(&patch.band_chr); self.fill = patch.fill.or(self.fill); self.cell_margins = patch.cell_margins.or(self.cell_margins); self.header_fill = patch.header_fill.or(self.header_fill); diff --git a/crates/docx/src/read/mod.rs b/crates/docx/src/read/mod.rs index d3e0b44..bf8e29b 100644 --- a/crates/docx/src/read/mod.rs +++ b/crates/docx/src/read/mod.rs @@ -444,9 +444,11 @@ impl Reader<'_> { } t.fill = tp.child("w:shd").and_then(props::shd_fill); t.cell_margins = tp.child("w:tblCellMar").map(props::margins); + t.band_size = tp.child_val("w:tblStyleRowBandSize").and_then(u32_of).filter(|n| *n > 0).map(|n| n.min(1000)); } - for c in s.children("w:tblStylePr") { + for c in s.children("w:tblStylePr").take(64) { let fill = c.child("w:tcPr").and_then(|p| p.child("w:shd")).and_then(props::shd_fill); + let cell_borders = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).map(props::borders); let chr = c.child("w:rPr").map(|r| self.pc.rpr(r)).unwrap_or_default(); match c.attr("w:type") { Some("wholeTable") => { @@ -464,8 +466,13 @@ impl Reader<'_> { Some("firstRow") => { t.header_fill = fill; t.header_chr = chr; + t.header_borders = cell_borders; + } + Some("band1Horz") => { + t.band_fill = fill; + t.band_chr = chr; + t.band_borders = cell_borders; } - Some("band1Horz") => t.band_fill = fill, Some("firstCol") => t.first_col_chr = chr, Some("lastRow") => { t.total_chr = chr; diff --git a/crates/docx/src/write/mod.rs b/crates/docx/src/write/mod.rs index 02f4f1e..e6a49c6 100644 --- a/crates/docx/src/write/mod.rs +++ b/crates/docx/src/write/mod.rs @@ -622,8 +622,11 @@ fn style_xml(w: &mut W, st: &Style) { w.close("w:rPr"); } if let Some(t) = &st.table { - if t.borders.is_some() || t.cell_margins.is_some() { + if t.borders.is_some() || t.cell_margins.is_some() || t.band_size.is_some() { w.open("w:tblPr", &[]); + if let Some(n) = t.band_size { + w.val("w:tblStyleRowBandSize", &n.clamp(1, 1000).to_string()); + } if let Some(b) = &t.borders { props::borders(w, "w:tblBorders", b, Some("w:insideH"), &[]); } @@ -632,35 +635,40 @@ fn style_xml(w: &mut W, st: &Style) { } w.close("w:tblPr"); } - let cond = - |w: &mut W, ty: &str, chr: &wordcraft_doc::CharProps, fill: Option, top: Option<&wordcraft_doc::props::Border>| { - if !props::has_rpr(chr) && fill.is_none() && top.is_none() { - return; + // One conditional formatting region (ECMA-376 §17.7.6): run properties, then cell borders + // and shading. + let cond = |w: &mut W, + ty: &str, + chr: &wordcraft_doc::CharProps, + fill: Option, + borders: Option| { + if !props::has_rpr(chr) && fill.is_none() && borders.is_none() { + return; + } + w.open("w:tblStylePr", &[("w:type", ty)]); + if props::has_rpr(chr) { + w.open("w:rPr", &[]); + props::rpr_inner(w, chr); + w.close("w:rPr"); + } + if fill.is_some() || borders.is_some() { + w.open("w:tcPr", &[]); + if let Some(bs) = &borders { + props::borders(w, "w:tcBorders", bs, Some("w:insideH"), &[]); } - w.open("w:tblStylePr", &[("w:type", ty)]); - if props::has_rpr(chr) { - w.open("w:rPr", &[]); - props::rpr_inner(w, chr); - w.close("w:rPr"); + if let Some(f) = fill { + w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); } - if fill.is_some() || top.is_some() { - w.open("w:tcPr", &[]); - if let Some(b) = top { - let bs = wordcraft_doc::props::Borders { top: Some(*b), ..Default::default() }; - props::borders(w, "w:tcBorders", &bs, None, &[]); - } - if let Some(f) = fill { - w.empty("w:shd", &[("w:val", "clear"), ("w:color", "auto"), ("w:fill", &f.hex())]); - } - w.close("w:tcPr"); - } - w.close("w:tblStylePr"); - }; + w.close("w:tcPr"); + } + w.close("w:tblStylePr"); + }; cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None); - cond(w, "firstRow", &t.header_chr, t.header_fill, None); - cond(w, "lastRow", &t.total_chr, None, t.total_border_top.as_ref()); + cond(w, "firstRow", &t.header_chr, t.header_fill, t.header_borders); + let total_top = t.total_border_top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }); + cond(w, "lastRow", &t.total_chr, None, total_top); cond(w, "firstCol", &t.first_col_chr, None, None); - cond(w, "band1Horz", &wordcraft_doc::CharProps::default(), t.band_fill, None); + cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders); } w.close("w:style"); } diff --git a/crates/docx/tests/roundtrip.rs b/crates/docx/tests/roundtrip.rs index 44e7715..e65f21b 100644 --- a/crates/docx/tests/roundtrip.rs +++ b/crates/docx/tests/roundtrip.rs @@ -429,7 +429,8 @@ fn comments_round_trip() { } } -/// A table style's cell text formatting, whole-table shading and cell margins survive a save. +/// A table style's cell text formatting, whole-table shading, cell margins and its header row and +/// row band conditional formats (#146) survive a save. #[test] fn table_style_formatting_round_trips() { use wordcraft_doc::styles::TableStyleParts; @@ -440,6 +441,11 @@ fn table_style_formatting_round_trips() { cell_margins: Some([1.0, 14.4, 0.0, 14.4]), header_chr: CharProps { italic: Some(true), ..Default::default() }, header_fill: Some(Rgb(0xFF, 0xFF, 0)), + header_borders: Some(Borders::all(Border::single(1.5))), + band_fill: Some(Rgb(0xF2, 0xF2, 0xF2)), + band_chr: CharProps { bold: Some(true), color: Some(TextColor::Rgb(Rgb(0, 0x70, 0xC0))), ..Default::default() }, + band_borders: Some(Borders::box_(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + band_size: Some(3), ..Default::default() }; d.styles.upsert(Style { diff --git a/crates/engine/src/catalog.rs b/crates/engine/src/catalog.rs index 1597ddc..7dcc945 100644 --- a/crates/engine/src/catalog.rs +++ b/crates/engine/src/catalog.rs @@ -327,6 +327,8 @@ View|Macros|Record Macro|tools.recordMacro View|SharePoint|Properties|file.properties Table Design|Table Style Options|Table Style Options|table.look Table Design|Table Styles|Table Styles|table.style +Table Design|Table Styles|New Table Style|table.newStyle +Table Design|Table Styles|Modify Table Style|table.modifyStyle Table Design|Table Styles|Shading|table.shading Table Design|Borders|Borders|table.borders Table Design|Borders|Border Painter|table.borderPainter diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index bd1962a..42aaf7b 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -16,6 +16,7 @@ pub mod references; pub mod review; pub mod speech; pub mod table; +pub mod table_style; pub mod text; pub mod tools; pub mod view; @@ -39,6 +40,7 @@ pub fn registry() -> Registry { v.extend(equation::specs()); v.extend(page::specs()); v.extend(table::specs()); + v.extend(table_style::specs()); v.extend(review::specs()); v.extend(file::specs()); v.extend(design::specs()); diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 837c032..7a6a408 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -44,7 +44,7 @@ pub fn specs() -> Vec { t(CommandSpec::new("table.splitTable", "Split Table", "Table Layout › Merge", split_table)), t(CommandSpec::new("table.style", "Table Styles", "Table Design › Table Styles", |s, v| { let st = p::req_str(v, "style")?; - let id = s.doc.styles.find(st).map(|x| x.id.clone()).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; + let id = super::table_style::table_style_id(s, st).ok_or_else(|| CmdError::Params(format!("no table style `{st}`")))?; with_table(s, |t| t.props.style = Some(id.clone())) }) .params(r#"{"style": string}"#)), @@ -242,7 +242,6 @@ fn with_cells(s: &mut Session, f: impl Fn(&mut wordcraft_doc::Cell)) -> CmdResul sel_result(s) } -/// Bump the revision of every paragraph in a table (table style changes alter their layout). /// Table Layout › Text Direction: turn the selected cells' text. Without a value it cycles the /// caret cell's direction (horizontal → down → up) and gives every selected cell the result. fn text_direction(s: &mut Session, v: &Value) -> CmdResult { @@ -260,7 +259,8 @@ fn text_direction(s: &mut Session, v: &Value) -> CmdResult { with_cells(s, |cl| cl.props.text_direction = dir) } -fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { +/// Bump the revision of every paragraph in a table (table style changes alter their layout). +pub(crate) fn touch_cells(s: &mut Session, tp: &Path) -> Result<(), CmdError> { let story = s.sel.focus.story; let paths: Vec = s.doc.para_paths(story).into_iter().filter(|p| p.0.len() > tp.0.len() && p.0.starts_with(&tp.0)).collect(); for p in paths { diff --git a/crates/engine/src/cmd/table_style.rs b/crates/engine/src/cmd/table_style.rs new file mode 100644 index 0000000..79baae1 --- /dev/null +++ b/crates/engine/src/cmd/table_style.rs @@ -0,0 +1,183 @@ +//! Table Design › Table Styles: create and modify table styles (whole table, header row and +//! banded rows), stored as table styles in the document's style sheet (ECMA-376 §17.7.6). + +use serde_json::{Value, json}; +use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor}; +use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts}; + +use crate::{CmdError, CmdResult, CommandSpec, Session, p}; + +/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, +/// `bandedRows`) takes the same shortcuts. +macro_rules! style_params { + ($head:literal) => { + concat!( + "{", + $head, + r#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "bandedRows"?: Region, "bandSize"?: n, "para"?: ParaProps, "apply"?: bool}"#, + r#" where Region = {"borders"?: bool | null (true: single lines, false: none, null: inherit), "borderColor"?: "RRGGBB", "borderWidth"?: pt, "fill"?: "RRGGBB" | null, "bold"?: bool, "italic"?: bool, "color"?: "RRGGBB" | null, "size"?: pt, "font"?: string}"# + ) + }; +} + +pub fn specs() -> Vec { + vec![ + CommandSpec::new("table.newStyle", "New Table Style", "Table Design › Table Styles", new_style).params(style_params!(r#""name": string"#)), + CommandSpec::new("table.modifyStyle", "Modify Table Style", "Table Design › Table Styles", modify_style) + .params(style_params!(r#""style"?: string (default: the current table's), "name"?: string"#)), + ] +} + +/// A table style's id by id or display name (case-insensitive); styles of other kinds don't count. +pub fn table_style_id(s: &Session, name: &str) -> Option { + let st = &s.doc.styles.styles; + st.iter() + .find(|x| x.kind == StyleKind::Table && x.id == name) + .or_else(|| st.iter().find(|x| x.kind == StyleKind::Table && x.name.eq_ignore_ascii_case(name))) + .map(|x| x.id.clone()) +} + +/// The style of the table at the caret. +fn current_table_style(s: &Session) -> Option { + let (tp, _, _) = s.sel.focus.path.cell()?; + s.doc.table(s.sel.focus.story, &tp)?.props.style.clone() +} + +fn new_style(s: &mut Session, v: &Value) -> CmdResult { + let name = p::req_str(v, "name")?.trim().to_string(); + if name.is_empty() { + return Err(CmdError::Params("name is empty".into())); + } + if s.doc.styles.find(&name).is_some() { + return Err(CmdError::Failed(format!("a style named `{name}` already exists"))); + } + let based_on = match p::str(v, "basedOn") { + Some(b) => Some(table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?), + None => table_style_id(s, "TableGrid").or_else(|| table_style_id(s, "TableNormal")), + }; + let id = s.doc.styles.new_id(&name); + let mut st = Style { id: id.clone(), name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() }; + edit(&mut st, v)?; + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(true) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +fn modify_style(s: &mut Session, v: &Value) -> CmdResult { + let id = match p::str(v, "style") { + Some(n) => table_style_id(s, n).ok_or_else(|| CmdError::Params(format!("no table style `{n}`")))?, + None => current_table_style(s).ok_or_else(|| CmdError::Disabled("the cursor isn't in a table with a style".into()))?, + }; + let rename = p::str(v, "name").map(str::trim).filter(|n| !n.is_empty()).map(str::to_string); + if let Some(n) = &rename + && s.doc.styles.find(n).is_some_and(|x| x.id != id) + { + return Err(CmdError::Failed(format!("a style named `{n}` already exists"))); + } + let based_on = match p::str(v, "basedOn") { + Some(b) => { + let b = table_style_id(s, b).ok_or_else(|| CmdError::Params(format!("no table style `{b}`")))?; + // A style can't be based on itself or on a style based on it. + if s.doc.styles.chain(&b).iter().any(|x| x.id == id) { + return Err(CmdError::Params("a style can't be based on itself".into())); + } + Some(b) + } + None => None, + }; + let mut st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?; + if let Some(n) = rename { + st.name = n; + } + if let Some(b) = based_on { + st.based_on = Some(b); + } + edit(&mut st, v)?; + s.doc.styles.upsert(st); + s.dirty = true; + if p::bool(v, "apply").unwrap_or(false) { + apply(s, &id)?; + } + Ok(json!({"id": id})) +} + +/// Apply style `id` to the table at the caret, if any. +fn apply(s: &mut Session, id: &str) -> Result<(), CmdError> { + let Some((tp, _, _)) = s.sel.focus.path.cell() else { return Ok(()) }; + s.doc.table_mut(s.sel.focus.story, &tp)?.props.style = Some(id.to_string()); + super::table::touch_cells(s, &tp) +} + +/// Apply the formatting params to `st`. +fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> { + let mut parts = st.table.take().unwrap_or_default(); + if let Some(r) = v.get("wholeTable") { + region(r, &mut parts.fill, &mut st.chr, &mut parts.borders)?; + } + if let Some(r) = v.get("headerRow") { + region(r, &mut parts.header_fill, &mut parts.header_chr, &mut parts.header_borders)?; + } + if let Some(r) = v.get("bandedRows") { + region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?; + } + if let Some(n) = v.get("bandSize") { + parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32); + } + if let Some(pp) = v.get("para") { + let pp: ParaProps = serde_json::from_value(pp.clone()).map_err(|e| CmdError::Params(format!("para: {e}")))?; + st.para.overlay(&pp); + st.para.style = None; + st.para.numbering = None; + } + st.table = (parts != TableStyleParts::default()).then_some(parts); + Ok(()) +} + +/// One region's shortcuts: borders, fill and character formatting. +fn region(r: &Value, fill: &mut Option, chr: &mut CharProps, borders: &mut Option) -> Result<(), CmdError> { + if !r.is_object() { + return Err(CmdError::Params("a region is an object".into())); + } + let color = |k: &str| -> Result, CmdError> { + match r.get(k) { + None | Some(Value::Null) => Ok(None), + Some(Value::String(h)) => Rgb::parse(h).map(Some).ok_or_else(|| CmdError::Params(format!("`{k}`: bad colour `{h}`"))), + Some(_) => Err(CmdError::Params(format!("`{k}` is \"RRGGBB\" or null"))), + } + }; + if r.get("fill").is_some() { + *fill = color("fill")?; + } + if r.get("color").is_some() { + chr.color = color("color")?.map(TextColor::Rgb); + } + if let Some(b) = p::bool(r, "bold") { + chr.bold = Some(b); + chr.bold_cs = Some(b); + } + if let Some(b) = p::bool(r, "italic") { + chr.italic = Some(b); + chr.italic_cs = Some(b); + } + if let Some(sz) = p::f32(r, "size") { + chr.size = Some(sz.clamp(1.0, 1638.0)); + chr.size_cs = chr.size; + } + if let Some(f) = p::str(r, "font").map(str::trim).filter(|f| !f.is_empty()) { + chr.font = Some(f.to_string()); + } + match r.get("borders") { + Some(Value::Bool(true)) => { + let width = p::f32(r, "borderWidth").unwrap_or(0.5).clamp(0.25, 6.0); + *borders = Some(Borders::all(Border { style: BorderStyle::Single, width, color: color("borderColor")?, space: 0.0 })); + } + Some(Value::Bool(false)) => *borders = Some(Borders::all(Border { style: BorderStyle::None, width: 0.0, color: None, space: 0.0 })), + Some(Value::Null) => *borders = None, + Some(_) => return Err(CmdError::Params("`borders` is true, false or null".into())), + None => {} + } + Ok(()) +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 7397ff3..68ae8fc 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -1701,6 +1701,69 @@ fn timestamps_come_from_the_clock() { assert!(date >= cmd::iso_from_unix_secs(before) && date <= cmd::iso_from_unix_secs(cmd::now_unix()), "{date}"); } +/// #146: a table style made by command styles the table at the caret (header row fill and bold +/// text, banded rows), can be modified, and saves as a table style the table refers to. +#[test] +fn custom_table_style_applies_modifies_and_saves() { + use wordcraft_doc::{Rgb, StyleKind, TextColor}; + let mut s = s(); + run(&mut s, "insert.table", json!({"rows": 3, "cols": 2})); + run(&mut s, "text.insert", json!({"text": "Head"})); + run(&mut s, "table.look", json!({"headerRow": true, "bandedRows": true})); + let (tp, _, _) = s.sel.focus.path.cell().unwrap(); + let r = run( + &mut s, + "table.newStyle", + json!({"name": "Thesis Table", "basedOn": "Table Grid", + "wholeTable": {"size": 10}, + "headerRow": {"fill": "1F3864", "bold": true, "color": "FFFFFF", "borders": true}, + "bandedRows": {"fill": "EEEEEE", "italic": true}}), + ); + let id = r["id"].as_str().unwrap().to_string(); + let table_style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone(); + assert_eq!(table_style(&s).as_deref(), Some(id.as_str()), "applied to the current table"); + assert!(s.run("table.newStyle", &json!({"name": "thesis table"})).is_err(), "names are unique"); + assert!(s.run("table.newStyle", &json!({"name": "X", "basedOn": "Heading 1"})).is_err(), "bases are table styles"); + assert!(s.run("table.style", &json!({"style": "Normal"})).is_err(), "only table styles apply to tables"); + + let fills = |s: &mut Session, c: Rgb| { + s.layout().pages[0].items.iter().filter(|i| matches!(i, crate::layout::Placed::Fill { color, .. } if *color == c)).count() + }; + let head = |s: &mut Session| { + let mut p = tp.0.clone(); + p.extend([0, 0, 0]); + s.layout().pages[0] + .items + .iter() + .find_map( + |i| if let crate::layout::Placed::Lines { path, para, .. } = i { (path.0 == p).then(|| para.styles[0].rc.clone()) } else { None }, + ) + .unwrap() + }; + assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 2, "header cells"); + assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 2, "first band"); + let rc = head(&mut s); + assert!(rc.bold && rc.size == 10.0, "{rc:?}"); + assert_eq!(rc.color, TextColor::Rgb(Rgb::WHITE)); + + // Modify the current table's style: the table follows. + run(&mut s, "table.modifyStyle", json!({"headerRow": {"fill": "C00000", "bold": false}, "bandSize": 2})); + assert_eq!(fills(&mut s, Rgb(0x1F, 0x38, 0x64)), 0); + assert_eq!(fills(&mut s, Rgb(0xC0, 0, 0)), 2); + assert_eq!(fills(&mut s, Rgb(0xEE, 0xEE, 0xEE)), 4, "two rows per band"); + assert!(!head(&mut s).bold); + + let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap(); + let st = back.styles.get(&id).unwrap(); + assert_eq!((st.kind, st.name.as_str(), st.based_on.as_deref()), (StyleKind::Table, "Thesis Table", Some("TableGrid"))); + let parts = st.table.as_ref().unwrap(); + assert_eq!((parts.header_fill, parts.band_fill, parts.band_size), (Some(Rgb(0xC0, 0, 0)), Some(Rgb(0xEE, 0xEE, 0xEE)), Some(2))); + assert_eq!((parts.header_chr.bold, parts.band_chr.italic), (Some(false), Some(true))); + assert!(parts.header_borders.is_some_and(|b| b.any_visible())); + let t = back.body.iter().find_map(|b| b.as_table()).unwrap(); + assert_eq!(t.props.style.as_deref(), Some(id.as_str()), "w:tblStyle"); +} + #[test] fn page_and_table_gridlines_toggle_independently() { // #69: View › Gridlines (the page drawing grid) and Table Layout › View Gridlines (table cell diff --git a/crates/layout/src/table.rs b/crates/layout/src/table.rs index 491a4ef..6f5cc42 100644 --- a/crates/layout/src/table.rs +++ b/crates/layout/src/table.rs @@ -40,8 +40,8 @@ struct Turned { across: f32, /// List counters and equation count before the cell, to lay it out again the same way. snap: (Counters, u32), - /// Its formatting region (header row, total row, first column) and cell index in the row. - region: (bool, bool, bool), + /// Its formatting region (header row, total row, first column, row band) and cell index in the row. + region: (bool, bool, bool, bool), ci: usize, /// Top margin plus top border band. top: f32, @@ -97,8 +97,9 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 { pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], avail: f32, depth: usize) -> TableLayout { let style = table_style(ctx, t); let parts = style.as_ref().map(|s| &s.parts); - // Cell text formatting per (header row, total row, first column) region, built once per table. - let mut cell_text: Vec<((bool, bool, bool), CellText)> = Vec::new(); + // Cell text formatting per (header row, total row, first column, row band) region, built once + // per table. + let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new(); let ncols = t.cols().max(1); // Column widths. let mut grid: Vec = if t.grid.len() == ncols { @@ -152,6 +153,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava }; let nrows = t.rows.len(); let header_rows = t.props.look.header_row; + let band_size = parts.and_then(|p| p.band_size).unwrap_or(1).clamp(1, 1000) as usize; // First pass: lay out every cell's content. struct CellBox { items: Vec, @@ -178,7 +180,16 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let mut row_insets = 0.0f32; let is_header = header_rows && ri == 0; let is_total = t.props.look.total_row && ri + 1 == nrows && nrows > 1; - let band = t.props.look.banded_rows && !is_header && (ri - usize::from(header_rows)) % 2 == 0; + let band = t.props.look.banded_rows && !is_header && (ri.saturating_sub(usize::from(header_rows)) / band_size).is_multiple_of(2); + // The header row's or the odd band's own cell borders (conditional formatting). + let region_borders = if is_header { + parts.and_then(|p| p.header_borders) + } else if band { + parts.and_then(|p| p.band_borders) + } else { + None + } + .unwrap_or_default(); for (ci, cell) in row.cells.iter().enumerate() { let span = cell.span(); let x0 = colx.get(g).copied().unwrap_or(acc); @@ -186,7 +197,7 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let margins = cell.props.margins.unwrap_or(margins_def); let cw = (x1 - x0 - margins[1] - margins[3]).max(4.0); let mut fill = cell.props.shading; - let region = (is_header, is_total, t.props.look.first_column && g == 0); + let region = (is_header, is_total, t.props.look.first_column && g == 0, band); if let Some(p) = parts { if is_header { fill = fill.or(p.header_fill); @@ -202,6 +213,9 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava // The whole table's formatting, then column, then row conditional formatting // (later regions win, ECMA-376 §17.7.6). let mut chr = st.chr.clone(); + if band { + chr.overlay(&st.parts.band_chr); + } if region.2 { chr.overlay(&st.parts.first_col_chr); } @@ -221,11 +235,13 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava let edge = |own: Option, outer: bool, outer_b: Option, inner_b: Option| own.or(if outer { outer_b } else { inner_b }); let cb = cell.props.borders.unwrap_or_default(); + let rb = region_borders; + let (first_g, last_g) = (g == 0, g + span >= ncols); let mut borders = Borders { - top: edge(cb.top, ri == 0, tb.top, tb.between), - bottom: edge(cb.bottom, ri + 1 == nrows, tb.bottom, tb.between), - left: edge(cb.left, g == 0, tb.left, tb.inside_v), - right: edge(cb.right, g + span >= ncols, tb.right, tb.inside_v), + top: edge(cb.top.or(rb.top), ri == 0, tb.top, tb.between), + bottom: edge(cb.bottom.or(rb.bottom), ri + 1 == nrows, tb.bottom, tb.between), + left: edge(cb.left.or(if first_g { rb.left } else { rb.inside_v }), first_g, tb.left, tb.inside_v), + right: edge(cb.right.or(if last_g { rb.right } else { rb.inside_v }), last_g, tb.right, tb.inside_v), between: None, inside_v: None, }; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 259eceb..c9a2114 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1,8 +1,8 @@ //! Dialogs: Font, Paragraph, Find & Replace, Go To, Insert Table, Page Setup, Link, Bookmark, -//! Word Count, Zoom, Watermark, New/Modify Style, Command search, About, Save Changes, and the -//! mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, Match Fields and -//! Check for Errors. Every dialog ends by running a command (or shows one's result), so agents get -//! the same result without the dialog. +//! Word Count, Zoom, Watermark, New/Modify Style, New/Modify Table Style, Command search, About, +//! Save Changes, and the mail-merge Recipient List, Insert Merge Field, Find Recipient, merge rules, +//! Match Fields and Check for Errors. Every dialog ends by running a command (or shows one's +//! result), so agents get the same result without the dialog. use egui::{Sense, Ui, vec2}; use serde::Serialize; @@ -101,6 +101,21 @@ pub enum Dialog { before: f32, after: f32, }, + /// Table Design › New / Modify Table Style: one formatting region at a time. + TableStyle { + /// The style being modified; `None` creates one. + id: Option, + name: String, + /// Style id. + based_on: String, + /// Index into `regions`: whole table, header row, banded rows. + region: usize, + regions: Box<[TableRegion; 3]>, + /// What the regions showed when the dialog opened (or the base style changed): only + /// changes are sent, so everything else stays inherited. + #[serde(skip)] + basis: Box<[TableRegion; 3]>, + }, Commands { query: String, }, @@ -211,6 +226,93 @@ pub fn recipient_table(fields: &[String], rows: &[Vec]) -> Result Box<[TableRegion; 3]> { + let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() }; + let p = &st.parts; + let hex = |c: Option| c.map(|c| c.hex()).unwrap_or_default(); + let text = |c: &wordcraft_doc::CharProps| match c.color { + Some(wordcraft_doc::TextColor::Rgb(c)) => c.hex(), + _ => String::new(), + }; + let lines = |b: Option| b.is_some_and(|b| b.any_visible()); + let mut header_chr = st.chr.clone(); + header_chr.overlay(&p.header_chr); + let mut band_chr = st.chr.clone(); + band_chr.overlay(&p.band_chr); + Box::new([ + TableRegion { borders: lines(p.borders), fill: hex(p.fill), bold: st.chr.bold.unwrap_or(false), color: text(&st.chr) }, + TableRegion { borders: lines(p.header_borders), fill: hex(p.header_fill), bold: header_chr.bold.unwrap_or(false), color: text(&header_chr) }, + TableRegion { borders: lines(p.band_borders), fill: hex(p.band_fill), bold: band_chr.bold.unwrap_or(false), color: text(&band_chr) }, + ]) +} + +/// The style of the table at the caret, when it is a table style. +fn current_table_style(app: &WordApp) -> Option { + let s = &app.session; + let (tp, _, _) = s.sel.focus.path.cell()?; + let id = s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()?; + s.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.id.clone()) +} + +/// The params `table.newStyle` / `table.modifyStyle` need for what changed in one region. +fn region_changes(r: &TableRegion, basis: &TableRegion) -> Value { + let color = |c: &str| if c.is_empty() { Value::Null } else { json!(c) }; + let mut v = json!({}); + if r.borders != basis.borders { + v["borders"] = json!(r.borders); + } + if r.fill != basis.fill { + v["fill"] = color(&r.fill); + } + if r.bold != basis.bold { + v["bold"] = json!(r.bold); + } + if r.color != basis.color { + v["color"] = color(&r.color); + } + v +} + +/// A colour menu: a swatch with the colour grid and No Color. `value` is hex, empty for none. +fn color_menu(ui: &mut Ui, theme: &[wordcraft_doc::Rgb], value: &mut String) { + let c = wordcraft_doc::Rgb::parse(value); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); + let t = Tokens::get(ui.ctx()); + match c { + Some(c) => { + ui.painter().rect_filled(r, 2.0, crate::theme::c32(c)); + } + None => { + ui.painter().line_segment([r.left_bottom(), r.right_top()], egui::Stroke::new(1.0, t.text_dim)); + } + } + ui.painter().rect_stroke(r, 2.0, egui::Stroke::new(1.0, t.border_strong), egui::StrokeKind::Inside); + let label = if c.is_some() { value.clone() } else { tl!("No Color").to_string() }; + ui.menu_button(label, |ui| { + if ui.button(tl!("No Color")).clicked() { + value.clear(); + ui.close(); + } + if let Some(hex) = crate::widgets::color_grid(ui, theme) { + *value = hex; + ui.close(); + } + }); + }); +} + impl Dialog { pub fn name(&self) -> &'static str { match self { @@ -228,6 +330,8 @@ impl Dialog { Dialog::Watermark { .. } => "watermark", Dialog::NewStyle { .. } => "newStyle", Dialog::ModifyStyle { .. } => "modifyStyle", + Dialog::TableStyle { id: None, .. } => "newTableStyle", + Dialog::TableStyle { .. } => "modifyTableStyle", Dialog::Commands { .. } => "commands", Dialog::About { .. } => "about", Dialog::SaveChanges { .. } => "saveChanges", @@ -327,6 +431,20 @@ impl Dialog { "zoom" => Dialog::Zoom { percent: (app.session.view.zoom * 100.0).round() }, "watermark" => Dialog::Watermark { text: "CONFIDENTIAL".into(), diagonal: true }, "newStyle" => Dialog::NewStyle { name: "Style1".into(), based_on: "Normal".into() }, + "newTableStyle" => { + let based_on = current_table_style(app).unwrap_or_else(|| "TableGrid".into()); + let styles = &app.session.doc.styles; + let name = (1..1000).map(|i| format!("Table Style {i}")).find(|n| styles.find(n).is_none()).unwrap_or_default(); + let regions = table_regions(app, &based_on); + Dialog::TableStyle { id: None, name, based_on, region: 0, basis: regions.clone(), regions } + } + "modifyTableStyle" => { + let id = current_table_style(app)?; + let st = app.session.doc.styles.get(&id)?; + let (name, based_on) = (st.name.clone(), st.based_on.clone().unwrap_or_default()); + let regions = table_regions(app, &id); + Dialog::TableStyle { id: Some(id), name, based_on, region: 0, basis: regions.clone(), regions } + } "commands" => Dialog::Commands { query: String::new() }, "about" => Dialog::About { tab: 0 }, "contributors" => Dialog::About { tab: 1 }, @@ -449,6 +567,8 @@ pub fn show(app: &mut WordApp, ctx: &egui::Context) { Dialog::Watermark { .. } => "Custom Watermark", Dialog::NewStyle { .. } => "Create New Style", Dialog::ModifyStyle { .. } => "Modify Style", + Dialog::TableStyle { id: None, .. } => "New Table Style", + Dialog::TableStyle { .. } => "Modify Table Style", Dialog::Commands { .. } => "Search Commands", Dialog::About { .. } => "About WordCraft", Dialog::SaveChanges { .. } => "WordCraft", @@ -883,6 +1003,90 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool { } ok || cancel } + Dialog::TableStyle { id, name, based_on, region, regions, basis } => { + let styles: Vec<(String, String)> = app + .session + .doc + .styles + .styles + .iter() + .filter(|s| s.kind == wordcraft_doc::StyleKind::Table && Some(&s.id) != id.as_ref()) + .map(|s| (s.id.clone(), s.name.clone())) + .collect(); + let base_name = styles.iter().find(|(i, _)| i == based_on).map(|(_, n)| n.clone()).unwrap_or_else(|| based_on.clone()); + let mut rebased = false; + egui::Grid::new("tstyle").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Name:")); + ui.text_edit_singleline(name); + ui.end_row(); + ui.label(tl!("Style based on:")); + egui::ComboBox::from_id_salt("tstyle_base").width(200.0).selected_text(base_name).show_ui(ui, |ui| { + for (sid, sname) in &styles { + if ui.selectable_label(sid == based_on, sname).clicked() && sid != based_on { + *based_on = sid.clone(); + rebased = true; + } + } + }); + ui.end_row(); + ui.label(tl!("Apply formatting to:")); + let names = ["Whole Table", "Header Row", "Banded Rows"]; + egui::ComboBox::from_id_salt("tstyle_region") + .width(200.0) + .selected_text(tl!(names.get(*region).copied().unwrap_or("Whole Table"))) + .show_ui(ui, |ui| { + for (i, n) in names.iter().enumerate() { + ui.selectable_value(region, i, tl!(n)); + } + }); + ui.end_row(); + }); + // A new style shows its new base's formatting; a modified one keeps its own. + if rebased && id.is_none() { + *regions = table_regions(app, based_on); + *basis = regions.clone(); + } + ui.separator(); + let theme = app.session.doc.settings.theme_colors.clone(); + if let Some(r) = regions.get_mut(*region) { + ui.horizontal(|ui| { + ui.checkbox(&mut r.borders, tl!("Borders")); + ui.checkbox(&mut r.bold, tl!("Bold")); + }); + egui::Grid::new("tstyle_colors").num_columns(2).spacing(vec2(10.0, 6.0)).show(ui, |ui| { + ui.label(tl!("Fill color:")); + color_menu(ui, &theme, &mut r.fill); + ui.end_row(); + ui.label(tl!("Font color:")); + color_menu(ui, &theme, &mut r.color); + ui.end_row(); + }); + } + let (ok, cancel) = buttons(ui, tl!("OK")); + if ok { + let mut v = json!({"name": name.trim(), "basedOn": based_on}); + for (i, key) in ["wholeTable", "headerRow", "bandedRows"].into_iter().enumerate() { + if let (Some(r), Some(b)) = (regions.get(i), basis.get(i)) { + let ch = region_changes(r, b); + if ch.as_object().is_some_and(|o| !o.is_empty()) { + v[key] = ch; + } + } + } + let cmd = match id { + Some(sid) => { + v["style"] = json!(sid); + "table.modifyStyle" + } + None => "table.newStyle", + }; + if let Err(e) = app.run(cmd, v) { + app.status(e); + return false; + } + } + ok || cancel + } Dialog::Commands { query } => { let r = ui.add(egui::TextEdit::singleline(query).hint_text(tl!("Type a command, e.g. \"insert table\"")).desired_width(380.0)); r.request_focus(); diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index 2ef46ec..19de3af 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -948,3 +948,10 @@ Dark Oscuro Use system setting Usar la configuración del sistema A file dialog is already open. Ya hay un cuadro de diálogo de archivo abierto. + New Table Style Nuevo estilo de tabla + Modify Table Style Modificar estilo de tabla + New Table Style… Nuevo estilo de tabla… + Modify Table Style… Modificar estilo de tabla… + Apply formatting to: Aplicar formato a: + Whole Table Toda la tabla + Fill color: Color de relleno: diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index a73def0..4acca7d 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -931,3 +931,10 @@ Dark ダーク Use system setting システム設定を使用 A file dialog is already open. ファイル ダイアログが既に開いています。 + New Table Style 新しい表スタイル + Modify Table Style 表スタイルの変更 + New Table Style… 新しい表スタイル… + Modify Table Style… 表スタイルの変更… + Apply formatting to: 書式の適用先: + Whole Table 表全体 + Fill color: 塗りつぶしの色: diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 921391b..8889245 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -943,3 +943,10 @@ Dark Escuro Use system setting Usar a configuração do sistema A file dialog is already open. Já há uma caixa de diálogo de arquivo aberta. + New Table Style Novo estilo de tabela + Modify Table Style Modificar estilo de tabela + New Table Style… Novo estilo de tabela… + Modify Table Style… Modificar estilo de tabela… + Apply formatting to: Aplicar formatação a: + Whole Table Tabela inteira + Fill color: Cor de preenchimento: diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index aeff3d2..17b84bd 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -943,3 +943,10 @@ Dark Темна Use system setting Як у системі A file dialog is already open. Діалогове вікно файлу вже відкрито. + New Table Style Новий стиль таблиці + Modify Table Style Змінити стиль таблиці + New Table Style… Новий стиль таблиці… + Modify Table Style… Змінити стиль таблиці… + Apply formatting to: Застосувати форматування до: + Whole Table Уся таблиця + Fill color: Колір заливки: diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index b2f9d3c..35b7f90 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -942,3 +942,10 @@ Dark 深色 Use system setting 使用系统设置 A file dialog is already open. 已有一个文件对话框处于打开状态。 + New Table Style 新建表格样式 + Modify Table Style 修改表格样式 + New Table Style… 新建表格样式… + Modify Table Style… 修改表格样式… + Apply formatting to: 将格式应用于: + Whole Table 整个表格 + Fill color: 填充颜色: diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index a46dcca..58c2d70 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -932,3 +932,10 @@ Dark 深色 Use system setting 使用系統設定 A file dialog is already open. 已有一個檔案對話方塊開啟中。 + New Table Style 新增表格樣式 + Modify Table Style 修改表格樣式 + New Table Style… 新增表格樣式… + Modify Table Style… 修改表格樣式… + Apply formatting to: 將格式套用至: + Whole Table 整個表格 + Fill color: 填滿色彩: diff --git a/crates/ui-egui/src/previews.rs b/crates/ui-egui/src/previews.rs index 95d8da7..e6d844a 100644 --- a/crates/ui-egui/src/previews.rs +++ b/crates/ui-egui/src/previews.rs @@ -329,7 +329,9 @@ pub fn table_style_tile(ui: &mut Ui, app: &mut WordApp, style: &str) -> Response if resp.hovered() { ui.painter().rect_filled(r, 3.0, t.hover); } - let key = format!("tstyle:{style}:{ppp}"); + // Keyed by the style sheet too, so a modified table style redraws. + let skey = app.previews.styles_key(&app.session.doc, app.session.rev()); + let key = format!("tstyle:{style}:{skey}:{ppp}"); let doc = &app.session.doc; let tex = app.previews.get_or(ui.ctx(), &key, || { let mut tb = Table::new(5, 4, 100.0); diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs index 978256e..0568985 100644 --- a/crates/ui-egui/src/ribbon.rs +++ b/crates/ui-egui/src/ribbon.rs @@ -1257,6 +1257,22 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) { }); }); stack(ui, |ui| { + let can_modify = look.is_some() + && app + .session + .sel + .focus + .path + .cell() + .and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()) + .is_some(); + menu_button(ui, app, "styles", Some("Styles"), "Table Styles", false, |ui, app| { + mi(ui, app, "New Table Style…", "ui.dialog", json!({"name": "newTableStyle"})); + if ui.add_enabled(can_modify, egui::Button::new(tl!("Modify Table Style…")).min_size(vec2(200.0, 0.0))).clicked() { + let _ = app.run("ui.dialog", json!({"name": "modifyTableStyle"})); + ui.close(); + } + }); split(ui, app, "shading", "Shading", "table.shading", json!({"color": app.canvas.last_shading.clone()}), false, None, |ui, app| { mi(ui, app, "No Color", "table.shading", json!({"color": null})); let theme = app.session.doc.settings.theme_colors.clone(); diff --git a/docs/parity.md b/docs/parity.md index afb6269..2de2ef4 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -2,7 +2,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word-processor feature catalog (`crates/engine/src/catalog.rs`) compared with the live command registry. -**378 of 428 catalog features have commands (88.3%).** +**380 of 430 catalog features have commands (88.4%).** | Tab | Live | Total | |---|---|---| @@ -17,7 +17,7 @@ Generated by `cargo xtask parity` (`wordcraft-cli parity --markdown`): the word- | Mailings | 17 | 17 | | Review | 26 | 29 | | View | 25 | 29 | -| Table Design | 5 | 5 | +| Table Design | 7 | 7 | | Table Layout | 28 | 30 | | Equation | 21 | 22 | | Picture Format | 13 | 13 |