From e433c77b55cc05e10290bb315e43528984f7dc6f Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 16:40:46 -0400 Subject: [PATCH] S: table and cell styles (style.cell.*, style.table.*), Table panel pickers Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/styles.rs | 121 +++++++++++ crates/doc/src/table.rs | 16 +- crates/engine/src/cmd/style.rs | 2 + crates/engine/src/cmd/table.rs | 319 ++++++++++++++++++++++++++++- crates/ui-egui/src/panels/table.rs | 27 +++ docs/parity.md | 10 +- 7 files changed, 488 insertions(+), 8 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 0ac66b8d3..822d986fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -30,6 +30,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Edit › Paste Into (copied objects become a frame's content, clipped by it; rendered, PDF and IDML both ways — verified in InDesign 2026); Polygon Settings (sides, star inset; double-click the Polygon tool). - Color panel (fill/stroke proxy, CMYK/RGB/Lab sliders with channel ramps, tint slider for swatches, spectrum ramp, Add to Swatches) and Color Picker (double-click the proxy); mixed colours are unnamed (not listed in Swatches until added); `object.color`, `swatch.addToSwatches`, `swatch.addUnnamed`. - Place text files: Word (.docx: styles by name with their attributes, bold/italic/underline/size/font, footnotes, tables, tabs, breaks), RTF and plain text, into the insertion point, the selected frame or a new frame; autoflow adds pages and threaded frames until the text fits; Remove Styles option (`designcraft-textimport`). +- Table and cell styles: cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. IDML mapping is still to come. - Style groups: paragraph and character styles shown in group folders, Move to Group (new, existing, none); `style.group` renames every use (styles, stories, table cells, footnotes, object styles). - Scrubby zoom: drag the Zoom tool left or right to zoom continuously around the press point. - Guides belong to the active layer: hidden with it (or with its Show Guides off) and locked with it. diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 21eb4fc89..463c91191 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -63,6 +63,11 @@ pub struct Styles { pub paragraph: Vec, pub character: Vec, pub object: Vec, + /// Cell and table styles (Table › Cell Styles / Table Styles). + #[serde(default)] + pub cell: Vec, + #[serde(default)] + pub table: Vec, /// Default styles for new text/frames (the style selected with nothing selected). pub default_paragraph: String, pub default_character: String, @@ -103,6 +108,8 @@ impl Default for Styles { ..Default::default() }, ], + cell: vec![CellStyle { name: NO_CELL_STYLE.into(), ..Default::default() }], + table: vec![TableStyle { name: BASIC_TABLE.into(), ..Default::default() }], default_paragraph: BASIC_PARAGRAPH.into(), default_character: NO_CHAR_STYLE.into(), default_text_frame: BASIC_TEXT_FRAME.into(), @@ -111,6 +118,120 @@ impl Default for Styles { } } +pub const NO_CELL_STYLE: &str = "[None]"; +pub const BASIC_TABLE: &str = "[Basic Table]"; + +/// A cell style: the cell attributes it sets (unset = left as is) and a paragraph style for the +/// cell's text. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct CellStyle { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub fill: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub fill_tint: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub insets: Option<[f64; 4]>, + #[serde(skip_serializing_if = "Option::is_none")] + pub vj: Option, + /// All four edges. + #[serde(skip_serializing_if = "Option::is_none")] + pub stroke: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub paragraph_style: Option, +} + +/// A table style: cell styles per region, the border, alternating row fills and spacing. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", default)] +pub struct TableStyle { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub header: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub body: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub footer: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub left_column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub right_column: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub border: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alt_rows: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_before: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub space_after: Option, +} + +impl CellStyle { + /// Write this style's attributes into `cell`. + pub fn apply_to(&self, cell: &mut crate::table::Cell) { + if let Some(v) = &self.fill { + cell.fill = v.clone(); + } + if let Some(v) = self.fill_tint { + cell.fill_tint = v; + } + if let Some(v) = self.insets { + cell.insets = v; + } + if let Some(v) = self.vj { + cell.vj = v; + } + if let Some(v) = &self.stroke { + cell.strokes = [v.clone(), v.clone(), v.clone(), v.clone()]; + } + if let Some(ps) = &self.paragraph_style { + for p in &mut cell.text.paras { + p.style = ps.clone(); + } + cell.text.rev += 1; + } + cell.style = if self.name == NO_CELL_STYLE { String::new() } else { self.name.clone() }; + } +} + +impl TableStyle { + /// Write this style into `t`: options, then each cell's region style. + pub fn apply_to(&self, t: &mut crate::table::Table, cells: &[CellStyle]) { + if let Some(b) = &self.border { + t.options.border = b.clone(); + } + if self.alt_rows.is_some() { + t.options.alt_rows = self.alt_rows.clone(); + } + if let Some(v) = self.space_before { + t.options.space_before = v; + } + if let Some(v) = self.space_after { + t.options.space_after = v; + } + let (nr, nc) = (t.rows.len(), t.columns.len()); + for r in 0..nr { + let kind = t.rows[r].kind; + for c in 0..nc { + let region = match kind { + crate::table::RowKind::Header => self.header.as_ref(), + crate::table::RowKind::Footer => self.footer.as_ref(), + _ if c == 0 && self.left_column.is_some() => self.left_column.as_ref(), + _ if c + 1 == nc && self.right_column.is_some() => self.right_column.as_ref(), + _ => self.body.as_ref(), + }; + if let (Some(name), Some(cell)) = (region, t.cells.get_mut(r * nc + c)) + && let Some(cs) = cells.iter().find(|s| s.name == *name) + { + cs.apply_to(cell); + } + } + } + t.style = if self.name == BASIC_TABLE { String::new() } else { self.name.clone() }; + } +} + const MAX_DEPTH: usize = 32; impl Styles { diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index d203aaed1..d80260759 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -116,6 +116,9 @@ pub struct Cell { /// Edge strokes: top, left, bottom, right. #[serde(default)] pub strokes: [CellStroke; 4], + /// Applied cell style ("" = [None]). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style: String, } impl Default for Cell { @@ -130,6 +133,7 @@ impl Default for Cell { vj: VerticalJustification::Top, rotation: 0.0, strokes: Default::default(), + style: String::new(), } } } @@ -236,6 +240,9 @@ pub struct Table { pub cells: Vec, #[serde(default)] pub options: TableOptions, + /// Applied table style ("" = [Basic Table]). + #[serde(default, skip_serializing_if = "String::is_empty")] + pub style: String, } /// A rectangular range of cells (inclusive). @@ -270,7 +277,14 @@ impl Table { rows.extend((0..footer).map(|_| Row { kind: RowKind::Footer, ..Default::default() })); let w = (width / cols as f64).max(3.0); let n = rows.len() * cols; - Table { id, rows, columns: vec![Column { width: w }; cols], cells: vec![Cell::default(); n], options: TableOptions::default() } + Table { + id, + rows, + columns: vec![Column { width: w }; cols], + cells: vec![Cell::default(); n], + options: TableOptions::default(), + style: String::new(), + } } /// A table from rows of cell strings (Convert Text to Table). Short rows are padded. diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index be98950cb..6440dd5d8 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -38,6 +38,8 @@ pub fn specs() -> Vec { "paragraph": st.doc.styles.paragraph.iter().map(|p| &p.name).collect::>(), "character": st.doc.styles.character.iter().map(|p| &p.name).collect::>(), "object": st.doc.styles.object.iter().map(|p| &p.name).collect::>(), + "cell": st.doc.styles.cell.iter().map(|p| &p.name).collect::>(), + "table": st.doc.styles.table.iter().map(|p| &p.name).collect::>(), })) }), cmd!("style.object.apply", "Apply Object Style", [], None, "{name, ids?}", super::has_selection, apply_object), diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index 2d51c0845..f4393f0ab 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -17,6 +17,63 @@ use super::{CommandSpec, bad, cmd, f64_or, has_doc, has_text, ok, str_param}; use crate::{Result, Session}; pub fn specs() -> Vec { + let mut v = style_specs(); + v.extend(table_specs()); + v +} + +fn style_specs() -> Vec { + vec![ + cmd!( + "style.cell.create", + "New Cell Style…", + [], + None, + "{name, fromSelection?: bool (the target cell's look), fill?, tint?, insets?: n|[t,l,b,r], vj?: top|center|bottom|justify, stroke?: {weight, color, tint?}, paragraphStyle?} → {name}", + has_doc, + |s, p| cell_style_create(s, p) + ), + cmd!( + "style.cell.apply", + "Apply Cell Style", + [], + None, + "{name, story?, table?} — the target cells (the whole table when the cursor is in it)", + in_table, + cell_style_apply + ), + cmd!( + "style.cell.edit", + "Cell Style Options…", + [], + None, + "{name, …same as style.cell.create} — cells using the style follow", + has_doc, + cell_style_edit + ), + cmd!( + "style.table.create", + "New Table Style…", + [], + None, + "{name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight, color}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", + has_doc, + table_style_create + ), + cmd!("style.table.apply", "Apply Table Style", [], None, "{name, story?, table?}", in_table, table_style_apply), + cmd!( + "style.table.edit", + "Table Style Options…", + [], + None, + "{name, …same as style.table.create} — tables using the style follow", + has_doc, + table_style_edit + ), + ] +} + +fn table_specs() -> Vec { vec![ cmd!( "table.insert", @@ -730,12 +787,270 @@ fn get(s: &mut Session, p: &Value) -> Result { .map(|(r, c)| { let cell = t.cell(r, c).expect("in range"); json!({"row": r, "col": c, "rowSpan": cell.row_span, "colSpan": cell.col_span, "text": cell.text.text, "fill": cell.fill, - "insets": cell.insets, "vj": cell.vj}) + "insets": cell.insets, "vj": cell.vj, "style": cell.style}) }) .collect(); Ok(json!({ "story": g.story.0, "table": g.table, "anchor": st.doc.story(g.story).and_then(|x| x.table_anchor(g.table)), "rows": t.rows, "columns": t.columns, "headerRows": t.header_rows(), "footerRows": t.footer_rows(), - "cells": cells, "options": t.options, "range": g.range, + "cells": cells, "options": t.options, "range": g.range, "style": t.style, })) } + +// ---------- cell and table styles ---------- + +fn stroke_param(v: Option<&Value>) -> Option { + let o = v?.as_object()?; + let mut st = designcraft_doc::CellStroke::default(); + if let Some(w) = o.get("weight").and_then(Value::as_f64) { + st.weight = w.max(0.0); + } + if let Some(c) = o.get("color").and_then(Value::as_str) { + st.color = c.to_string(); + } + if let Some(t) = o.get("tint").and_then(Value::as_f64) { + st.tint = t.clamp(0.0, 1.0) as f32; + } + Some(st) +} + +/// Fill in the cell style fields given in `p`. +fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) -> Result<()> { + if let Some(f) = color_param(p, "fill") { + cs.fill = Some(f); + } + if let Some(t) = p.get("tint").and_then(Value::as_f64) { + cs.fill_tint = Some(t.clamp(0.0, 1.0) as f32); + } + match p.get("insets") { + Some(Value::Number(n)) => cs.insets = n.as_f64().map(|v| [v.max(0.0); 4]), + Some(Value::Array(a)) if a.len() == 4 => { + let v: Vec = a.iter().map(|x| x.as_f64().unwrap_or(0.0).max(0.0)).collect(); + cs.insets = Some([v[0], v[1], v[2], v[3]]); + } + _ => {} + } + if let Some(v) = p.get("vj") { + cs.vj = Some(serde_json::from_value(v.clone()).map_err(|e| bad(cmd, format!("vj: {e}")))?); + } + if let Some(st) = stroke_param(p.get("stroke")) { + cs.stroke = Some(st); + } + if let Some(ps) = str_param(p, "paragraphStyle") { + cs.paragraph_style = Some(ps.to_string()); + } + Ok(()) +} + +fn cell_style_create(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Cell Style 1").to_string(); + let mut cs = designcraft_doc::CellStyle::default(); + if p.get("fromSelection").and_then(Value::as_bool).unwrap_or(false) { + let g = target(s, p, "style.cell.create")?; + let d = &s.doc()?.doc; + let cell = d + .story(g.story) + .and_then(|st| st.tables.get(&g.table)) + .and_then(|t| t.cell(g.range.r0, g.range.c0)) + .ok_or_else(|| bad("style.cell.create", "no cell"))?; + cs.fill = Some(cell.fill.clone()); + cs.fill_tint = Some(cell.fill_tint); + cs.insets = Some(cell.insets); + cs.vj = Some(cell.vj); + cs.stroke = Some(cell.strokes[0].clone()); + cs.paragraph_style = cell.text.paras.first().map(|p| p.style.clone()); + } + cell_style_fields(&mut cs, p, "style.cell.create")?; + s.edit(|d, _| { + let name = designcraft_doc::Styles::unique_name(|n| d.styles.cell.iter().any(|c| c.name == n), &base); + cs.name = name.clone(); + d.styles_mut().cell.push(cs); + Ok(json!({"name": name})) + }) +} + +fn cell_style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.cell.apply", "missing name"))?.to_string(); + let cs = s + .doc()? + .doc + .styles + .cell + .iter() + .find(|c| c.name == name) + .cloned() + .ok_or_else(|| bad("style.cell.apply", format!("no cell style `{name}`")))?; + let g = target(s, p, "style.cell.apply")?; + edit_table(s, &g, "style.cell.apply", |t| { + let owners = t.owners(); + let nc = t.ncols(); + let mut n = 0; + for r in g.range.r0..=g.range.r1 { + for c in g.range.c0..=g.range.c1 { + if owners[r * nc + c] == (r, c) + && let Some(cell) = t.cell_mut(r, c) + { + cs.apply_to(cell); + n += 1; + } + } + } + Ok(json!({"cells": n})) + }) +} + +/// Re-apply cell styles named in `names` to every cell using them (after a style edit). +fn reapply_cell_styles(d: &mut Document, names: &[String]) { + let styles = d.styles.cell.clone(); + for sid in d.stories.keys().copied().collect::>() { + let Some(st) = d.story_mut(sid) else { continue }; + for t in st.tables.values_mut() { + if !t.cells.iter().any(|c| names.contains(&c.style)) { + continue; + } + for cell in &mut std::sync::Arc::make_mut(t).cells { + if names.contains(&cell.style) + && let Some(cs) = styles.iter().find(|x| x.name == cell.style) + { + cs.apply_to(cell); + } + } + } + st.rev += 1; + } +} + +fn cell_style_edit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); + let p = p.clone(); + s.edit(|d, _| { + let cs = d.styles_mut().cell.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.cell.edit", format!("no cell style `{name}`")))?; + cell_style_fields(cs, &p, "style.cell.edit")?; + reapply_cell_styles(d, std::slice::from_ref(&name)); + ok() + }) +} + +fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { + for (k, f) in [ + ("header", &mut ts.header), + ("body", &mut ts.body), + ("footer", &mut ts.footer), + ("leftColumn", &mut ts.left_column), + ("rightColumn", &mut ts.right_column), + ] { + if let Some(v) = p.get(k) { + *f = v.as_str().map(str::to_string); + } + } + if let Some(b) = stroke_param(p.get("border")) { + ts.border = Some(b); + } + if let Some(a) = p.get("altRows").and_then(Value::as_object) { + let n = |k: &str, d: u32| a.get(k).and_then(Value::as_u64).map_or(d, |v| v as u32); + let c = |k: &str| a.get(k).and_then(Value::as_str).unwrap_or(designcraft_color::swatch::NONE).to_string(); + let t = |k: &str| a.get(k).and_then(Value::as_f64).map_or(1.0, |v| v.clamp(0.0, 1.0) as f32); + ts.alt_rows = Some(designcraft_doc::AltFills { + first: n("first", 1), + first_color: c("firstColor"), + first_tint: t("firstTint"), + next: n("next", 1), + next_color: c("nextColor"), + next_tint: t("nextTint"), + skip_first: n("skipFirst", 0), + skip_last: n("skipLast", 0), + }); + } + if let Some(v) = p.get("spaceBefore").and_then(Value::as_f64) { + ts.space_before = Some(v); + } + if let Some(v) = p.get("spaceAfter").and_then(Value::as_f64) { + ts.space_after = Some(v); + } +} + +fn table_style_create(s: &mut Session, p: &Value) -> Result { + let base = str_param(p, "name").unwrap_or("Table Style 1").to_string(); + let mut ts = designcraft_doc::TableStyle::default(); + table_style_fields(&mut ts, p); + s.edit(|d, _| { + let name = designcraft_doc::Styles::unique_name(|n| d.styles.table.iter().any(|c| c.name == n), &base); + ts.name = name.clone(); + d.styles_mut().table.push(ts); + Ok(json!({"name": name})) + }) +} + +fn table_style_apply(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.table.apply", "missing name"))?.to_string(); + let doc = &s.doc()?.doc; + let ts = doc.styles.table.iter().find(|c| c.name == name).cloned().ok_or_else(|| bad("style.table.apply", format!("no table style `{name}`")))?; + let cells = doc.styles.cell.clone(); + let g = target(s, p, "style.table.apply")?; + edit_table(s, &g, "style.table.apply", |t| { + ts.apply_to(t, &cells); + ok() + }) +} + +fn table_style_edit(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").ok_or_else(|| bad("style.table.edit", "missing name"))?.to_string(); + let p = p.clone(); + s.edit(|d, _| { + let ts = + d.styles_mut().table.iter_mut().find(|c| c.name == name).ok_or_else(|| bad("style.table.edit", format!("no table style `{name}`")))?; + table_style_fields(ts, &p); + let (ts, cells) = (ts.clone(), d.styles.cell.clone()); + for sid in d.stories.keys().copied().collect::>() { + let Some(st) = d.story_mut(sid) else { continue }; + for t in st.tables.values_mut() { + if t.style == name { + ts.apply_to(std::sync::Arc::make_mut(t), &cells); + } + } + st.rev += 1; + } + ok() + }) +} + +#[cfg(test)] +mod style_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn cell_and_table_styles_apply_and_follow_edits() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 500, 400], "content": "text", "text": ""})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("table.insert", &json!({"rows": 3, "cols": 2, "headerRows": 1})).unwrap(); + s.execute("style.paragraph.create", &json!({"name": "Cell Head"})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Head", "fill": "Black", "paragraphStyle": "Cell Head", "insets": 6})).unwrap(); + s.execute("style.cell.create", &json!({"name": "Body", "fill": "[Paper]"})).unwrap(); + s.execute("style.table.create", &json!({"name": "Data", "header": "Head", "body": "Body", "border": {"weight": 2, "color": "Black"}})) + .unwrap(); + s.execute("style.table.apply", &json!({"name": "Data"})).unwrap(); + let t = |s: &Session| -> designcraft_doc::Table { + let d = &s.doc().unwrap().doc; + (**d.story(designcraft_doc::StoryId(sid)).unwrap().tables.values().next().unwrap()).clone() + }; + let tb = t(&s); + assert_eq!(tb.style, "Data"); + assert_eq!(tb.options.border.weight, 2.0); + let head = tb.cell(0, 0).unwrap(); + assert_eq!((head.fill.as_str(), head.style.as_str(), head.insets), ("Black", "Head", [6.0; 4])); + assert_eq!(head.text.paras[0].style, "Cell Head"); + assert_eq!(tb.cell(1, 1).unwrap().style, "Body"); + // Editing the cell style updates its cells. + s.execute("style.cell.edit", &json!({"name": "Head", "fill": "C=100 M=0 Y=0 K=0"})).unwrap(); + assert_eq!(t(&s).cell(0, 1).unwrap().fill, "C=100 M=0 Y=0 K=0"); + // Editing the table style re-applies it. + s.execute("style.table.edit", &json!({"name": "Data", "border": {"weight": 4}})).unwrap(); + assert_eq!(t(&s).options.border.weight, 4.0); + assert!(s.execute("style.cell.apply", &json!({"name": "Nope"})).is_err()); + } +} diff --git a/crates/ui-egui/src/panels/table.rs b/crates/ui-egui/src/panels/table.rs index 1074df95e..e243aa729 100644 --- a/crates/ui-egui/src/panels/table.rs +++ b/crates/ui-egui/src/panels/table.rs @@ -28,6 +28,33 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { let run = |app: &mut DesignApp, id: &str, p: Value| { let _ = app.run(id, p); }; + // Table and cell styles: pick to apply, + to save the current look as a new style. + if let Some(st) = app.session.active() { + let styles = &st.doc.styles; + let tnames: Vec = styles.table.iter().map(|s| s.name.clone()).collect(); + let cnames: Vec = styles.cell.iter().map(|s| s.name.clone()).collect(); + let tcur = info["style"].as_str().filter(|s| !s.is_empty()).unwrap_or(designcraft_doc::BASIC_TABLE).to_string(); + let ccur = cell["style"].as_str().filter(|s| !s.is_empty()).unwrap_or(designcraft_doc::NO_CELL_STYLE).to_string(); + for (label, kind, names, cur) in [("Table Style", "table", tnames, tcur), ("Cell Style", "cell", cnames, ccur)] { + ui.horizontal(|ui| { + caption(ui, label); + egui::ComboBox::from_id_salt(("tp_style", kind)).selected_text(&cur).width(130.0).show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == cur, n).clicked() { + run(app, &format!("style.{kind}.apply"), json!({"name": n, "table": tid})); + } + } + }); + if crate::icons::button(ui, "plus", 20.0, false, &format!("New {label}")).clicked() { + if kind == "cell" { + run(app, "style.cell.create", json!({"name": "Cell Style 1", "fromSelection": true})); + } else { + run(app, "style.table.create", json!({"name": "Table Style 1"})); + } + } + }); + } + } egui::Grid::new("table_panel").num_columns(4).spacing([6.0, 6.0]).show(ui, |ui| { caption(ui, "Rows"); if let Some(v) = number(ui, "tp_rows", Some(rows.len() as f64), "", 52.0, 0) { diff --git a/docs/parity.md b/docs/parity.md index 8edf91cbc..10bda4bc7 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -10,7 +10,7 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Priority | Features | Done | Partial | Missing | Parity | |---|---|---|---|---|---| | P0 | 102 | 99 | 3 | 0 | 99% | -| P1 | 97 | 55 | 24 | 18 | 69% | +| P1 | 97 | 57 | 24 | 16 | 71% | | P2 | 76 | 8 | 7 | 61 | 15% | | Area | Parity | @@ -25,8 +25,8 @@ Each row of the InDesign feature catalogue is scored **D** (done), **P** (partia | Placing & links | 78% | | Type & text frames | 82% | | Typography | 83% | -| Styles | 82% | -| Tables | 73% | +| Styles | 92% | +| Tables | 81% | | Long documents | 58% | | Interactivity & digital | 30% | | Output & production | 58% | @@ -239,7 +239,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Styles | Paragraph styles | P0 | D | | Styles | Character styles | P0 | D | | Styles | Object styles | P1 | D | -| Styles | Table & cell styles | P1 | M | +| Styles | Table & cell styles | P1 | D | | Styles | Style groups | P1 | D | | Styles | Quick Apply | P1 | D | | Styles | Break link to style | P1 | D | @@ -253,7 +253,7 @@ Open P0 items: Preferences, Vector/page formats (PDF/AI/SVG/EPS place), Export t | Tables | Table options | P1 | D | | Tables | Cell options | P1 | D | | Tables | Graphic cells | P2 | M | -| Tables | Table styles & cell styles | P1 | M | +| Tables | Table styles & cell styles | P1 | D | | Tables | Table panel | P1 | D | | Tables | Overset cells | P1 | D | | Tables | Tables spanning frames | P1 | D |