diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index f936371a7..4699e1af1 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -169,11 +169,20 @@ pub struct CellStrokeAttrs { pub tint: Option, #[serde(skip_serializing_if = "Option::is_none")] pub kind: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap_color: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gap_tint: Option, } impl CellStrokeAttrs { pub fn is_empty(&self) -> bool { - self.weight.is_none() && self.color.is_none() && self.tint.is_none() && self.kind.is_none() + self.weight.is_none() + && self.color.is_none() + && self.tint.is_none() + && self.kind.is_none() + && self.gap_color.is_none() + && self.gap_tint.is_none() } pub fn apply_to(&self, stroke: &mut crate::table::CellStroke) { @@ -189,6 +198,12 @@ impl CellStrokeAttrs { if let Some(v) = &self.kind { stroke.kind = v.clone(); } + if let Some(v) = &self.gap_color { + stroke.gap_color = v.clone(); + } + if let Some(v) = self.gap_tint { + stroke.gap_tint = v; + } } fn merge(&mut self, other: &Self) { @@ -204,10 +219,23 @@ impl CellStrokeAttrs { if other.kind.is_some() { self.kind = other.kind.clone(); } + if other.gap_color.is_some() { + self.gap_color = other.gap_color.clone(); + } + if other.gap_tint.is_some() { + self.gap_tint = other.gap_tint; + } } fn from_stroke(stroke: &crate::table::CellStroke) -> Self { - Self { weight: Some(stroke.weight), color: Some(stroke.color.clone()), tint: Some(stroke.tint), kind: Some(stroke.kind.clone()) } + Self { + weight: Some(stroke.weight), + color: Some(stroke.color.clone()), + tint: Some(stroke.tint), + kind: Some(stroke.kind.clone()), + gap_color: Some(stroke.gap_color.clone()), + gap_tint: Some(stroke.gap_tint), + } } } diff --git a/crates/doc/src/table.rs b/crates/doc/src/table.rs index 7fe385e98..8752075c8 100644 --- a/crates/doc/src/table.rs +++ b/crates/doc/src/table.rs @@ -62,11 +62,30 @@ pub struct CellStroke { pub color: String, pub tint: f32, pub kind: StrokeType, + /// Painted in the gaps of dashed, dotted and striped types. + #[serde(skip_serializing_if = "is_no_swatch")] + pub gap_color: String, + #[serde(skip_serializing_if = "is_full")] + pub gap_tint: f32, +} + +fn is_no_swatch(s: &str) -> bool { + s == designcraft_color::swatch::NONE +} +fn is_full(t: &f32) -> bool { + *t == 1.0 } impl Default for CellStroke { fn default() -> Self { - CellStroke { weight: 1.0, color: designcraft_color::swatch::BLACK.into(), tint: 1.0, kind: StrokeType::Solid } + CellStroke { + weight: 1.0, + color: designcraft_color::swatch::BLACK.into(), + tint: 1.0, + kind: StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + } } } diff --git a/crates/engine/src/cmd/table.rs b/crates/engine/src/cmd/table.rs index cb286866f..787904474 100644 --- a/crates/engine/src/cmd/table.rs +++ b/crates/engine/src/cmd/table.rs @@ -29,7 +29,7 @@ fn style_specs() -> Vec { "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}", + "{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?, type?, gapColor?, gapTint?} (all four edges; values as table.setCell's stroke), paragraphStyle?} → {name}", has_doc, |s, p| cell_style_create(s, p) ), @@ -56,7 +56,7 @@ fn style_specs() -> Vec { "New Table Style…", [], None, - "{name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight, color}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", + "{name, header?, body?, footer?, leftColumn?, rightColumn?: cell style names, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, altRows?: {first, firstColor, next, nextColor}, spaceBefore?, spaceAfter?} → {name}", has_doc, table_style_create ), @@ -196,10 +196,19 @@ fn table_specs() -> Vec { "Cell Options", [], None, - "{fill?: swatch, tint?, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?, tint?, type?: solid|dashed|dotted, edges?: all|outer|inner|top|left|bottom|right}}", + "{fill?: swatch, tint?: 0..1, insets?: n | [t,l,b,r], vj?: top|center|bottom|justify, text?, stroke?: {weight?, color?: swatch, tint?: 0..1, type?: solid|dashed|dotted|thickThin|thinThick|thinThin|thickThick|thinThickThin|thickThinThick|wavy|hashed | {kind:…}, gapColor?: swatch, gapTint?: 0..1, edges?: name | [names] (all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical; default all)}} — top/left/bottom/right are the target range's outer sides, innerHorizontal/innerVertical the lines between its rows/columns", in_table, set_cell ), + cmd!( + query "table.getCellStroke", + "Get Cell Stroke", + [], + None, + "{story?, table?, rows?, cols?, edges?} → {weight, color, tint, type, gapColor, gapTint, fill, fillTint, edges: count} of the chosen edges of the target cells; a value that differs between them is null", + in_table_or_ids, + get_cell_stroke + ), cmd!("table.setRowHeight", "Row Height", [], None, "{height, mode?: atLeast|exactly}", in_table, set_row_height), cmd!("table.setColumnWidth", "Column Width", [], None, "{width}", in_table, set_col_width), cmd!("table.distributeColumns", "Distribute Columns Evenly", ["Table"], None, "{}", in_table, distribute_cols), @@ -217,7 +226,7 @@ fn table_specs() -> Vec { "Table Options", [], None, - "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}", + "{direction?: leftToRight|rightToLeft, border?: {weight?, color?, tint?, type?, gapColor?, gapTint?}, spaceBefore?, spaceAfter?, headerRows?, footerRows?, repeatHeader?, repeatFooter?, altRows?: {first, firstColor, firstTint, next, nextColor, nextTint, skipFirst, skipLast} | null, altCols?: … | null}", in_table, options ), @@ -606,29 +615,174 @@ fn unmerge(s: &mut Session, p: &Value) -> Result { }) } -fn parse_stroke(v: &Value, mut base: CellStroke) -> CellStroke { - if let Some(w) = v.get("weight").and_then(Value::as_f64) { - base.weight = w.max(0.0); +/// Cell edges chosen for a stroke edit: the target range's four outer sides (physical left and +/// right) and the lines between its rows and columns. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct EdgeSel { + top: bool, + left: bool, + bottom: bool, + right: bool, + inner_h: bool, + inner_v: bool, +} + +impl EdgeSel { + const ALL: EdgeSel = EdgeSel { top: true, left: true, bottom: true, right: true, inner_h: true, inner_v: true }; + + fn parse(v: Option<&Value>, cmd: &str) -> Result { + let names: Vec<&Value> = match v { + None | Some(Value::Null) => return Ok(EdgeSel::ALL), + Some(Value::Array(a)) => a.iter().collect(), + Some(v) => vec![v], + }; + let mut e = EdgeSel::default(); + for n in names { + match n.as_str() { + Some("all") => e = EdgeSel::ALL, + Some("outer") => (e.top, e.left, e.bottom, e.right) = (true, true, true, true), + Some("inner") => (e.inner_h, e.inner_v) = (true, true), + Some("top") => e.top = true, + Some("left") => e.left = true, + Some("bottom") => e.bottom = true, + Some("right") => e.right = true, + Some("innerHorizontal") => e.inner_h = true, + Some("innerVertical") => e.inner_v = true, + _ => return Err(bad(cmd, format!("unknown edge {n}; use all|outer|inner|top|left|bottom|right|innerHorizontal|innerVertical"))), + } + } + if e == EdgeSel::default() { + return Err(bad(cmd, "no edges chosen")); + } + Ok(e) } - if let Some(c) = color_param(v, "color") { - base.color = c; + + /// Which sides (top, left, bottom, right) of the cell at `(r, c)` with `spans` are chosen + /// within `rg`. Left and right are physical sides, also in RTL tables. + fn sides(&self, rg: CellRange, (r, c): (usize, usize), spans: (u32, u32), rtl: bool) -> [bool; 4] { + let last_row = r.saturating_add((spans.0.max(1) - 1) as usize); + let last_col = c.saturating_add((spans.1.max(1) - 1) as usize); + let (start, end) = (c <= rg.c0, last_col >= rg.c1); + let (left_outer, right_outer) = if rtl { (end, start) } else { (start, end) }; + [ + if r <= rg.r0 { self.top } else { self.inner_h }, + if left_outer { self.left } else { self.inner_v }, + if last_row >= rg.r1 { self.bottom } else { self.inner_h }, + if right_outer { self.right } else { self.inner_v }, + ] } - if let Some(t) = v.get("tint").and_then(Value::as_f64) { - base.tint = t.clamp(0.0, 1.0) as f32; +} + +/// A finite number in `lo..=hi`, or an error naming `key`. +fn num_in(v: &Value, key: &str, lo: f64, hi: f64, cmd: &str) -> Result> { + match v.get(key) { + None | Some(Value::Null) => Ok(None), + Some(x) => match x.as_f64() { + Some(n) if n.is_finite() && (lo..=hi).contains(&n) => Ok(Some(n)), + _ => Err(bad(cmd, format!("{key} must be a number from {lo} to {hi}"))), + }, } - match str_param(v, "type") { - Some("dashed") => base.kind = StrokeType::Dashed { pattern: vec![base.weight.max(1.0) * 3.0, base.weight.max(1.0) * 2.0] }, - Some("dotted") => base.kind = StrokeType::Dotted, - Some("solid") => base.kind = StrokeType::Solid, - _ => {} +} + +/// A swatch of the document (or [None]), or an error naming `key`. +fn swatch_in(doc: &Document, v: &Value, keys: &[&str], cmd: &str) -> Result> { + let Some((key, x)) = keys.iter().find_map(|k| v.get(*k).filter(|x| !x.is_null()).map(|x| (*k, x))) else { return Ok(None) }; + let name = x.as_str().ok_or_else(|| bad(cmd, format!("{key} must be a swatch name")))?; + if name != designcraft_color::swatch::NONE && doc.swatch(name).is_none() { + return Err(bad(cmd, format!("no swatch `{name}`"))); } - base + Ok(Some(name.to_string())) +} + +/// The cell stroke type names of `type`. +const CELL_STROKE_TYPES: &[&str] = + &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; + +/// The attributes a stroke edit sets. A plain `"dashed"` gets a pattern scaled to the +/// resulting weight of each edge. +struct StrokeEdit { + attrs: designcraft_doc::CellStrokeAttrs, + scaled_dashes: bool, +} + +impl StrokeEdit { + fn parse(doc: &Document, v: &Value, cmd: &str) -> Result { + if !v.is_object() { + return Err(bad(cmd, "stroke must be an object")); + } + let mut attrs = designcraft_doc::CellStrokeAttrs { + weight: num_in(v, "weight", 0.0, 1000.0, cmd)?, + color: swatch_in(doc, v, &["color"], cmd)?, + tint: num_in(v, "tint", 0.0, 1.0, cmd)?.map(|t| t as f32), + gap_color: swatch_in(doc, v, &["gapColor", "gapSwatch"], cmd)?, + gap_tint: num_in(v, "gapTint", 0.0, 1.0, cmd)?.map(|t| t as f32), + kind: None, + }; + let mut scaled_dashes = false; + match v.get("type") { + None | Some(Value::Null) => {} + Some(Value::String(name)) if name == "dashed" => { + scaled_dashes = true; + attrs.kind = Some(StrokeType::Dashed { pattern: Vec::new() }); + } + Some(Value::String(name)) if CELL_STROKE_TYPES.contains(&name.as_str()) => { + attrs.kind = Some(serde_json::from_value(json!({"kind": name})).map_err(|e| bad(cmd, e.to_string()))?); + } + Some(t @ Value::Object(_)) => { + let kind: StrokeType = serde_json::from_value(t.clone()).map_err(|e| bad(cmd, format!("bad stroke type: {e}")))?; + let finite = |v: &[f64]| v.len() <= 64 && v.iter().all(|x| x.is_finite() && *x >= 0.0); + match &kind { + StrokeType::Dashed { pattern } if !finite(pattern) || pattern.iter().all(|x| *x == 0.0) => { + return Err(bad(cmd, "a dash pattern needs up to 64 non-negative lengths, not all zero")); + } + StrokeType::Stripes { bands } if bands.len() > 64 || !bands.iter().all(|(a, b)| finite(&[*a, *b])) => { + return Err(bad(cmd, "stripes need up to 64 bands of non-negative numbers")); + } + StrokeType::Style { name } if !doc.stroke_styles.iter().any(|s| s.name == *name) => { + return Err(bad(cmd, format!("no stroke style `{name}`"))); + } + _ => {} + } + attrs.kind = Some(kind); + } + Some(other) => return Err(bad(cmd, format!("unknown stroke type {other}"))), + } + Ok(StrokeEdit { attrs, scaled_dashes }) + } + + fn apply(&self, mut base: CellStroke) -> CellStroke { + self.attrs.apply_to(&mut base); + if self.scaled_dashes { + let w = base.weight.max(1.0); + base.kind = StrokeType::Dashed { pattern: vec![w * 3.0, w * 2.0] }; + } + base + } +} + +/// The stroke an edge shows: the table border on an unoverridden perimeter edge, else the cell's. +fn shown_edge<'a>(cell: &'a designcraft_doc::Cell, side: usize, perimeter: bool, borders: &'a [CellStroke; 4]) -> &'a CellStroke { + match borders.get(side) { + Some(border) if perimeter && !cell.border_overrides.get(side).copied().unwrap_or(false) => border, + _ => &cell.strokes[side.min(3)], + } +} + +/// Whether each side (top, left, bottom, right; physical) of the cell is on the table's perimeter. +fn perimeter(t: &Table, (r, c): (usize, usize), cell: &designcraft_doc::Cell) -> [bool; 4] { + let (nr, nc) = (t.nrows(), t.ncols()); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); + let at_right = c.saturating_add(cell.col_span.max(1) as usize); + [r == 0, if rtl { at_right >= nc } else { c == 0 }, at_bottom >= nr, if rtl { c == 0 } else { at_right >= nc }] } fn set_cell(s: &mut Session, p: &Value) -> Result { - let g = target(s, p, "table.setCell")?; - let fill = color_param(p, "fill"); - let tint = p.get("tint").and_then(Value::as_f64).map(|t| t.clamp(0.0, 1.0) as f32); + const ID: &str = "table.setCell"; + let g = target(s, p, ID)?; + let doc = &s.doc()?.doc; + let fill = swatch_in(doc, p, &["fill"], ID)?; + let tint = num_in(p, "tint", 0.0, 1.0, ID)?.map(|t| t as f32); let insets: Option<[f64; 4]> = match p.get("insets") { Some(Value::Number(n)) => n.as_f64().map(|v| [v.max(0.0); 4]), Some(Value::Array(a)) if a.len() == 4 => { @@ -642,12 +796,15 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { Some("center") => Some(VerticalJustification::Center), Some("bottom") => Some(VerticalJustification::Bottom), Some("justify") => Some(VerticalJustification::Justify), - Some(o) => return Err(bad("table.setCell", format!("unknown vj `{o}`"))), + Some(o) => return Err(bad(ID, format!("unknown vj `{o}`"))), None => None, }; let text = str_param(p, "text").map(str::to_string); - let stroke = p.get("stroke").cloned(); - edit_table(s, &g, "table.setCell", |t| { + let stroke = match p.get("stroke").filter(|v| !v.is_null()) { + Some(sv) => Some((StrokeEdit::parse(doc, sv, ID)?, EdgeSel::parse(sv.get("edges"), ID)?)), + None => None, + }; + edit_table(s, &g, ID, |t| { let owners = t.owners(); let nc = t.ncols(); let nr = t.nrows(); @@ -658,9 +815,13 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let rg = g.range; for r in rg.r0..=rg.r1 { for c in rg.c0..=rg.c1 { - if owners[r * nc + c] != (r, c) { + if owners.get(r * nc + c) != Some(&(r, c)) { continue; } + let on_perimeter = match t.cell(r, c) { + Some(cell) => perimeter(t, (r, c), cell), + None => continue, + }; let Some(cell) = t.cell_mut(r, c) else { continue }; if let Some(f) = &fill { cell.fill = f.clone(); @@ -678,24 +839,12 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { let len = cell.text.len(); cell.text.replace(0..len, tx); } - if let Some(sv) = &stroke { - let edges = str_param(sv, "edges").unwrap_or("all"); - let at_bottom = r.saturating_add(cell.row_span.max(1) as usize); - let at_right = c.saturating_add(cell.col_span.max(1) as usize); - let perimeter = [r == 0, if rtl { at_right == nc } else { c == 0 }, at_bottom == nr, if rtl { c == 0 } else { at_right == nc }]; - for (i, on) in [ - ("top", r == rg.r0), - ("left", if rtl { at_right.saturating_sub(1) == rg.c1 } else { c == rg.c0 }), - ("bottom", at_bottom.saturating_sub(1) == rg.r1), - ("right", if rtl { c == rg.c0 } else { at_right.saturating_sub(1) == rg.c1 }), - ] - .iter() - .enumerate() - .map(|(i, (name, outer))| (i, edges == "all" || edges == *name || (edges == "outer" && *outer) || (edges == "inner" && !*outer))) - { + if let Some((edit, edges)) = &stroke { + let chosen = edges.sides(rg, (r, c), (cell.row_span, cell.col_span), rtl); + for (i, on) in chosen.into_iter().enumerate() { if on { - let base = if perimeter[i] && !cell.border_overrides[i] { &borders[i] } else { &cell.strokes[i] }; - cell.strokes[i] = parse_stroke(sv, base.clone()); + let base = shown_edge(cell, i, on_perimeter[i], &borders).clone(); + cell.strokes[i] = edit.apply(base); cell.stroke_defined[i] = true; cell.stroke_priorities[i] = priority; cell.border_overrides[i] = true; @@ -706,29 +855,27 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { } // Keep both copies of unmerged shared boundaries consistent. Left/right name // physical sides, also in RTL tables. - if let Some(sv) = &stroke { - let edges = str_param(sv, "edges").unwrap_or("all"); - let selected = |side: &str| edges == "all" || edges == "outer" || edges == side; - if selected("top") + if let Some((_, edges)) = &stroke { + if edges.top && let Some(row) = rg.r0.checked_sub(1) { for c in rg.c0..=rg.c1 { mirror_cell_edge(t, &owners, (rg.r0, c), 0, (row, c), 2); } } - if selected("bottom") && rg.r1.saturating_add(1) < nr { + if edges.bottom && rg.r1.saturating_add(1) < nr { for c in rg.c0..=rg.c1 { mirror_cell_edge(t, &owners, (rg.r1, c), 2, (rg.r1 + 1, c), 0); } } - if selected(if rtl { "right" } else { "left" }) + if (if rtl { edges.right } else { edges.left }) && let Some(col) = rg.c0.checked_sub(1) { for r in rg.r0..=rg.r1 { mirror_cell_edge(t, &owners, (r, rg.c0), if rtl { 3 } else { 1 }, (r, col), if rtl { 1 } else { 3 }); } } - if selected(if rtl { "left" } else { "right" }) && rg.c1.saturating_add(1) < nc { + if (if rtl { edges.left } else { edges.right }) && rg.c1.saturating_add(1) < nc { for r in rg.r0..=rg.r1 { mirror_cell_edge(t, &owners, (r, rg.c1), if rtl { 1 } else { 3 }, (r, rg.c1 + 1), if rtl { 3 } else { 1 }); } @@ -738,6 +885,62 @@ fn set_cell(s: &mut Session, p: &Value) -> Result { }) } +/// The shown stroke attributes of the chosen edges of the target cells, and their fill; values +/// that differ are null. +fn get_cell_stroke(s: &mut Session, p: &Value) -> Result { + const ID: &str = "table.getCellStroke"; + let g = target(s, p, ID)?; + let edges = EdgeSel::parse(p.get("edges"), ID)?; + let st = s.doc()?; + let t = st.doc.story(g.story).and_then(|x| x.tables.get(&g.table)).ok_or_else(|| bad(ID, "no table"))?; + let owners = t.owners(); + let nc = t.ncols(); + let rtl = t.options.direction == designcraft_doc::TextDirection::RightToLeft; + let borders: [CellStroke; 4] = std::array::from_fn(|side| t.options.border_for(side).clone()); + // One value per key, or null once two edges (cells) disagree. + fn merge(slot: &mut Option, v: Value) { + match slot { + None => *slot = Some(v), + Some(cur) if *cur != v => *cur = Value::Null, + _ => {} + } + } + let keys = ["weight", "color", "tint", "type", "gapColor", "gapTint"]; + let mut stroke: [Option; 6] = Default::default(); + let (mut fill, mut fill_tint) = (None, None); + let mut count = 0usize; + for r in g.range.r0..=g.range.r1 { + for c in g.range.c0..=g.range.c1 { + if owners.get(r * nc + c) != Some(&(r, c)) { + continue; + } + let Some(cell) = t.cell(r, c) else { continue }; + merge(&mut fill, json!(cell.fill)); + merge(&mut fill_tint, json!(cell.fill_tint)); + let on_perimeter = perimeter(t, (r, c), cell); + for (i, on) in edges.sides(g.range, (r, c), (cell.row_span, cell.col_span), rtl).into_iter().enumerate() { + if !on { + continue; + } + count += 1; + let e = shown_edge(cell, i, on_perimeter[i], &borders); + let values = [json!(e.weight), json!(e.color), json!(e.tint), json!(e.kind), json!(e.gap_color), json!(e.gap_tint)]; + for (slot, v) in stroke.iter_mut().zip(values) { + merge(slot, v); + } + } + } + } + let mut out = serde_json::Map::new(); + for (k, v) in keys.iter().zip(stroke) { + out.insert((*k).into(), v.unwrap_or(Value::Null)); + } + out.insert("fill".into(), fill.unwrap_or(Value::Null)); + out.insert("fillTint".into(), fill_tint.unwrap_or(Value::Null)); + out.insert("edges".into(), json!(count)); + Ok(Value::Object(out)) +} + /// Only saturation needs rebasing. Preserve every distinct priority's order and keep /// zero/negative priorities untouched; the work is bounded by the table's edges. fn next_cell_stroke_priority(t: &mut Table) -> i32 { @@ -907,14 +1110,18 @@ fn parse_alt(v: &Value) -> Option { fn options(s: &mut Session, p: &Value) -> Result { let g = target(s, p, "table.options")?; + let border = match p.get("border").filter(|v| !v.is_null()) { + Some(b) => Some(StrokeEdit::parse(&s.doc()?.doc, b, "table.options")?), + None => None, + }; let p = p.clone(); edit_table(s, &g, "table.options", |t| { let o = &mut t.options; if let Some(v) = p.get("direction") { o.direction = serde_json::from_value(v.clone()).map_err(|e| bad("table.options", e.to_string()))?; } - if let Some(b) = p.get("border") { - o.border = parse_stroke(b, o.border.clone()); + if let Some(b) = &border { + o.border = b.apply(o.border.clone()); o.borders = Default::default(); } o.space_before = f64_or(&p, "spaceBefore", o.space_before); @@ -1035,23 +1242,17 @@ fn get(s: &mut Session, p: &Value) -> Result { // ---------- 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); +/// A style's uniform edge stroke from `stroke`/`border`: the given values over the default +/// edge, validated like `table.setCell`'s stroke. +fn stroke_param(s: &Session, v: Option<&Value>, cmd: &str) -> Result> { + match v.filter(|v| !v.is_null()) { + Some(v) => Ok(Some(StrokeEdit::parse(&s.doc()?.doc, v, cmd)?.apply(CellStroke::default()))), + None => Ok(None), } - 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<()> { +fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, stroke: Option, cmd: &str) -> Result<()> { if let Some(v) = p.get("basedOn") { cs.based_on = v.as_str().map(str::to_string); } @@ -1076,7 +1277,7 @@ fn cell_style_fields(cs: &mut designcraft_doc::CellStyle, p: &Value, cmd: &str) 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")) { + if let Some(st) = stroke { cs.stroke = Some(st); cs.strokes = Default::default(); } @@ -1104,7 +1305,8 @@ fn cell_style_create(s: &mut Session, p: &Value) -> Result { 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")?; + let stroke = stroke_param(s, p.get("stroke"), "style.cell.create")?; + cell_style_fields(&mut cs, p, stroke, "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(); @@ -1182,17 +1384,18 @@ fn dependent_styles<'a>(name: &str, styles: impl Iterator Result { let name = str_param(p, "name").ok_or_else(|| bad("style.cell.edit", "missing name"))?.to_string(); + let stroke = stroke_param(s, p.get("stroke"), "style.cell.edit")?; 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")?; + cell_style_fields(cs, &p, stroke, "style.cell.edit")?; let names = dependent_styles(&name, d.styles.cell.iter().map(|cs| (cs.name.as_str(), cs.based_on.as_deref()))); reapply_cell_styles(d, &names); ok() }) } -fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { +fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value, border: Option) { if let Some(v) = p.get("basedOn") { ts.based_on = v.as_str().map(str::to_string); } @@ -1207,7 +1410,7 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { *f = v.as_str().map(str::to_string); } } - if let Some(b) = stroke_param(p.get("border")) { + if let Some(b) = border { ts.border = Some(b); ts.borders = Default::default(); } @@ -1237,7 +1440,8 @@ fn table_style_fields(ts: &mut designcraft_doc::TableStyle, p: &Value) { 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); + let border = stroke_param(s, p.get("border"), "style.table.create")?; + table_style_fields(&mut ts, p, border); 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(); @@ -1263,11 +1467,12 @@ fn table_style_apply(s: &mut Session, p: &Value) -> Result { 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 border = stroke_param(s, p.get("border"), "style.table.edit")?; 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); + table_style_fields(ts, &p, border); let names = dependent_styles(&name, d.styles.table.iter().map(|ts| (ts.name.as_str(), ts.based_on.as_deref()))); let styles: Vec<_> = names.iter().map(|name| d.styles.resolve_table_style(name)).collect(); let cells = d.styles.cell.clone(); diff --git a/crates/engine/src/tests_table.rs b/crates/engine/src/tests_table.rs index 45861347d..83b4f9c97 100644 --- a/crates/engine/src/tests_table.rs +++ b/crates/engine/src/tests_table.rs @@ -399,3 +399,68 @@ fn editing_beside_a_merged_cell_only_changes_the_selected_edge_segment() { } } } + +#[test] +fn cell_stroke_gap_colour_on_inner_horizontal_edges_of_a_selection_undoes_and_saves() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + s.execute("table.select", &json!({"what": "table"})).unwrap(); + let before = table_for(&s, sid, tid).clone(); + let cyan = "C=100 M=0 Y=0 K=0"; + let stroke = json!({"weight": 3, "type": "dashed", "gapColor": cyan, "gapTint": 0.5, "edges": ["innerHorizontal"]}); + s.execute("table.setCell", &json!({ "stroke": stroke })).unwrap(); + let t = table_for(&s, sid, tid).clone(); + let edge = |r: usize, c: usize, side: usize| t.cell(r, c).unwrap().strokes[side].clone(); + for (r, c, side) in [(0, 0, 2), (0, 1, 2), (1, 0, 0), (1, 1, 0)] { + let e = edge(r, c, side); + assert_eq!((e.weight, e.gap_color.as_str(), e.gap_tint), (3.0, cyan, 0.5), "inner horizontal {r},{c} side {side}"); + assert!(matches!(e.kind, doc::StrokeType::Dashed { .. })); + } + for (r, c, side) in [(0, 0, 0), (0, 0, 1), (0, 0, 3), (1, 1, 2), (1, 1, 1), (0, 1, 3)] { + assert_eq!(&edge(r, c, side), &before.cell(r, c).unwrap().strokes[side], "{r},{c} side {side} keeps its stroke"); + } + // The query reports the chosen edges, and mixed values as null. + let inner = s.execute("table.getCellStroke", &json!({"edges": "innerHorizontal"})).unwrap(); + assert_eq!((inner["weight"].as_f64(), inner["gapColor"].as_str(), inner["edges"].as_u64()), (Some(3.0), Some(cyan), Some(4))); + let all = s.execute("table.getCellStroke", &json!({})).unwrap(); + assert!(all["weight"].is_null() && all["gapColor"].is_null()); + // Bad values are errors and change nothing. + for bad in + [json!({"gapTint": 2}), json!({"gapColor": "No Such Swatch"}), json!({"weight": -1}), json!({"type": "zigzag"}), json!({"edges": "middle"})] + { + assert!(s.execute("table.setCell", &json!({ "stroke": bad })).is_err(), "{bad}"); + assert_eq!(table_for(&s, sid, tid), &t); + } + let bytes = crate::cmd::file_bytes(&s.doc().unwrap().doc); + let back = crate::cmd::file_from(&bytes).unwrap(); + assert_eq!(back.story(doc::StoryId(sid)).unwrap().tables.get(&tid).unwrap().as_ref(), &t); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(table_for(&s, sid, tid), &before); +} + +#[test] +fn cell_and_table_style_strokes_take_type_and_gap_colour_with_validation() { + let (mut s, sid, _) = session_with_frame(); + let tid = s.execute("table.insert", &json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + let stroke = json!({"weight": 2, "type": "dotted", "gapColor": "[Paper]", "gapTint": 0.4}); + s.execute("style.cell.create", &json!({"name": "Dotted", "stroke": stroke})).unwrap(); + s.execute("style.cell.apply", &json!({"name": "Dotted", "table": tid})).unwrap(); + let edges = s.execute("table.getCellStroke", &json!({"table": tid})).unwrap(); + assert_eq!((edges["weight"].as_f64(), edges["type"]["kind"].as_str()), (Some(2.0), Some("dotted"))); + assert_eq!((edges["gapColor"].as_str(), edges["gapTint"].as_f64().map(|t| (t * 10.0).round())), (Some("[Paper]"), Some(4.0))); + s.execute("style.table.create", &json!({"name": "Framed", "border": {"type": "thickThin", "gapSwatch": "[Black]"}})).unwrap(); + let framed = s.doc().unwrap().doc.styles.table.iter().find(|t| t.name == "Framed").unwrap().border.clone().unwrap(); + assert_eq!((framed.kind, framed.gap_color.as_str()), (doc::StrokeType::ThickThin, "[Black]")); + let styles = s.doc().unwrap().doc.styles.clone(); + let table = table_for(&s, sid, tid).clone(); + for (cmd, p) in [ + ("style.cell.edit", json!({"name": "Dotted", "stroke": {"gapTint": 3}})), + ("style.cell.create", json!({"name": "Bad", "stroke": {"type": "zigzag"}})), + ("style.table.edit", json!({"name": "Framed", "border": {"gapColor": "No Such Swatch"}})), + ("style.table.create", json!({"name": "Bad", "border": {"weight": -2}})), + ] { + assert!(s.execute(cmd, &p).is_err(), "{cmd} {p}"); + assert_eq!(s.doc().unwrap().doc.styles, styles, "{cmd} changed the styles"); + assert_eq!(table_for(&s, sid, tid), &table); + } +} diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 7f97a0ec6..29aea247b 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -2361,6 +2361,10 @@ impl<'a> Ex<'a> { el.set(&format!("{prefix}StrokeColor"), self.sw(&s.color)); el.set(&format!("{prefix}StrokeTint"), pct(s.tint as f64)); el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(&s.kind)); + if s.gap_color != designcraft_color::swatch::NONE || s.gap_tint != 1.0 { + el.set(&format!("{prefix}StrokeGapColor"), self.sw(&s.gap_color)); + el.set(&format!("{prefix}StrokeGapTint"), pct(s.gap_tint as f64)); + } } fn partial_cell_stroke_attrs(&self, el: &mut El, prefix: &str, attrs: &designcraft_doc::CellStrokeAttrs) { @@ -2376,6 +2380,12 @@ impl<'a> Ex<'a> { if let Some(value) = &attrs.kind { el.set(&format!("{prefix}StrokeType"), names::stroke_type_out(value)); } + if let Some(value) = &attrs.gap_color { + el.set(&format!("{prefix}StrokeGapColor"), self.sw(value)); + } + if let Some(value) = attrs.gap_tint { + el.set(&format!("{prefix}StrokeGapTint"), pct(value as f64)); + } } fn partial_alt_fill_attrs(&self, el: &mut El, kind: &str, attrs: &designcraft_doc::AltFillsAttrs) { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index e41bce376..9d108c801 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -26,7 +26,15 @@ fn uniform_cell_stroke(edges: &[designcraft_doc::CellStrokeAttrs; 4]) -> Option< if edges.iter().any(|edge| edge != first) { return None; } - Some(designcraft_doc::CellStroke { weight: first.weight?, color: first.color.clone()?, tint: first.tint?, kind: first.kind.clone()? }) + let mut stroke = designcraft_doc::CellStroke { + weight: first.weight?, + color: first.color.clone()?, + tint: first.tint?, + kind: first.kind.clone()?, + ..Default::default() + }; + first.apply_to(&mut stroke); + Some(stroke) } /// Import an IDML package. Linked images are read from disk when available (not on wasm). @@ -1753,6 +1761,8 @@ impl<'r> Importer<'r> { color: el.prop(&format!("{prefix}StrokeColor")).map(|v| self.swatch_ref(&v)), tint: tint(el.num(&format!("{prefix}StrokeTint"))), kind: el.prop(&format!("{prefix}StrokeType")).map(|v| names::stroke_type_in(&v)), + gap_color: el.prop(&format!("{prefix}StrokeGapColor")).map(|v| self.swatch_ref(&v)), + gap_tint: tint(el.num(&format!("{prefix}StrokeGapTint"))), } } diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 4a3a940c5..3d7ee8f32 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -2647,3 +2647,32 @@ fn hostile_tab_lists_import_within_the_limits() { let report = import_idml_report(&fixture_with_story(&fine), &|_| None).unwrap(); assert!(report.warnings.is_empty(), "{:?}", report.warnings); } + +#[test] +fn cell_edge_gap_colours_and_tints_import_and_round_trip_per_edge() { + let styles = CASCADE_STYLES.replace( + r#"TopEdgeStrokeType="StrokeStyle/$ID/Dashed""#, + r#"TopEdgeStrokeType="StrokeStyle/$ID/Dashed" TopEdgeStrokeGapColor="Color/Paper" TopEdgeStrokeGapTint="60" BottomEdgeStrokeGapColor="Color/Brand""#, + ); + let story = CASCADE_STORY.replace( + r#""#, + r#""#, + ); + let gaps = |d: &Document| { + let base = d.styles.cell.iter().find(|s| s.name == "Base").unwrap().strokes.clone(); + let table = d.stories.values().flat_map(|s| s.tables.values()).next().unwrap(); + let cell = table.cell(1, 1).unwrap().strokes.clone(); + (base.map(|e| (e.gap_color, e.gap_tint)), cell.map(|e| (e.gap_color, e.gap_tint))) + }; + let d = import_idml(&fixture_with_table_styles(&styles, &story)).unwrap(); + d.check().unwrap(); + let (style, cell) = gaps(&d); + assert_eq!(style[0], (Some("[Paper]".to_string()), Some(0.6))); + assert_eq!(style[2], (Some("Brand".to_string()), None)); + assert_eq!((style[1].clone(), style[3].clone()), ((None, None), (None, None))); + assert_eq!(cell[1], ("Brand".to_string(), 0.3), "left"); + assert_eq!(cell[3], ("[Paper]".to_string(), 1.0), "right"); + assert_eq!(cell[0].0, "[Paper]", "the Base style's top gap colour reaches the cell"); + let back = import_idml(&export_idml(&d)).unwrap(); + assert_eq!(gaps(&back), (style, cell)); +} diff --git a/crates/pdf/src/text.rs b/crates/pdf/src/text.rs index a087473ea..3d739646f 100644 --- a/crates/pdf/src/text.rs +++ b/crates/pdf/src/text.rs @@ -217,7 +217,37 @@ impl Exporter<'_> { for seg in &t.strokes { let st = &seg.stroke; let Some(col) = self.swatch_color(&st.color, st.tint) else { continue }; - let (cap, dash) = match &st.kind { + let kind = self.doc.stroke_kind(&st.kind); + let mut bp = BezPath::new(); + bp.move_to(seg.a); + bp.line_to(seg.b); + let Some(p) = to_path(&bp) else { continue }; + // Gap colour under dashes, dots and stripes: the whole edge, solid. + if !matches!(kind, designcraft_doc::StrokeType::Solid) + && st.gap_color != designcraft_color::swatch::NONE + && let Some(gap) = self.swatch_color(&st.gap_color, st.gap_tint) + { + s.set_fill(None); + s.set_stroke(Some(Stroke { + paint: gap.into(), + width: st.weight as f32, + line_cap: krilla::paint::LineCap::Butt, + ..Default::default() + })); + s.draw_path(&p); + s.set_stroke(None); + } + // Stripes, wavy and hash types are fills along the edge. + if let Some(o) = kind.outline(&bp, st.weight, 0.05) + && let Some(op) = to_path(&o) + { + s.set_stroke(None); + s.set_fill(Some(solid_fill(col, 1.0))); + s.draw_path(&op); + s.set_fill(None); + continue; + } + let (cap, dash) = match &kind { designcraft_doc::StrokeType::Dashed { pattern } if pattern.iter().any(|v| *v > 0.0) => { let mut pat: Vec = pattern.iter().map(|v| v.max(0.0) as f32).collect(); if pat.len() % 2 == 1 { @@ -230,10 +260,6 @@ impl Exporter<'_> { } _ => (krilla::paint::LineCap::Square, None), }; - let mut bp = BezPath::new(); - bp.move_to(seg.a); - bp.line_to(seg.b); - let Some(p) = to_path(&bp) else { continue }; s.set_fill(None); s.set_stroke(Some(Stroke { paint: col.into(), width: st.weight as f32, line_cap: cap, dash, ..Default::default() })); s.draw_path(&p); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index e815936b0..2af53c4fb 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1632,6 +1632,36 @@ mod tests { assert!(p[0] > 240 && p[1] < 200, "pink on screen: {p:?}"); } + #[test] + fn table_edges_paint_gap_colour_between_dashes_and_stripes() { + let mut d = Document::new(&NewDocument::default()); + let lid = d.default_layer(); + let (_, sid) = d.add_text_frame(SpreadRef::Doc(0), Rect::new(36.0, 36.0, 336.0, 400.0), lid, "", ParaFormat::default()).unwrap(); + let mut t = designcraft_doc::Table::new(1, 1, 1, 0, 0, 200.0); + t.rows[0].height = 60.0; + t.rows[0].mode = designcraft_doc::RowHeightMode::Exactly; + let gap = |kind| designcraft_doc::CellStroke { weight: 8.0, kind, gap_color: "C=100 M=0 Y=0 K=0".into(), ..Default::default() }; + let cell = t.cell_mut(0, 0).unwrap(); + cell.strokes[0] = gap(designcraft_doc::StrokeType::Dashed { pattern: vec![12.0, 6.0] }); + cell.strokes[2] = gap(designcraft_doc::StrokeType::ThickThin); + cell.border_overrides = [true; 4]; + cell.stroke_defined = [true; 4]; + d.story_mut(sid).unwrap().insert_table(0, t); + let cache = Cache::new(); + let rect = cache.get(&d, sid, None).frames[0].tables[0].cell(0, 0).unwrap().rect; + let mut r = Renderer::new(); + r.threads = 0; + let img = r.render_page(&d, &cache, 0, 1.0, false, &RenderOptions::default()).unwrap(); + let ink = |p: [u8; 4]| p[0] < 80 && p[2] < 80; + let cyan = |p: [u8; 4]| p[0] < 80 && p[2] > 150; + let y = rect.y0.round() as u32; + let (dash, space) = (img.pixel((rect.x0 + 6.0) as u32, y), img.pixel((rect.x0 + 15.0) as u32, y)); + assert!(ink(dash), "dash {dash:?}"); + assert!(cyan(space), "gap {space:?}"); + let column: Vec<_> = (rect.y1 as u32 - 5..=rect.y1 as u32 + 5).map(|y| img.pixel(rect.center().x as u32, y)).collect(); + assert!(column.iter().any(|p| ink(*p)) && column.iter().any(|p| cyan(*p)), "stripes and gap: {column:?}"); + } + #[test] fn renders_tables() { let mut d = Document::new(&NewDocument::default()); diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 0d8791e59..72435902c 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -464,22 +464,37 @@ impl Renderer { ctx.set_transform(f.view * xf); for s in &t.strokes { let Some(col) = doc.resolve_color(&s.stroke.color, s.stroke.tint) else { continue }; + let kind = doc.stroke_kind(&s.stroke.kind); + let line = kurbo::Line::new(s.a, s.b).to_path(0.1); + // Gap colour under dashes, dots and stripes: the whole edge, solid. + if !matches!(kind, designcraft_doc::StrokeType::Solid) + && let Some(gap) = doc.resolve_color(&s.stroke.gap_color, s.stroke.gap_tint) + { + ctx.set_paint(color_of(&gap, 1.0)); + ctx.set_stroke(kurbo::Stroke::new(s.stroke.weight).with_caps(kurbo::Cap::Butt)); + ctx.stroke_path(&line); + } ctx.set_paint(color_of(&col, 1.0)); - ctx.set_stroke(cell_stroke(&s.stroke)); - ctx.stroke_path(&kurbo::Line::new(s.a, s.b).to_path(0.1)); + // Stripes, wavy and hash types are fills along the edge. + if let Some(o) = kind.outline(&line, s.stroke.weight, 0.05 * f.px) { + ctx.fill_path(&o); + continue; + } + ctx.set_stroke(cell_stroke(&kind, s.stroke.weight)); + ctx.stroke_path(&line); } } } } -/// Kurbo stroke for a table edge. -fn cell_stroke(s: &designcraft_doc::CellStroke) -> kurbo::Stroke { - let st = kurbo::Stroke::new(s.weight).with_caps(kurbo::Cap::Square); - match &s.kind { +/// Kurbo stroke for a table edge of a resolved stroke type. +fn cell_stroke(kind: &designcraft_doc::StrokeType, weight: f64) -> kurbo::Stroke { + let st = kurbo::Stroke::new(weight).with_caps(kurbo::Cap::Square); + match kind { designcraft_doc::StrokeType::Dashed { pattern } if !pattern.is_empty() => { st.with_caps(kurbo::Cap::Butt).with_dashes(0.0, pattern.iter().copied()) } - designcraft_doc::StrokeType::Dotted => st.with_dashes(0.0, [0.0, s.weight * 2.0]).with_caps(kurbo::Cap::Round), + designcraft_doc::StrokeType::Dotted => st.with_dashes(0.0, [0.0, weight * 2.0]).with_caps(kurbo::Cap::Round), _ => st, } } diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3457b7773..a34481830 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1146,6 +1146,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "frameSize" => crate::i18n::tr(&app.ui.language, "Rectangle"), "goToPage" => crate::i18n::tr(&app.ui.language, "Go to Page"), "insertTable" => crate::i18n::tr(&app.ui.language, "Create Table"), + "cellOptions" => crate::i18n::tr(&app.ui.language, "Cell Options"), "textFrameOptions" => crate::i18n::tr(&app.ui.language, "Text Frame Options"), "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "numberingSection" => crate::i18n::tr(&app.ui.language, "Numbering & Section Options…").trim_end_matches('…'), @@ -1677,6 +1678,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { ui.end_row(); }); } + "cellOptions" => cell_options(app, ui, &mut d), "textFrameOptions" => { seed_text_frame_options(app, &mut d); text_frame_options_dialog(app, ui, &mut d); @@ -1747,6 +1749,99 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } +/// Cell Options keys (`table.getCellStroke` names) and whether each belongs to the stroke. +const CELL_OPTION_KEYS: [(&str, bool); 8] = + [("weight", true), ("type", true), ("color", true), ("tint", true), ("gapColor", true), ("gapTint", true), ("fill", false), ("fillTint", false)]; + +/// Table › Cell Options › Strokes and Fills… for the target cells. The fields start from the +/// cells' values (`seed`); OK applies the fields that differ from it. +pub fn open_cell_options(app: &mut DesignApp) -> Result { + let edges = crate::panels::cell_stroke::edges_param(&[true; 6]); + let cur = app.session.execute("table.getCellStroke", &json!({"edges": edges})).map_err(|e| e.to_string())?; + let mut fields = Map::new(); + for (k, _) in CELL_OPTION_KEYS { + fields.insert(k.into(), cur[k].clone()); + } + fields.insert("seed".into(), Value::Object(fields.clone())); + fields.insert("edges".into(), edges); + app.ui.dialog = Some(Dialog::new("cellOptions", Value::Object(fields))); + Ok(Value::Null) +} + +/// Cell Options › Strokes and Fills: the edge proxy and stroke fields, then the cell fill. +fn cell_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + use crate::panels::cell_stroke; + let lang = app.ui.language.clone(); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Cell Stroke")).font(semibold(12.0))); + let mut edges = cell_stroke::edges_from(d.fields.get("edges").unwrap_or(&Value::Null)); + if cell_stroke::edge_proxy(ui, "cell_options", &mut edges, &lang) { + d.fields.insert("edges".into(), cell_stroke::edges_param(&edges)); + // Fields the user hasn't changed follow the chosen edges. + let cur = cell_stroke::current(app, &edges); + let seed = d.fields.get("seed").cloned().unwrap_or(Value::Null); + let mut next = seed.clone(); + for (k, stroke) in CELL_OPTION_KEYS { + if stroke && d.fields.get(k) == seed.get(k) { + d.fields.insert(k.into(), cur[k].clone()); + next[k] = cur[k].clone(); + } + } + d.fields.insert("seed".into(), next); + } + ui.add_space(6.0); + let values = Value::Object(d.fields.clone()); + let any = edges.iter().any(|on| *on); + if let Some((k, v)) = ui.add_enabled_ui(any, |ui| cell_stroke::stroke_fields(app, ui, "cell_options", &values)).inner { + d.fields.insert(k.into(), v); + } + ui.add_space(10.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Cell Fill")).font(semibold(12.0))); + egui::Grid::new("cell_fill").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Color:")); + let mut picked = None; + crate::panels::swatch_picker(app, ui, "cell_fill_color", values["fill"].as_str().map(str::to_string), |_, n| picked = Some(n)); + if let Some(n) = picked { + d.fields.insert("fill".into(), json!(n)); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&lang, "Tint:")); + let tint = values["fillTint"].as_f64().map(|t| (t * 100.0).round()); + if let Some(t) = crate::widgets::NumField::number("cell_fill_tint", tint, "%", 0).width(60.0).range(0.0, 100.0).show(ui) { + d.fields.insert("fillTint".into(), json!(t / 100.0)); + } + ui.end_row(); + }); +} + +/// Apply the Cell Options fields that differ from the cells' values, as one `table.setCell`. +fn confirm_cell_options(app: &mut DesignApp, d: &Dialog) -> Result { + let seed = d.fields.get("seed").cloned().unwrap_or(Value::Null); + let mut stroke = Map::new(); + let mut params = Map::new(); + for (k, is_stroke) in CELL_OPTION_KEYS { + let Some(v) = d.fields.get(k).filter(|v| !v.is_null() && seed.get(k) != Some(*v)) else { continue }; + match (is_stroke, k) { + (true, _) => { + stroke.insert(k.into(), v.clone()); + } + (false, "fillTint") => { + params.insert("tint".into(), v.clone()); + } + _ => { + params.insert(k.into(), v.clone()); + } + } + } + if !stroke.is_empty() { + stroke.insert("edges".into(), d.fields.get("edges").cloned().unwrap_or(json!("all"))); + params.insert("stroke".into(), Value::Object(stroke)); + } + if params.is_empty() { + return Ok(Value::Null); + } + app.run("table.setCell", Value::Object(params)) +} + /// Apply the open dialog. pub fn confirm(app: &mut DesignApp) -> Result { let Some(d) = app.ui.dialog.take() else { return Err("no dialog open".into()) }; @@ -1802,6 +1897,15 @@ pub fn confirm(app: &mut DesignApp) -> Result { } r } + "cellOptions" => { + // On an error the dialog stays open to be corrected. + let r = confirm_cell_options(app, &d); + if let Err(e) = &r { + app.status(e.clone()); + app.ui.dialog = Some(d); + } + r + } "textFrameOptions" => { // On an error the dialog stays open to be corrected. let mut d = d; @@ -4766,6 +4870,33 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + #[test] + fn cell_options_dialog_draws_and_applies_only_changed_values_to_the_chosen_edges() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + let tid = app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + app.run("table.select", json!({"what": "table"})).unwrap(); + app.run("app.cellOptionsDialog", json!({})).unwrap(); + let ctx = dialog_context(""); + dialog_frame(&mut app, &ctx, egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(900.0, 700.0)), vec![]); + let d = app.ui.dialog.as_mut().unwrap(); + assert_eq!(d.id, "cellOptions"); + assert_eq!(d.fields["weight"], json!(1.0)); + d.fields.insert("edges".into(), json!(["top", "bottom", "left", "right"])); + d.fields.insert("gapColor".into(), json!("[Paper]")); + d.fields.insert("fill".into(), json!("[Black]")); + d.fields.insert("fillTint".into(), json!(0.2)); + confirm(&mut app).unwrap(); + let st = app.session.active().unwrap(); + let t = st.doc.stories.values().find_map(|s| s.tables.get(&tid)).unwrap(); + let gap = |r: usize, c: usize, side: usize| t.cell(r, c).unwrap().strokes[side].gap_color.clone(); + assert_eq!([gap(0, 0, 0), gap(0, 0, 1), gap(1, 1, 2), gap(1, 1, 3)], ["[Paper]"; 4].map(String::from), "outer edges"); + assert_eq!([gap(0, 0, 2), gap(0, 0, 3)], ["[None]"; 2].map(String::from), "inner edges keep theirs"); + assert!(t.cells.iter().all(|c| c.fill == "[Black]" && (c.fill_tint - 0.2).abs() < 1e-6)); + assert!(t.cells.iter().all(|c| c.strokes.iter().all(|e| e.weight == 1.0)), "unchanged values are not applied"); + } + #[test] fn numbering_and_section_options_keep_their_actions_in_view() { for language in ["en", "ar"] { diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5765862ba..160894d53 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3418,6 +3418,21 @@ const TABLE: &[(&str, [&str; 5])] = &[ ], ), ("Start At", ["Beginnen bei", "Commencer à", "Empezar en", "開始番号", "起始编号"]), + // Cell strokes. + ("Strokes and Fills…", ["Konturen und Flächen …", "Contours et fonds…", "Trazos y rellenos…", "線と塗り…", "描边和填色…"]), + ("Cell Stroke", ["Zellkontur", "Contour de cellule", "Trazo de celda", "セルの線", "单元格描边"]), + ("Cell Fill", ["Zellfläche", "Fond de cellule", "Relleno de celda", "セルの塗り", "单元格填色"]), + ("Cell edges", ["Zellkanten", "Bords de cellule", "Bordes de celda", "セルの境界線", "单元格边缘"]), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + [ + "Klicken Sie auf eine Linie, um sie ein- oder auszuschließen. Doppelklick wählt alle äußeren oder alle inneren Linien; Dreifachklick wählt alle aus oder hebt die Auswahl auf.", + "Cliquez sur une ligne pour l'inclure ou l'exclure. Un double-clic sélectionne toutes les lignes extérieures ou intérieures ; un triple-clic sélectionne ou désélectionne tout.", + "Haga clic en una línea para incluirla o excluirla. Doble clic selecciona todas las líneas exteriores o interiores; triple clic selecciona o borra todas.", + "線をクリックすると、含めるか除外するかを切り替えます。ダブルクリックで外側または内側の線をすべて選択し、トリプルクリックですべてを選択または解除します。", + "单击线条可将其包含或排除。双击选择所有外侧或所有内侧线条;三击选择或清除全部。", + ], + ), ]; /// Interface direction; independent of document binding and paragraph direction. diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 5e201a517..9010dfb76 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1567,4 +1567,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Mode:", "الوضع:"), ("Continue from Previous Number", "المتابعة من الرقم السابق"), ("Start At", "البدء عند"), + ("Strokes and Fills…", "الحدود والتعبئة…"), + ("Cell Stroke", "حد الخلية"), + ("Cell Fill", "تعبئة الخلية"), + ("Cell edges", "حواف الخلية"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "انقر على خط لتضمينه أو استبعاده. النقر المزدوج يحدد كل الخطوط الخارجية أو كل الخطوط الداخلية؛ والنقر الثلاثي يحدد الكل أو يلغي تحديده.", + ), ]; diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 7cfaba81a..df0120cdc 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -1820,6 +1820,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Em Box Top", "Geviert oben"), ("Fancy", "Verziert"), ("Gap Tint:", "Lückenfarbton:"), + ("Strokes and Fills…", "Konturen und Flächen …"), + ("Cell Stroke", "Zellkontur"), + ("Cell Fill", "Zellfläche"), + ("Cell edges", "Zellkanten"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Klicken Sie auf eine Linie, um sie ein- oder auszuschließen. Doppelklick wählt alle äußeren oder alle inneren Linien; Dreifachklick wählt alle aus oder hebt die Auswahl auf.", + ), ("Glyphs…", "Glyphen…"), ("Inset", "Eingezogen"), ("Inverse Rounded", "Umgekehrt abgerundet"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 27f7fa0a7..dfa9968f7 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1623,4 +1623,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Mode:", "Modalità:"), ("Continue from Previous Number", "Continua dal numero precedente"), ("Start At", "Inizia da"), + ("Strokes and Fills…", "Tracce e riempimenti…"), + ("Cell Stroke", "Traccia cella"), + ("Cell Fill", "Riempimento cella"), + ("Cell edges", "Bordi cella"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Fai clic su una linea per includerla o escluderla. Il doppio clic seleziona tutte le linee esterne o interne; il triplo clic seleziona o deseleziona tutte.", + ), ]; diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 538c80963..61695edcd 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1894,6 +1894,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Preset Name", "プリセット名"), ("Bounding Box (Visible Layers Only)", "バウンディングボックス(表示レイヤーのみ)"), ("Bounding Box (All Layers)", "バウンディングボックス(すべてのレイヤー)"), + ("Strokes and Fills…", "線と塗り…"), + ("Cell Stroke", "セルの線"), + ("Cell Fill", "セルの塗り"), + ("Cell edges", "セルの境界線"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "線をクリックすると、含めるか除外するかを切り替えます。ダブルクリックで外側または内側の線をすべて選択し、トリプルクリックですべてを選択または解除します。", + ), ]; /// Captions whose Japanese depends on where they appear: (English, context, Japanese). diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 83b423fb5..98e51e6f5 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1164,4 +1164,12 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Mode:", "Modo:"), ("Continue from Previous Number", "Continuar do número anterior"), ("Start At", "Iniciar em"), + ("Strokes and Fills…", "Traçados e preenchimentos…"), + ("Cell Stroke", "Traçado da célula"), + ("Cell Fill", "Preenchimento da célula"), + ("Cell edges", "Bordas da célula"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Clique em uma linha para incluí-la ou excluí-la. Clique duas vezes para selecionar todas as linhas externas ou internas; clique três vezes para selecionar ou limpar todas.", + ), ]; diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 25672dff9..fb1dbfa57 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1587,6 +1587,14 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Mode:", "Режим:"), ("Continue from Previous Number", "Продовжити з попереднього номера"), ("Start At", "Почати з"), + ("Strokes and Fills…", "Обведення та заливки…"), + ("Cell Stroke", "Обведення клітинки"), + ("Cell Fill", "Заливка клітинки"), + ("Cell edges", "Краї клітинки"), + ( + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + "Клацніть лінію, щоб увімкнути або вимкнути її. Подвійне клацання вибирає всі зовнішні або всі внутрішні лінії; потрійне — вибирає всі або знімає вибір.", + ), ]; /// Contexts whose Ukrainian noun differs from the shared action/text-layout label. diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 245d2df3e..1b411fe2b 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -70,6 +70,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.resetShortcuts", "Reset Shortcuts", None, "{} — every command back to its default shortcut"), ("app.findChange", "Find/Change…", Some("Cmd+F"), "{}"), ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), + ( + "app.cellOptionsDialog", + "Strokes and Fills…", + None, + "{} — Cell Options › Strokes and Fills of the target cells: edge proxy, stroke weight, type, colour, tint, gap colour, gap tint, and cell fill (table.setCell)", + ), ("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"), ("app.rubyDialog", "Ruby…", None, "{} — the reading set over the selected text"), ( @@ -615,6 +621,9 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:table.selectTable", "<", "-", + ">Cell Options", + "ui:app.cellOptionsDialog", + "<", "cmd:table.merge", "cmd:table.unmerge", ">Convert Cell Type", @@ -820,6 +829,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option crate::dialogs::open_cell_options(app), "window.hideMenuItem" => { let Some(item) = p.get("item").and_then(Value::as_str) else { return Some(Err("missing item".into())) }; let hide = p.get("hidden").and_then(Value::as_bool).unwrap_or(true); diff --git a/crates/ui-egui/src/panels/cell_stroke.rs b/crates/ui-egui/src/panels/cell_stroke.rs new file mode 100644 index 000000000..d8b412ea2 --- /dev/null +++ b/crates/ui-egui/src/panels/cell_stroke.rs @@ -0,0 +1,286 @@ +//! Cell strokes: the edge proxy (a drawn 2 × 2 cell grid whose lines choose the cell edges a change +//! applies to) and the stroke fields shared by the Stroke panel and Cell Options › Strokes and Fills. +//! +//! The proxy's lines are the target range's top, bottom, left and right sides and the lines +//! between its rows (inner horizontal) and columns (inner vertical). A click toggles a line; a +//! double-click on a line chooses all outer or all inner lines; a triple-click chooses all lines, +//! or none when all were chosen. + +use designcraft_doc::StrokeType; +use egui::{Pos2, Rect, Sense, Stroke, StrokeKind, Ui, pos2, vec2}; +use serde_json::{Value, json}; + +use crate::DesignApp; +use crate::i18n::tr; +use crate::theme::Tokens; +use crate::widgets::{NumField, caption}; + +/// Edge names of the proxy lines, in proxy order; `table.setCell` takes them as `stroke.edges`. +pub const EDGE_NAMES: [&str; 6] = ["top", "bottom", "left", "right", "innerHorizontal", "innerVertical"]; + +/// Proxy line choice, in [`EDGE_NAMES`] order. +pub type Edges = [bool; 6]; + +pub const PROXY_SIZE: f32 = 64.0; +const PROXY_INSET: f32 = 8.0; + +const WEIGHTS: &[f64] = &[0.0, 0.25, 0.5, 0.75, 1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0, 9.0, 10.0, 20.0, 30.0, 40.0, 50.0, 100.0]; + +/// Stroke types offered for cell edges (all of them draw on table edges). +const TYPES: &[&str] = + &["solid", "dashed", "dotted", "thickThin", "thinThick", "thinThin", "thickThick", "thinThickThin", "thickThinThick", "wavy", "hashed"]; + +/// Whether the selection is table cells (or the text cursor is in a cell). +pub fn in_cells(app: &DesignApp) -> bool { + app.session.active().is_some_and(|d| d.selection.cells.is_some() || d.selection.text.is_some_and(|t| t.cell.is_some())) +} + +/// The chosen edges as the `edges` parameter. +pub fn edges_param(edges: &Edges) -> Value { + json!(EDGE_NAMES.iter().zip(edges).filter(|(_, on)| **on).map(|(name, _)| *name).collect::>()) +} + +/// Edges from an `edges` parameter (names); anything else chooses all. +pub fn edges_from(v: &Value) -> Edges { + match v.as_array() { + Some(names) => std::array::from_fn(|i| names.iter().any(|n| n.as_str() == EDGE_NAMES.get(i).copied())), + None => [true; 6], + } +} + +/// The proxy's lines (start, end) inside its rect, in [`EDGE_NAMES`] order. +pub fn proxy_lines(r: Rect) -> [(Pos2, Pos2); 6] { + let i = r.shrink(PROXY_INSET); + let c = i.center(); + [ + (i.left_top(), i.right_top()), + (i.left_bottom(), i.right_bottom()), + (i.left_top(), i.left_bottom()), + (i.right_top(), i.right_bottom()), + (pos2(i.left(), c.y), pos2(i.right(), c.y)), + (pos2(c.x, i.top()), pos2(c.x, i.bottom())), + ] +} + +/// The line under `p`, if any is within reach. +fn line_at(r: Rect, p: Pos2) -> Option { + proxy_lines(r) + .iter() + .enumerate() + .map(|(i, (a, b))| { + let t = if a == b { 0.0 } else { ((p - *a).dot(*b - *a) / (*b - *a).length_sq()).clamp(0.0, 1.0) }; + (i, p.distance(*a + (*b - *a) * t)) + }) + .filter(|(_, d)| *d <= PROXY_INSET - 1.0) + .min_by(|a, b| a.1.total_cmp(&b.1)) + .map(|(i, _)| i) +} + +/// The edge proxy. Returns whether the choice changed. +pub fn edge_proxy(ui: &mut Ui, id: &str, edges: &mut Edges, lang: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let (r, resp) = ui.allocate_exact_size(vec2(PROXY_SIZE, PROXY_SIZE), Sense::click()); + let label = tr(lang, "Cell edges").to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Other, true, &label)); + let resp = resp.on_hover_text(tr( + lang, + "Click a line to include or exclude it. Double-click selects all outer or all inner lines; triple-click selects or clears all.", + )); + let hit = resp.interact_pointer_pos().or_else(|| resp.hover_pos()).and_then(|p| line_at(r, p)); + let mut changed = false; + if resp.clicked() { + // A double-click counts only on the line of the click before it. + let last_key = ui.id().with((id, "proxy_last")); + let last: Option = ui.data(|d| d.get_temp(last_key)).flatten(); + if resp.triple_clicked() { + let all = edges.iter().all(|on| *on); + *edges = [!all; 6]; + changed = true; + } else if let Some(h) = hit { + if resp.double_clicked() && last == Some(h) { + let outer = h < 4; + for (i, on) in edges.iter_mut().enumerate() { + *on = (i < 4) == outer; + } + } else if let Some(on) = edges.get_mut(h) { + *on = !*on; + } + changed = true; + } + ui.data_mut(|d| d.insert_temp(last_key, hit)); + } + let p = ui.painter(); + p.rect_filled(r, 0.0, t.input); + p.rect_filled(r.shrink(PROXY_INSET), 0.0, egui::Color32::from_gray(0xf2)); + p.rect_stroke(r, 0.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + for (i, (a, b)) in proxy_lines(r).into_iter().enumerate() { + let on = edges.get(i).copied().unwrap_or(false); + let w = if hit == Some(i) { + 3.0 + } else if on { + 2.5 + } else { + 1.0 + }; + p.line_segment([a, b], Stroke::new(w, if on { t.accent } else { egui::Color32::from_gray(0x9a) })); + } + changed +} + +/// A built-in type by its `type` parameter name. +fn builtin(kind: &str) -> StrokeType { + match kind { + "dashed" => StrokeType::Dashed { pattern: Vec::new() }, + "dotted" => StrokeType::Dotted, + "thickThin" => StrokeType::ThickThin, + "thinThick" => StrokeType::ThinThick, + "thinThin" => StrokeType::ThinThin, + "thickThick" => StrokeType::ThickThick, + "thinThickThin" => StrokeType::ThinThickThin, + "thickThinThick" => StrokeType::ThickThinThick, + "wavy" => StrokeType::Wavy, + "hashed" => StrokeType::Hashed, + _ => StrokeType::Solid, + } +} + +/// The type's menu label (named styles by name). +fn type_label(lang: &str, v: &Value) -> String { + match serde_json::from_value::(v.clone()) { + Ok(StrokeType::Style { name }) => name, + Ok(k) => tr(lang, k.label()).to_string(), + Err(_) => String::new(), + } +} + +/// Weight, type, colour, tint, gap colour and gap tint for the stroke attributes in `v` +/// (`table.getCellStroke` keys; null shows as mixed). Returns the attribute the user changed. +pub fn stroke_fields(app: &mut DesignApp, ui: &mut Ui, id: &str, v: &Value) -> Option<(&'static str, Value)> { + let lang = app.ui.language.clone(); + let custom: Vec = app.session.active().map(|d| d.doc.stroke_styles.iter().map(|x| x.name.clone()).collect()).unwrap_or_default(); + let mut out = None; + let percent = |key: &str| v[key].as_f64().map(|t| (t * 100.0).round()); + egui::Grid::new((id, "cell_stroke")).num_columns(2).spacing(vec2(8.0, 6.0)).show(ui, |ui| { + caption(ui, tr(&lang, "Weight:")); + let wid = format!("{id}_weight"); + if let Some(w) = NumField::number(&wid, v["weight"].as_f64(), " pt", 3).width(86.0).spinner().presets(WEIGHTS).range(0.0, 1000.0).show(ui) { + out = Some(("weight", json!(w))); + } + ui.end_row(); + caption(ui, tr(&lang, "Type:")); + let shown = type_label(&lang, &v["type"]); + egui::ComboBox::from_id_salt((id, "type")).selected_text(crate::rtl::widget(ui, &shown)).width(140.0).show_ui(ui, |ui| { + for kind in TYPES { + let text = tr(&lang, builtin(kind).label()); + if ui.selectable_label(shown == text, crate::rtl::widget(ui, text)).clicked() { + out = Some(("type", json!(kind))); + } + } + if !custom.is_empty() { + ui.separator(); + } + for n in &custom { + if ui.selectable_label(shown == *n, n).clicked() { + out = Some(("type", json!({"kind": "style", "name": n}))); + } + } + }); + ui.end_row(); + for (label, key, tint_label, tint_key) in [("Color:", "color", "Tint:", "tint"), ("Gap Color:", "gapColor", "Gap Tint:", "gapTint")] { + caption(ui, tr(&lang, label)); + let mut picked = None; + super::swatch_picker(app, ui, &format!("{id}_{key}"), v[key].as_str().map(str::to_string), |_, n| picked = Some(n)); + if let Some(n) = picked { + out = Some((key, json!(n))); + } + ui.end_row(); + caption(ui, tr(&lang, tint_label)); + let tid = format!("{id}_{tint_key}"); + if let Some(t) = NumField::number(&tid, percent(tint_key), "%", 0).width(60.0).range(0.0, 100.0).show(ui) { + out = Some((tint_key, json!(t / 100.0))); + } + ui.end_row(); + } + }); + out +} + +/// The shown stroke attributes of `edges` of the target cells (null where they differ). +pub fn current(app: &mut DesignApp, edges: &Edges) -> Value { + if !edges.iter().any(|on| *on) { + return Value::Null; + } + app.session.execute("table.getCellStroke", &json!({"edges": edges_param(edges)})).unwrap_or(Value::Null) +} + +/// The Stroke panel for table cells: the proxy and the stroke fields, applied at once. +pub fn panel(app: &mut DesignApp, ui: &mut Ui) { + let key = egui::Id::new("cell_stroke_edges"); + let mut edges: Edges = ui.data(|d| d.get_temp(key)).unwrap_or([true; 6]); + let lang = app.ui.language.clone(); + if edge_proxy(ui, "stroke_panel", &mut edges, &lang) { + ui.data_mut(|d| d.insert_temp(key, edges)); + } + ui.add_space(6.0); + let values = current(app, &edges); + let any = edges.iter().any(|on| *on); + let changed = ui.add_enabled_ui(any, |ui| stroke_fields(app, ui, "csp", &values)).inner; + if let Some((k, v)) = changed + && let Err(e) = app.run("table.setCell", json!({"stroke": {k: v, "edges": edges_param(&edges)}})) + { + app.status(format!("Stroke: {e}")); + } +} + +#[cfg(test)] +mod tests { + use egui_kittest::{Harness, kittest::Queryable}; + + use super::*; + + #[test] + fn stroke_panel_proxy_limits_a_weight_change_to_the_chosen_cell_edges() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("frame.create", json!({"rect": [72, 72, 400, 400], "content": "text", "caret": true})).unwrap(); + let tid = app.run("table.insert", json!({"rows": 2, "cols": 2})).unwrap()["table"].as_u64().unwrap(); + app.run("table.select", json!({"what": "table"})).unwrap(); + let mut h = Harness::builder().with_size(vec2(320.0, 600.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let id = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(id)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(id, true)); + return; + } + crate::panels::properties::stroke_panel(app, ui); + }, + app, + ); + h.run_steps(3); + // Leave only the inner horizontal line chosen: click the others off. + let lines = proxy_lines(h.get_by_label("Cell edges").rect()); + for i in [0, 1, 2, 3, 5] { + let (a, b) = lines[i]; + let at = a + (b - a) * 0.3; + h.hover_at(at); + h.drag_at(at); + h.drop_at(at); + h.run_steps(3); + } + let weight = h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.value().as_deref() == Some("1 pt")).rect(); + h.hover_at(weight.center()); + h.drag_at(weight.center()); + h.drop_at(weight.center()); + h.run_steps(2); + h.key_press_modifiers(egui::Modifiers::COMMAND, egui::Key::A); + h.event(egui::Event::Text("4".into())); + h.key_press(egui::Key::Enter); + h.run_steps(3); + let st = h.state().session.active().unwrap(); + let t = st.doc.stories.values().find_map(|s| s.tables.get(&tid)).unwrap(); + let w = |r: usize, c: usize, side: usize| t.cell(r, c).unwrap().strokes[side].weight; + assert_eq!([w(0, 0, 2), w(0, 1, 2), w(1, 0, 0), w(1, 1, 0)], [4.0; 4], "inner horizontal edges"); + assert_eq!([w(0, 0, 0), w(0, 0, 1), w(0, 0, 3), w(0, 1, 1), w(1, 1, 2), w(1, 1, 3)], [1.0; 6], "the other edges keep their weight"); + } +} diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 86253f40c..a82d05f3c 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -1,5 +1,6 @@ //! Panels and shared panel helpers. +pub mod cell_stroke; pub mod conditions; pub mod datamerge; pub mod glyphs; diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index def6b4fb4..25de4b114 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1578,6 +1578,10 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { /// Stroke panel: weight, cap, miter limit, join, alignment, type, arrowheads, gap colour. pub fn stroke_panel(app: &mut DesignApp, ui: &mut egui::Ui) { + if super::cell_stroke::in_cells(app) { + super::cell_stroke::panel(app, ui); + return; + } let Some(i) = sel_info(app) else { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Select an object.")); return; diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index bdb6e9189..32d075b2e 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -351,6 +351,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Track Changes (`changes.track/list/acceptAll/rejectAll`, Type ▸ Track Changes): typing is marked added (highlighted on screen), deletions are kept but hidden from layout and exports until accepted or rejected. Per-change accept/reject and authors aren't there yet. - Track Changes: per-change Accept / Reject (`changes.accept`, `changes.reject`) and a Track Changes panel listing every change. - Move table rows and columns (`table.moveRow`, `table.moveColumn`, Table panel buttons; with whole rows or columns selected, drag them with the Type tool — `table.dropCells`). +- Cell strokes: with cells selected (or the cursor in a cell) the Stroke panel edits the chosen cell edges, picked on a drawn edge proxy (top, bottom, left, right, inner horizontal, inner vertical); Table › Cell Options › Strokes and Fills… does the same with the cell fill. Weight, every stroke type, colour, tint, gap colour and gap tint draw on screen and in PDF and round-trip through IDML (`table.setCell` `stroke.edges`, `table.getCellStroke`). - Distribute Rows Evenly (`table.distributeRows`, Table menu and panel): selected rows receive equal fixed heights preserving their composed total height, with undo/redo. Row-spanning merges and rows outside the composed frames are rejected; shrinking tall cells can overset their text. - Object States (`states.create/show/rename/release/list`, Object States panel): multi-state objects show one state on screen and in PDF. - Buttons (`button.set/clear/list`, Buttons and Forms panel): go to page / next / previous / first / last / URL as PDF link annotations. Form fields too (`form.set/clear/list`: text fields, check boxes, combo and list boxes, signature fields; required, multiline, defaults) exported as AcroForm widgets through an incremental update (not in PDF/X).