mirror of
https://github.com/storytold/wordcraft.git
synced 2026-10-25 10:57:14 -06:00
Table styles: every region, a live preview, delete, custom styles first (#146)
Follow-up to the custom table styles work: the New/Modify Table Style dialog now covers every region a table style formats, shows what the style will look like, and table styles can be deleted. - engine: table.newStyle / table.modifyStyle take lastRow (total row), firstColumn, lastColumn and bandedColumns regions with the same props as the others. New table.deleteStyle removes a custom table style: built-in ones are refused, styles based on it are re-based on its own base with its formatting folded in (so they look the same), and tables using it fall back to Table Grid. Undoable like any command. - doc/layout: table styles gain fill and cell borders for the first and last column and total row, and fill, text and borders for column bands; layout applies them in the standard order (column bands, row bands, first/last column, header/total row). - docx: firstCol/lastCol/lastRow/band1Vert shading and cell borders are read and written. - ui: the dialog's "Apply formatting to" lists all seven regions and a small live preview table shows the pending changes; Styles menu gets Delete Table Style (disabled for built-ins); the gallery shows the document's own table styles first. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8aa4a25245
commit
2fefdebcb1
@@ -65,6 +65,21 @@ pub struct TableStyleParts {
|
||||
pub first_col_chr: CharProps,
|
||||
pub total_chr: CharProps,
|
||||
pub total_border_top: Option<Border>,
|
||||
/// Shading and cell borders of the first column (`firstCol` region).
|
||||
pub first_col_fill: Option<Rgb>,
|
||||
pub first_col_borders: Option<Borders>,
|
||||
/// The last column (`lastCol` region).
|
||||
pub last_col_fill: Option<Rgb>,
|
||||
pub last_col_chr: CharProps,
|
||||
pub last_col_borders: Option<Borders>,
|
||||
/// Shading and cell borders of the total (last) row (`lastRow` region); a top edge left
|
||||
/// unset falls back to `total_border_top`.
|
||||
pub total_fill: Option<Rgb>,
|
||||
pub total_borders: Option<Borders>,
|
||||
/// The odd column bands (`band1Vert` region).
|
||||
pub col_band_fill: Option<Rgb>,
|
||||
pub col_band_chr: CharProps,
|
||||
pub col_band_borders: Option<Borders>,
|
||||
}
|
||||
|
||||
impl TableStyleParts {
|
||||
@@ -91,6 +106,16 @@ impl TableStyleParts {
|
||||
self.first_col_chr.overlay(&patch.first_col_chr);
|
||||
self.total_chr.overlay(&patch.total_chr);
|
||||
self.total_border_top = patch.total_border_top.or(self.total_border_top);
|
||||
self.first_col_fill = patch.first_col_fill.or(self.first_col_fill);
|
||||
self.first_col_borders = merge(self.first_col_borders, patch.first_col_borders);
|
||||
self.last_col_fill = patch.last_col_fill.or(self.last_col_fill);
|
||||
self.last_col_chr.overlay(&patch.last_col_chr);
|
||||
self.last_col_borders = merge(self.last_col_borders, patch.last_col_borders);
|
||||
self.total_fill = patch.total_fill.or(self.total_fill);
|
||||
self.total_borders = merge(self.total_borders, patch.total_borders);
|
||||
self.col_band_fill = patch.col_band_fill.or(self.col_band_fill);
|
||||
self.col_band_chr.overlay(&patch.col_band_chr);
|
||||
self.col_band_borders = merge(self.col_band_borders, patch.col_band_borders);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -473,10 +473,28 @@ impl Reader<'_> {
|
||||
t.band_chr = chr;
|
||||
t.band_borders = cell_borders;
|
||||
}
|
||||
Some("firstCol") => t.first_col_chr = chr,
|
||||
Some("firstCol") => {
|
||||
t.first_col_chr = chr;
|
||||
t.first_col_fill = fill;
|
||||
t.first_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastCol") => {
|
||||
t.last_col_chr = chr;
|
||||
t.last_col_fill = fill;
|
||||
t.last_col_borders = cell_borders;
|
||||
}
|
||||
Some("lastRow") => {
|
||||
t.total_chr = chr;
|
||||
t.total_border_top = c.child("w:tcPr").and_then(|p| p.child("w:tcBorders")).and_then(|b| b.child("w:top")).map(props::border);
|
||||
t.total_fill = fill;
|
||||
// The top rule is kept on its own (as built-in styles have it); the other edges
|
||||
// are the region's borders.
|
||||
t.total_border_top = cell_borders.and_then(|b| b.top);
|
||||
t.total_borders = cell_borders.map(|b| wordcraft_doc::props::Borders { top: None, ..b }).filter(|b| *b != Default::default());
|
||||
}
|
||||
Some("band1Vert") => {
|
||||
t.col_band_fill = fill;
|
||||
t.col_band_chr = chr;
|
||||
t.col_band_borders = cell_borders;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
|
||||
@@ -665,9 +665,18 @@ fn style_xml(w: &mut W, st: &Style) {
|
||||
};
|
||||
cond(w, "wholeTable", &wordcraft_doc::CharProps::default(), t.fill, None);
|
||||
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);
|
||||
// The total row's borders, its top edge falling back to the built-in top rule.
|
||||
let total_borders = match (t.total_borders, t.total_border_top) {
|
||||
(Some(mut b), top) => {
|
||||
b.top = b.top.or(top);
|
||||
Some(b)
|
||||
}
|
||||
(None, top) => top.map(|b| wordcraft_doc::props::Borders { top: Some(b), ..Default::default() }),
|
||||
};
|
||||
cond(w, "lastRow", &t.total_chr, t.total_fill, total_borders);
|
||||
cond(w, "firstCol", &t.first_col_chr, t.first_col_fill, t.first_col_borders);
|
||||
cond(w, "lastCol", &t.last_col_chr, t.last_col_fill, t.last_col_borders);
|
||||
cond(w, "band1Vert", &t.col_band_chr, t.col_band_fill, t.col_band_borders);
|
||||
cond(w, "band1Horz", &t.band_chr, t.band_fill, t.band_borders);
|
||||
}
|
||||
w.close("w:style");
|
||||
|
||||
@@ -329,6 +329,7 @@ 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|Delete Table Style|table.deleteStyle
|
||||
Table Design|Table Styles|Shading|table.shading
|
||||
Table Design|Borders|Borders|table.borders
|
||||
Table Design|Borders|Border Painter|table.borderPainter
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
//! 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).
|
||||
//! Table Design › Table Styles: create, modify and delete table styles (whole table, header and
|
||||
//! total rows, first and last columns, banded rows and columns), stored as table styles in the
|
||||
//! document's style sheet (ECMA-376 §17.7.6).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use wordcraft_doc::props::{Border, BorderStyle, Borders, CharProps, ParaProps, Rgb, TextColor};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts};
|
||||
use wordcraft_doc::styles::{Style, StyleKind, TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{Block, Table};
|
||||
|
||||
use crate::{CmdError, CmdResult, CommandSpec, Session, p};
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`,
|
||||
/// `bandedRows`) takes the same shortcuts.
|
||||
/// The regions a table style formats, by param name: the table style options they follow.
|
||||
pub const REGIONS: [&str; 7] = ["wholeTable", "headerRow", "lastRow", "firstColumn", "lastColumn", "bandedRows", "bandedColumns"];
|
||||
|
||||
/// The formatting params both commands share; each region (`wholeTable`, `headerRow`, `lastRow`
|
||||
/// (the total row), `firstColumn`, `lastColumn`, `bandedRows`, `bandedColumns`) 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#", "basedOn"?: string, "wholeTable"?: Region, "headerRow"?: Region, "lastRow"?: Region (total row), "firstColumn"?: Region, "lastColumn"?: Region, "bandedRows"?: Region, "bandedColumns"?: Region, "bandSize"?: n (rows per band), "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}"#
|
||||
)
|
||||
};
|
||||
@@ -25,6 +33,8 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
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"#)),
|
||||
CommandSpec::new("table.deleteStyle", "Delete Table Style", "Table Design › Table Styles", delete_style)
|
||||
.params(r#"{"style"?: string (default: the current table's)}"#),
|
||||
]
|
||||
}
|
||||
|
||||
@@ -43,7 +53,8 @@ fn current_table_style(s: &Session) -> Option<String> {
|
||||
s.doc.table(s.sel.focus.story, &tp)?.props.style.clone()
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
/// The style `table.newStyle` would add for `v`, validated.
|
||||
fn build_new(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let name = p::req_str(v, "name")?.trim().to_string();
|
||||
if name.is_empty() {
|
||||
return Err(CmdError::Params("name is empty".into()));
|
||||
@@ -56,8 +67,14 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
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() };
|
||||
let mut st = Style { id, name, kind: StyleKind::Table, based_on, priority: Some(99), ..Default::default() };
|
||||
edit(&mut st, v)?;
|
||||
Ok(st)
|
||||
}
|
||||
|
||||
fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let st = build_new(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(true) {
|
||||
@@ -66,11 +83,28 @@ fn new_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The table style `v` names (`style`), else the current table's.
|
||||
fn target(s: &Session, v: &Value) -> Result<String, CmdError> {
|
||||
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())),
|
||||
}
|
||||
}
|
||||
|
||||
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 st = build_modified(s, v)?;
|
||||
let id = st.id.clone();
|
||||
s.doc.styles.upsert(st);
|
||||
s.dirty = true;
|
||||
if p::bool(v, "apply").unwrap_or(false) {
|
||||
apply(s, &id)?;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
}
|
||||
|
||||
/// The style `table.modifyStyle` would store for `v`, validated.
|
||||
fn build_modified(s: &Session, v: &Value) -> Result<Style, CmdError> {
|
||||
let id = target(s, v)?;
|
||||
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)
|
||||
@@ -96,12 +130,100 @@ fn modify_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
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(st)
|
||||
}
|
||||
|
||||
/// How table style `v["style"]` (or, without it, a new style) would look with the formatting
|
||||
/// params of `table.modifyStyle` / `table.newStyle` applied, for a dialog's live preview. Names
|
||||
/// aren't checked; `None` when the params don't validate.
|
||||
pub fn preview(s: &Session, v: &Value) -> Option<TableStyleProps> {
|
||||
if !v.is_object() {
|
||||
return None;
|
||||
}
|
||||
Ok(json!({"id": id}))
|
||||
let mut v = v.clone();
|
||||
let st = if p::str(&v, "style").is_some() {
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.remove("name");
|
||||
}
|
||||
build_modified(s, &v).ok()?
|
||||
} else {
|
||||
// A name no style has: the look doesn't depend on it.
|
||||
v["name"] = json!("\u{1}preview");
|
||||
build_new(s, &v).ok()?
|
||||
};
|
||||
let id = st.id.clone();
|
||||
let mut sheet = s.doc.styles.clone();
|
||||
sheet.upsert(st);
|
||||
sheet.table_style(&id)
|
||||
}
|
||||
|
||||
/// Delete a custom table style. Styles based on it are re-based on its own base with its
|
||||
/// formatting folded in, so they look the same; tables using it take Table Grid (the style new
|
||||
/// tables get), or no style when the document has no Table Grid.
|
||||
fn delete_style(s: &mut Session, v: &Value) -> CmdResult {
|
||||
let id = target(s, v)?;
|
||||
let st = s.doc.styles.get(&id).cloned().ok_or_else(|| CmdError::Params("no such style".into()))?;
|
||||
if st.builtin {
|
||||
return Err(CmdError::Failed(format!("`{}` is a built-in table style and can't be deleted", st.name)));
|
||||
}
|
||||
let mut rebased = Vec::new();
|
||||
for child in s.doc.styles.styles.iter_mut().filter(|c| c.based_on.as_deref() == Some(id.as_str())) {
|
||||
let mut para = st.para.clone();
|
||||
para.overlay(&child.para);
|
||||
child.para = para;
|
||||
let mut chr = st.chr.clone();
|
||||
chr.overlay(&child.chr);
|
||||
child.chr = chr;
|
||||
if child.kind == StyleKind::Table {
|
||||
let mut parts = st.table.clone().unwrap_or_default();
|
||||
if let Some(t) = &child.table {
|
||||
parts.overlay(t);
|
||||
}
|
||||
child.table = (parts != TableStyleParts::default()).then_some(parts);
|
||||
}
|
||||
child.based_on = st.based_on.clone();
|
||||
rebased.push(child.id.clone());
|
||||
}
|
||||
s.doc.styles.styles.retain(|x| x.id != id);
|
||||
let fallback = table_style_id(s, "TableGrid");
|
||||
let mut tables = restyle(&mut s.doc.body, &id, fallback.as_deref(), 0);
|
||||
for part in s.doc.parts.values_mut() {
|
||||
tables += restyle(&mut part.blocks, &id, fallback.as_deref(), 0);
|
||||
}
|
||||
s.dirty = true;
|
||||
Ok(json!({"deleted": id, "tables": tables, "rebased": rebased}))
|
||||
}
|
||||
|
||||
/// Tables nest at most this deep for restyling.
|
||||
const MAX_DEPTH: usize = 32;
|
||||
|
||||
/// Whether table `t`, or one nested in it, uses style `id`.
|
||||
fn uses_style(t: &Table, id: &str, depth: usize) -> bool {
|
||||
t.props.style.as_deref() == Some(id)
|
||||
|| (depth < MAX_DEPTH
|
||||
&& t.rows.iter().flat_map(|r| &r.cells).flat_map(|c| &c.blocks).any(|b| b.as_table().is_some_and(|t| uses_style(t, id, depth + 1))))
|
||||
}
|
||||
|
||||
/// Give every table in `blocks` that uses style `from` style `to`; the number restyled.
|
||||
fn restyle(blocks: &mut [Arc<Block>], from: &str, to: Option<&str>, depth: usize) -> usize {
|
||||
let mut n = 0;
|
||||
for b in blocks {
|
||||
if !b.as_table().is_some_and(|t| uses_style(t, from, depth)) {
|
||||
continue;
|
||||
}
|
||||
if let Block::Table(t) = Arc::make_mut(b) {
|
||||
if t.props.style.as_deref() == Some(from) {
|
||||
t.props.style = to.map(str::to_string);
|
||||
n += 1;
|
||||
}
|
||||
if depth < MAX_DEPTH {
|
||||
for c in t.rows.iter_mut().flat_map(|r| &mut r.cells) {
|
||||
n += restyle(&mut c.blocks, from, to, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
/// Apply style `id` to the table at the caret, if any.
|
||||
@@ -123,6 +245,18 @@ fn edit(st: &mut Style, v: &Value) -> Result<(), CmdError> {
|
||||
if let Some(r) = v.get("bandedRows") {
|
||||
region(r, &mut parts.band_fill, &mut parts.band_chr, &mut parts.band_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastRow") {
|
||||
region(r, &mut parts.total_fill, &mut parts.total_chr, &mut parts.total_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("firstColumn") {
|
||||
region(r, &mut parts.first_col_fill, &mut parts.first_col_chr, &mut parts.first_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("lastColumn") {
|
||||
region(r, &mut parts.last_col_fill, &mut parts.last_col_chr, &mut parts.last_col_borders)?;
|
||||
}
|
||||
if let Some(r) = v.get("bandedColumns") {
|
||||
region(r, &mut parts.col_band_fill, &mut parts.col_band_chr, &mut parts.col_band_borders)?;
|
||||
}
|
||||
if let Some(n) = v.get("bandSize") {
|
||||
parts.band_size = n.as_u64().map(|n| n.clamp(1, 1000) as u32);
|
||||
}
|
||||
|
||||
@@ -1763,3 +1763,73 @@ fn custom_table_style_applies_modifies_and_saves() {
|
||||
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");
|
||||
}
|
||||
|
||||
/// #146: the first/last column, total row and banded column regions apply in layout (later
|
||||
/// regions win: the total row's fill beats the first column's) and round-trip through .docx.
|
||||
#[test]
|
||||
fn custom_table_style_column_and_total_regions() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 3, "cols": 4}));
|
||||
run(
|
||||
&mut s,
|
||||
"table.look",
|
||||
json!({"headerRow": false, "bandedRows": false, "firstColumn": true, "lastColumn": true, "totalRow": true, "bandedColumns": true}),
|
||||
);
|
||||
let r = run(
|
||||
&mut s,
|
||||
"table.newStyle",
|
||||
json!({"name": "Ledger",
|
||||
"firstColumn": {"fill": "112233", "bold": true},
|
||||
"lastColumn": {"fill": "445566"},
|
||||
"lastRow": {"fill": "778899", "borders": true},
|
||||
"bandedColumns": {"fill": "ABCDEF"}}),
|
||||
);
|
||||
let id = r["id"].as_str().unwrap().to_string();
|
||||
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()
|
||||
};
|
||||
// 3 rows x 4 columns: the total row takes all 4 of its cells, the first and last columns the
|
||||
// two cells above it, and the column band (column 2 of the inner columns 2 and 3) one each.
|
||||
assert_eq!(fills(&mut s, Rgb(0x77, 0x88, 0x99)), 4, "total row");
|
||||
assert_eq!(fills(&mut s, Rgb(0x11, 0x22, 0x33)), 2, "first column");
|
||||
assert_eq!(fills(&mut s, Rgb(0x44, 0x55, 0x66)), 2, "last column");
|
||||
assert_eq!(fills(&mut s, Rgb(0xAB, 0xCD, 0xEF)), 2, "first column band");
|
||||
|
||||
let back = wordcraft_docx::read(&wordcraft_docx::write(&s.doc).unwrap()).unwrap();
|
||||
let p = back.styles.get(&id).unwrap().table.clone().unwrap();
|
||||
assert_eq!(
|
||||
(p.first_col_fill, p.first_col_chr.bold, p.last_col_fill, p.total_fill, p.col_band_fill),
|
||||
(Some(Rgb(0x11, 0x22, 0x33)), Some(true), Some(Rgb(0x44, 0x55, 0x66)), Some(Rgb(0x77, 0x88, 0x99)), Some(Rgb(0xAB, 0xCD, 0xEF)))
|
||||
);
|
||||
assert!(p.total_borders.is_some_and(|b| b.any_visible()));
|
||||
}
|
||||
|
||||
/// #146: deleting a custom table style re-bases the styles based on it (keeping their look),
|
||||
/// puts its tables on Table Grid, refuses built-in styles, and undoes.
|
||||
#[test]
|
||||
fn delete_table_style_falls_back_and_undoes() {
|
||||
use wordcraft_doc::Rgb;
|
||||
let mut s = s();
|
||||
run(&mut s, "insert.table", json!({"rows": 2, "cols": 2}));
|
||||
let (tp, _, _) = s.sel.focus.path.cell().unwrap();
|
||||
run(&mut s, "table.newStyle", json!({"name": "Base", "headerRow": {"fill": "C00000"}}));
|
||||
run(&mut s, "table.newStyle", json!({"name": "Child", "basedOn": "Base", "apply": false, "wholeTable": {"bold": true}}));
|
||||
let style = |s: &Session| s.doc.table(StoryRef::Body, &tp).unwrap().props.style.clone();
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Table Grid"})).is_err(), "built-in");
|
||||
assert!(s.run("table.deleteStyle", &json!({"style": "Normal Table"})).is_err(), "built-in");
|
||||
|
||||
let r = run(&mut s, "table.deleteStyle", json!({}));
|
||||
assert_eq!((r["deleted"].as_str(), r["tables"].as_u64()), (Some("Base"), Some(1)));
|
||||
assert!(s.doc.styles.get("Base").is_none());
|
||||
assert_eq!(style(&s).as_deref(), Some("TableGrid"), "tables fall back to Table Grid");
|
||||
let child = s.doc.styles.table_style("Child").unwrap();
|
||||
assert_eq!((child.parts.header_fill, child.chr.bold), (Some(Rgb(0xC0, 0, 0)), Some(true)), "the child keeps its look");
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("TableGrid"));
|
||||
|
||||
run(&mut s, "edit.undo", json!({}));
|
||||
assert!(s.doc.styles.get("Base").is_some());
|
||||
assert_eq!(style(&s).as_deref(), Some("Base"));
|
||||
assert_eq!(s.doc.styles.get("Child").unwrap().based_on.as_deref(), Some("Base"));
|
||||
}
|
||||
|
||||
+90
-27
@@ -4,7 +4,7 @@
|
||||
|
||||
use wordcraft_doc::numbering::Counters;
|
||||
use wordcraft_doc::props::{Align, Border, Borders, HeightRule, Rgb, TextDirection, VAlign, VMerge};
|
||||
use wordcraft_doc::styles::TableStyleProps;
|
||||
use wordcraft_doc::styles::{TableStyleParts, TableStyleProps};
|
||||
use wordcraft_doc::{StoryRef, Table};
|
||||
use wordcraft_geom::Rect;
|
||||
|
||||
@@ -40,9 +40,9 @@ 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, row band) and cell index in
|
||||
/// the row.
|
||||
region: (bool, bool, bool, bool),
|
||||
/// Its formatting region (header row, total row, first column, last column, row band, column
|
||||
/// band) and cell index in the row.
|
||||
region: (bool, bool, bool, bool, bool, bool),
|
||||
ci: usize,
|
||||
/// Top margin plus top border band.
|
||||
top: f32,
|
||||
@@ -95,12 +95,54 @@ fn first_cell_left_margin_in(t: &Table, def: [f32; 4]) -> f32 {
|
||||
t.rows.first().and_then(|r| r.cells.first()).and_then(|c| c.props.margins).unwrap_or(def)[1]
|
||||
}
|
||||
|
||||
/// A cell's borders from the table style's conditional regions it is in (`regions`: header row,
|
||||
/// total row, first column, last column, row band, column band), later regions winning. Row
|
||||
/// regions give their left/right edges at the table's sides and `inside_v` between cells; column
|
||||
/// regions their top/bottom edges at the table's top and bottom and `between` between rows.
|
||||
fn region_borders(p: &TableStyleParts, (ri, nrows): (usize, usize), (first_g, last_g): (bool, bool), regions: [bool; 6]) -> Borders {
|
||||
let [header, total, first_col, last_col, band, col_band] = regions;
|
||||
let row = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: b.top,
|
||||
bottom: b.bottom,
|
||||
left: if first_g { b.left } else { b.inside_v },
|
||||
right: if last_g { b.right } else { b.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let col = |b: Option<Borders>| {
|
||||
b.map(|b| Borders {
|
||||
top: if ri == 0 { b.top } else { b.between },
|
||||
bottom: if ri + 1 >= nrows { b.bottom } else { b.between },
|
||||
left: b.left,
|
||||
right: b.right,
|
||||
between: None,
|
||||
inside_v: None,
|
||||
})
|
||||
};
|
||||
let mut out = Borders::default();
|
||||
for (on, b) in [
|
||||
(col_band, col(p.col_band_borders)),
|
||||
(band, row(p.band_borders)),
|
||||
(first_col, col(p.first_col_borders)),
|
||||
(last_col, col(p.last_col_borders)),
|
||||
(header, row(p.header_borders)),
|
||||
(total, row(p.total_borders)),
|
||||
] {
|
||||
if on && let Some(b) = b {
|
||||
out.overlay(&b);
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
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, row band) region, built once
|
||||
// per table.
|
||||
let mut cell_text: Vec<((bool, bool, bool, bool), CellText)> = Vec::new();
|
||||
// Cell text formatting per region (header row, total row, first column, last column, row
|
||||
// band, column band), built once per table.
|
||||
let mut cell_text: Vec<((bool, bool, bool, bool, bool, bool), CellText)> = Vec::new();
|
||||
let ncols = t.cols().max(1);
|
||||
// Column widths.
|
||||
let mut grid: Vec<f32> = if t.grid.len() == ncols {
|
||||
@@ -182,15 +224,6 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
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.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);
|
||||
@@ -198,15 +231,34 @@ 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, band);
|
||||
let first_col = t.props.look.first_column && g == 0;
|
||||
let last_col = t.props.look.last_column && g + span >= ncols && ncols > 1;
|
||||
let first_cols = usize::from(t.props.look.first_column);
|
||||
let col_band = t.props.look.banded_columns && !first_col && g.saturating_sub(first_cols).is_multiple_of(2);
|
||||
let region = (is_header, is_total, first_col, last_col, band, col_band);
|
||||
// Conditional formatting, later regions winning (ECMA-376 §17.7.6): whole table,
|
||||
// column bands, row bands, first/last column, header/total row.
|
||||
if let Some(p) = parts {
|
||||
if is_header {
|
||||
fill = fill.or(p.header_fill);
|
||||
}
|
||||
if band && fill.is_none() {
|
||||
fill = p.band_fill;
|
||||
}
|
||||
fill = fill.or(p.fill);
|
||||
let row_fill = if is_header {
|
||||
p.header_fill
|
||||
} else if is_total {
|
||||
p.total_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let col_fill = if first_col {
|
||||
p.first_col_fill
|
||||
} else if last_col {
|
||||
p.last_col_fill
|
||||
} else {
|
||||
None
|
||||
};
|
||||
fill = fill
|
||||
.or(row_fill)
|
||||
.or(col_fill)
|
||||
.or(if band { p.band_fill } else { None })
|
||||
.or(if col_band { p.col_band_fill } else { None })
|
||||
.or(p.fill);
|
||||
}
|
||||
if let Some(st) = &style
|
||||
&& !cell_text.iter().any(|(r, _)| *r == region)
|
||||
@@ -214,12 +266,18 @@ 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 col_band {
|
||||
chr.overlay(&st.parts.col_band_chr);
|
||||
}
|
||||
if band {
|
||||
chr.overlay(&st.parts.band_chr);
|
||||
}
|
||||
if region.2 {
|
||||
if first_col {
|
||||
chr.overlay(&st.parts.first_col_chr);
|
||||
}
|
||||
if last_col {
|
||||
chr.overlay(&st.parts.last_col_chr);
|
||||
}
|
||||
if is_header {
|
||||
chr.overlay(&st.parts.header_chr);
|
||||
} else if is_total {
|
||||
@@ -236,8 +294,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
let edge =
|
||||
|own: Option<Border>, outer: bool, outer_b: Option<Border>, inner_b: Option<Border>| 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 rb = parts
|
||||
.map(|p| region_borders(p, (ri, nrows), (first_g, last_g), [is_header, is_total, first_col, last_col, band, col_band]))
|
||||
.unwrap_or_default();
|
||||
let mut borders = Borders {
|
||||
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),
|
||||
@@ -246,7 +306,10 @@ pub fn layout_table(ctx: &mut Ctx, story: StoryRef, t: &Table, path: &[u32], ava
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
if is_total && let Some(b) = parts.and_then(|p| p.total_border_top) {
|
||||
if is_total
|
||||
&& parts.is_none_or(|p| p.total_borders.is_none_or(|b| b.top.is_none()))
|
||||
&& let Some(b) = parts.and_then(|p| p.total_border_top)
|
||||
{
|
||||
borders.top = Some(b);
|
||||
}
|
||||
// Word keeps the cell's text clear of its top border (and the last row's of its bottom
|
||||
|
||||
+159
-30
@@ -108,13 +108,13 @@ pub enum Dialog {
|
||||
name: String,
|
||||
/// Style id.
|
||||
based_on: String,
|
||||
/// Index into `regions`: whole table, header row, banded rows.
|
||||
/// Index into `regions` (see [`REGION_LABELS`]).
|
||||
region: usize,
|
||||
regions: Box<[TableRegion; 3]>,
|
||||
regions: Box<[TableRegion; 7]>,
|
||||
/// 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]>,
|
||||
basis: Box<[TableRegion; 7]>,
|
||||
},
|
||||
Commands {
|
||||
query: String,
|
||||
@@ -236,8 +236,11 @@ pub struct TableRegion {
|
||||
pub color: String,
|
||||
}
|
||||
|
||||
/// The Table Style dialog's regions, in the order of `table_style::REGIONS` (the param names).
|
||||
const REGION_LABELS: [&str; 7] = ["Whole Table", "Header Row", "Total Row", "First Column", "Last Column", "Banded Rows", "Banded Columns"];
|
||||
|
||||
/// The regions of table style `id` as resolved through its based-on chain.
|
||||
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
|
||||
fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 7]> {
|
||||
let Some(st) = app.session.doc.styles.table_style(id) else { return Default::default() };
|
||||
let p = &st.parts;
|
||||
let hex = |c: Option<wordcraft_doc::Rgb>| c.map(|c| c.hex()).unwrap_or_default();
|
||||
@@ -246,17 +249,136 @@ fn table_regions(app: &WordApp, id: &str) -> Box<[TableRegion; 3]> {
|
||||
_ => String::new(),
|
||||
};
|
||||
let lines = |b: Option<wordcraft_doc::props::Borders>| 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);
|
||||
// A region's text: the whole table's formatting with the region's over it.
|
||||
let region = |borders: Option<wordcraft_doc::props::Borders>, fill: Option<wordcraft_doc::Rgb>, chr: &wordcraft_doc::CharProps| {
|
||||
let mut c = st.chr.clone();
|
||||
c.overlay(chr);
|
||||
TableRegion { borders: lines(borders), fill: hex(fill), bold: c.bold.unwrap_or(false), color: text(&c) }
|
||||
};
|
||||
let none = wordcraft_doc::CharProps::default();
|
||||
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) },
|
||||
region(p.borders, p.fill, &none),
|
||||
region(p.header_borders, p.header_fill, &p.header_chr),
|
||||
region(p.total_borders, p.total_fill, &p.total_chr),
|
||||
region(p.first_col_borders, p.first_col_fill, &p.first_col_chr),
|
||||
region(p.last_col_borders, p.last_col_fill, &p.last_col_chr),
|
||||
region(p.band_borders, p.band_fill, &p.band_chr),
|
||||
region(p.col_band_borders, p.col_band_fill, &p.col_band_chr),
|
||||
])
|
||||
}
|
||||
|
||||
/// The params `table.newStyle` / `table.modifyStyle` take for the Table Style dialog's state:
|
||||
/// only what changed from `basis`, so the rest stays inherited.
|
||||
fn table_style_params(id: Option<&str>, name: &str, based_on: &str, regions: &[TableRegion; 7], basis: &[TableRegion; 7]) -> Value {
|
||||
let mut v = json!({"name": name.trim(), "basedOn": based_on});
|
||||
for ((key, r), b) in wordcraft_engine::cmd::table_style::REGIONS.into_iter().zip(regions.iter()).zip(basis.iter()) {
|
||||
let ch = region_changes(r, b);
|
||||
if ch.as_object().is_some_and(|o| !o.is_empty()) {
|
||||
v[key] = ch;
|
||||
}
|
||||
}
|
||||
if let Some(id) = id {
|
||||
v["style"] = json!(id);
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// A small sample table drawn with `style` (5 columns, a header, three body rows and a total
|
||||
/// row), showing the regions `look` turns on: the Table Style dialog's live preview.
|
||||
fn table_style_preview(ui: &mut Ui, style: Option<&wordcraft_doc::styles::TableStyleProps>, look: wordcraft_doc::props::TableLook) {
|
||||
use wordcraft_doc::props::{Border, Borders};
|
||||
const COLS: usize = 5;
|
||||
const ROWS: usize = 5;
|
||||
const TEXT: [[&str; COLS]; ROWS] = [
|
||||
["", "Mon", "Tue", "Wed", "Sum"],
|
||||
["North", "4", "7", "2", "13"],
|
||||
["South", "6", "1", "5", "12"],
|
||||
["West", "3", "8", "4", "15"],
|
||||
["Total", "13", "16", "11", "40"],
|
||||
];
|
||||
let (rect, _) = ui.allocate_exact_size(vec2(300.0, 120.0), Sense::hover());
|
||||
let painter = ui.painter_at(rect);
|
||||
// The page is white whatever the interface theme.
|
||||
painter.rect_filled(rect, 2.0, egui::Color32::WHITE);
|
||||
let t = Tokens::get(ui.ctx());
|
||||
painter.rect_stroke(rect, 2.0, egui::Stroke::new(1.0, t.border), egui::StrokeKind::Inside);
|
||||
let Some(st) = style else { return };
|
||||
let p = &st.parts;
|
||||
let table = rect.shrink2(vec2(14.0, 10.0));
|
||||
let (cw, rh) = (table.width() / COLS as f32, table.height() / ROWS as f32);
|
||||
let rgb = |c: wordcraft_doc::Rgb| egui::Color32::from_rgb(c.0, c.1, c.2);
|
||||
let tb = p.borders.unwrap_or_default();
|
||||
for (ri, row) in TEXT.iter().enumerate() {
|
||||
let header = look.header_row && ri == 0;
|
||||
let total = look.total_row && ri + 1 == ROWS;
|
||||
let band = look.banded_rows
|
||||
&& !header
|
||||
&& (ri.saturating_sub(usize::from(look.header_row)) / p.band_size.unwrap_or(1).clamp(1, 1000) as usize).is_multiple_of(2);
|
||||
for (ci, txt) in row.iter().enumerate() {
|
||||
let first = look.first_column && ci == 0;
|
||||
let last = look.last_column && ci + 1 == COLS;
|
||||
let col_band = look.banded_columns && !first && ci.saturating_sub(usize::from(look.first_column)).is_multiple_of(2);
|
||||
let cell = egui::Rect::from_min_size(table.min + vec2(ci as f32 * cw, ri as f32 * rh), vec2(cw, rh));
|
||||
// Regions from lowest to highest priority, as layout applies them.
|
||||
let regions = [
|
||||
(col_band, p.col_band_fill, &p.col_band_chr, p.col_band_borders),
|
||||
(band, p.band_fill, &p.band_chr, p.band_borders),
|
||||
(first, p.first_col_fill, &p.first_col_chr, p.first_col_borders),
|
||||
(last, p.last_col_fill, &p.last_col_chr, p.last_col_borders),
|
||||
(header, p.header_fill, &p.header_chr, p.header_borders),
|
||||
(total, p.total_fill, &p.total_chr, p.total_borders),
|
||||
];
|
||||
let mut fill = p.fill;
|
||||
let mut chr = st.chr.clone();
|
||||
let mut b = Borders {
|
||||
top: if ri == 0 { tb.top } else { tb.between },
|
||||
bottom: if ri + 1 == ROWS { tb.bottom } else { tb.between },
|
||||
left: if ci == 0 { tb.left } else { tb.inside_v },
|
||||
right: if ci + 1 == COLS { tb.right } else { tb.inside_v },
|
||||
between: None,
|
||||
inside_v: None,
|
||||
};
|
||||
for (on, f, c, rb) in regions {
|
||||
if !on {
|
||||
continue;
|
||||
}
|
||||
fill = f.or(fill);
|
||||
chr.overlay(c);
|
||||
if let Some(rb) = rb {
|
||||
b.overlay(&Borders { between: None, inside_v: None, ..rb });
|
||||
}
|
||||
}
|
||||
if total && p.total_borders.is_none_or(|x| x.top.is_none()) {
|
||||
b.top = p.total_border_top.or(b.top);
|
||||
}
|
||||
if let Some(f) = fill {
|
||||
painter.rect_filled(cell, 0.0, rgb(f));
|
||||
}
|
||||
let edge = |from: egui::Pos2, to: egui::Pos2, e: Option<Border>| {
|
||||
if let Some(e) = e.filter(Border::is_visible) {
|
||||
let color = e.color.map_or(egui::Color32::BLACK, rgb);
|
||||
painter.line_segment([from, to], egui::Stroke::new(e.width.clamp(0.5, 3.0) * 1.3, color));
|
||||
}
|
||||
};
|
||||
edge(cell.left_top(), cell.right_top(), b.top);
|
||||
edge(cell.left_bottom(), cell.right_bottom(), b.bottom);
|
||||
edge(cell.left_top(), cell.left_bottom(), b.left);
|
||||
edge(cell.right_top(), cell.right_bottom(), b.right);
|
||||
let color = match chr.color {
|
||||
Some(wordcraft_doc::TextColor::Rgb(c)) => rgb(c),
|
||||
_ => egui::Color32::BLACK,
|
||||
};
|
||||
let font = egui::FontId::proportional(11.0);
|
||||
let pos = cell.left_center() + vec2(4.0, 0.0);
|
||||
painter.text(pos, egui::Align2::LEFT_CENTER, *txt, font.clone(), color);
|
||||
if chr.bold.unwrap_or(false) {
|
||||
// A second pass a hair to the right reads as bold at this size.
|
||||
painter.text(pos + vec2(0.6, 0.0), egui::Align2::LEFT_CENTER, *txt, font, color);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The style of the table at the caret, when it is a table style.
|
||||
fn current_table_style(app: &WordApp) -> Option<String> {
|
||||
let s = &app.session;
|
||||
@@ -1027,12 +1149,11 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
});
|
||||
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")))
|
||||
.selected_text(tl!(REGION_LABELS.get(*region).copied().unwrap_or("Whole Table")))
|
||||
.show_ui(ui, |ui| {
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
for (i, n) in REGION_LABELS.iter().enumerate() {
|
||||
ui.selectable_value(region, i, tl!(n));
|
||||
}
|
||||
});
|
||||
@@ -1059,24 +1180,32 @@ fn body(app: &mut WordApp, ui: &mut Ui, d: &mut Dialog) -> bool {
|
||||
ui.end_row();
|
||||
});
|
||||
}
|
||||
// Live preview: the style as OK would leave it, on the current table's options with
|
||||
// the region being edited turned on.
|
||||
let v = table_style_params(id.as_deref(), name, based_on, regions, basis);
|
||||
let mut look = app
|
||||
.session
|
||||
.sel
|
||||
.focus
|
||||
.path
|
||||
.cell()
|
||||
.and_then(|(tp, _, _)| app.session.doc.table(app.session.sel.focus.story, &tp).map(|t| t.props.look))
|
||||
.unwrap_or_default();
|
||||
match *region {
|
||||
1 => look.header_row = true,
|
||||
2 => look.total_row = true,
|
||||
3 => look.first_column = true,
|
||||
4 => look.last_column = true,
|
||||
5 => look.banded_rows = true,
|
||||
6 => look.banded_columns = true,
|
||||
_ => {}
|
||||
}
|
||||
ui.add_space(4.0);
|
||||
ui.label(egui::RichText::new(tl!("Preview")).small().weak());
|
||||
table_style_preview(ui, wordcraft_engine::cmd::table_style::preview(&app.session, &v).as_ref(), look);
|
||||
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",
|
||||
};
|
||||
let cmd = if id.is_some() { "table.modifyStyle" } else { "table.newStyle" };
|
||||
if let Err(e) = app.run(cmd, v) {
|
||||
app.status(e);
|
||||
return false;
|
||||
|
||||
@@ -955,3 +955,5 @@
|
||||
Apply formatting to: Aplicar formato a:
|
||||
Whole Table Toda la tabla
|
||||
Fill color: Color de relleno:
|
||||
Delete Table Style Eliminar estilo de tabla
|
||||
Preview Vista previa
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -938,3 +938,5 @@
|
||||
Apply formatting to: 書式の適用先:
|
||||
Whole Table 表全体
|
||||
Fill color: 塗りつぶしの色:
|
||||
Delete Table Style 表スタイルの削除
|
||||
Preview プレビュー
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 411 and column 13.
|
@@ -950,3 +950,5 @@
|
||||
Apply formatting to: Aplicar formatação a:
|
||||
Whole Table Tabela inteira
|
||||
Fill color: Cor de preenchimento:
|
||||
Delete Table Style Excluir estilo de tabela
|
||||
Preview Visualização
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -950,3 +950,5 @@
|
||||
Apply formatting to: Застосувати форматування до:
|
||||
Whole Table Уся таблиця
|
||||
Fill color: Колір заливки:
|
||||
Delete Table Style Видалити стиль таблиці
|
||||
Preview Попередній перегляд
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 31.
|
@@ -949,3 +949,5 @@
|
||||
Apply formatting to: 将格式应用于:
|
||||
Whole Table 整个表格
|
||||
Fill color: 填充颜色:
|
||||
Delete Table Style 删除表格样式
|
||||
Preview 预览
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 6 and column 25.
|
@@ -939,3 +939,5 @@
|
||||
Apply formatting to: 將格式套用至:
|
||||
Whole Table 整個表格
|
||||
Fill color: 填滿色彩:
|
||||
Delete Table Style 刪除表格樣式
|
||||
Preview 預覽
|
||||
|
||||
|
Can't render this file because it contains an unexpected character in line 412 and column 13.
|
@@ -1233,18 +1233,20 @@ fn table_design(app: &mut WordApp, ui: &mut Ui) {
|
||||
});
|
||||
});
|
||||
group(ui, "Table Styles", None, app, |ui, app| {
|
||||
let styles: Vec<(String, String)> = app
|
||||
// The document's own styles first, so a new one shows without scrolling.
|
||||
let mut styles: Vec<(bool, String, String)> = app
|
||||
.session
|
||||
.doc
|
||||
.styles
|
||||
.styles
|
||||
.iter()
|
||||
.filter(|s| s.kind == wordcraft_doc::StyleKind::Table && !s.hidden)
|
||||
.map(|s| (s.id.clone(), s.name.clone()))
|
||||
.map(|s| (s.builtin, s.id.clone(), s.name.clone()))
|
||||
.collect();
|
||||
styles.sort_by_key(|(builtin, _, _)| *builtin);
|
||||
egui::ScrollArea::horizontal().max_width(420.0).show(ui, |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
for (id, name) in styles {
|
||||
for (_, id, name) in styles {
|
||||
if crate::previews::table_style_tile(ui, app, &id).on_hover_text(name).clicked() {
|
||||
let _ = app.run("table.style", json!({"style": id}));
|
||||
}
|
||||
@@ -1252,21 +1254,26 @@ 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();
|
||||
let current = look.and_then(|_| {
|
||||
let (tp, _, _) = app.session.sel.focus.path.cell()?;
|
||||
let id = app.session.doc.table(app.session.sel.focus.story, &tp)?.props.style.clone()?;
|
||||
app.session.doc.styles.get(&id).filter(|st| st.kind == wordcraft_doc::StyleKind::Table).map(|st| st.builtin)
|
||||
});
|
||||
let can_modify = current.is_some();
|
||||
// Built-in table styles can't be deleted.
|
||||
let can_delete = current == Some(false);
|
||||
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();
|
||||
}
|
||||
if ui.add_enabled(can_delete, egui::Button::new(tl!("Delete Table Style")).min_size(vec2(200.0, 0.0))).clicked() {
|
||||
if let Err(e) = app.run("table.deleteStyle", json!({})) {
|
||||
app.status(e);
|
||||
}
|
||||
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}));
|
||||
|
||||
+2
-2
@@ -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.
|
||||
|
||||
**380 of 430 catalog features have commands (88.4%).**
|
||||
**381 of 431 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 | 7 | 7 |
|
||||
| Table Design | 8 | 8 |
|
||||
| Table Layout | 28 | 30 |
|
||||
| Equation | 21 | 22 |
|
||||
| Picture Format | 13 | 13 |
|
||||
|
||||
Reference in New Issue
Block a user