Merge table-cell-strokes: cell edge strokes in the Stroke panel and Cell Options, with gap colour and tint (#370)

Conflicts: ui-egui dialogs.rs (next's numberingSection arm and the branch's cellOptions arm; the branch's
copies of the dialog test helpers already on next dropped, its cell options test kept); idml tests.rs and
the i18n tables appended at the end on both sides (both kept; the branch's Gap Tint: and Tint: rows that
next already had dropped).
This commit is contained in:
aiongg
2026-10-10 20:30:54 +00:00
23 changed files with 1019 additions and 86 deletions
+30 -2
View File
@@ -169,11 +169,20 @@ pub struct CellStrokeAttrs {
pub tint: Option<f32>,
#[serde(skip_serializing_if = "Option::is_none")]
pub kind: Option<crate::item::StrokeType>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap_color: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub gap_tint: Option<f32>,
}
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),
}
}
}
+20 -1
View File
@@ -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,
}
}
}
+275 -70
View File
@@ -29,7 +29,7 @@ fn style_specs() -> Vec<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<CommandSpec> {
"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<Value> {
})
}
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<EdgeSel> {
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<Option<f64>> {
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<Option<String>> {
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<StrokeEdit> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
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<Value> {
}
// 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<Value> {
})
}
/// 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<Value> {
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<Value>, 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<Value>; 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<AltFills> {
fn options(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
// ---------- cell and table styles ----------
fn stroke_param(v: Option<&Value>) -> Option<designcraft_doc::CellStroke> {
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<Option<CellStroke>> {
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<CellStroke>, 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<Value> {
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<Item = (&'a str, Optio
fn cell_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
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<CellStroke>) {
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<Value> {
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<Value> {
fn table_style_edit(s: &mut Session, p: &Value) -> Result<Value> {
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();
+65
View File
@@ -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);
}
}
+10
View File
@@ -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) {
+11 -1
View File
@@ -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"))),
}
}
+29
View File
@@ -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#"<Cell Name="1:1">"#,
r#"<Cell Name="1:1" LeftEdgeStrokeType="StrokeStyle/$ID/Dotted" LeftEdgeStrokeGapColor="Color/Brand" LeftEdgeStrokeGapTint="30" RightEdgeStrokeGapColor="Color/Paper">"#,
);
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));
}
+31 -5
View File
@@ -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<f32> = 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);
+30
View File
@@ -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());
+22 -7
View File
@@ -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,
}
}
+131
View File
@@ -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<Value, String> {
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<Value, String> {
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<Value, String> {
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<Value, String> {
}
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"] {
+15
View File
@@ -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.
+8
View File
@@ -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.",
"انقر على خط لتضمينه أو استبعاده. النقر المزدوج يحدد كل الخطوط الخارجية أو كل الخطوط الداخلية؛ والنقر الثلاثي يحدد الكل أو يلغي تحديده.",
),
];
+8
View File
@@ -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"),
+8
View File
@@ -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.",
),
];
+8
View File
@@ -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).
+8
View File
@@ -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.",
),
];
+8
View File
@@ -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.
+10
View File
@@ -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<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("menus", json!({"query": ""})));
Ok(Value::Null)
}
"app.cellOptionsDialog" => 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);
+286
View File
@@ -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::<Vec<_>>())
}
/// 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<usize> {
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<usize> = 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::<StrokeType>(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<String> = 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::<bool>(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");
}
}
+1
View File
@@ -1,5 +1,6 @@
//! Panels and shared panel helpers.
pub mod cell_stroke;
pub mod conditions;
pub mod datamerge;
pub mod glyphs;
+4
View File
@@ -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;
+1
View File
@@ -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).