From 85932f18ab9d5ec903f7842559f15dba6d615147 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 11:29:19 +0200 Subject: [PATCH 1/8] Paragraph rules: Type, Gap Color and Tint, Keep In Frame A paragraph rule has InDesign's Type: solid, the thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document. Composition draws it as that shape in the rule's box (dashes and dots as boxes and circles, stripes, waves and hashes as their outlines), with Gap Color and Gap Tint underneath; a pattern that can't be laid out draws solid. Keep In Frame keeps a rule above the first line of a frame inside the frame. The Paragraph Rules page (paragraph style options and the Paragraph Rules dialog) is laid out as InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent, Keep In Frame. IDML reads and writes RuleAboveType/RuleBelowType, the gap colour and tint, and KeepRuleAboveInFrame. A rule's new settings at their defaults stay out of the JSON. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/compose/src/lib.rs | 59 +++++++++++++++--- crates/compose/src/tests.rs | 36 +++++++++++ crates/doc/src/attrs.rs | 42 ++++++++++++- crates/idml/src/export.rs | 6 ++ crates/idml/src/import.rs | 13 ++++ crates/idml/src/tests.rs | 4 ++ crates/ui-egui/src/dialogs.rs | 100 +++++++++++++++++++++++++++++-- crates/ui-egui/src/i18n.rs | 1 + crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 2 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 2 + crates/ui-egui/src/i18n/pl.rs | 2 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 2 + crates/ui-egui/src/i18n/uk.rs | 1 + 17 files changed, 259 insertions(+), 15 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index a7b5105d3..33918dc8f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -348,6 +348,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-21** · Paragraph rules have InDesign's Type (solid, thick/thin stripes, dashed, dotted, straight hash, wavy, or a stroke style of the document), Gap Color and Gap Tint, and Keep In Frame (rule above); the Paragraph Rules page is laid out as InDesign's (Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, Left and Right Indent). IDML reads and writes them (`RuleAboveType`, `RuleBelowGapColor`, `KeepRuleAboveInFrame`…). Found in the Learn Skills Daily course, chapter 20. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 4a2c66397..1920b3020 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1872,12 +1872,11 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, &above); - ft.decos.push(Deco { - rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), - color, - tint, - ..Deco::default() - }); + // Keep In Frame: not above the frame's text area. + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); + let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; + let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); + ft.decos.extend(rule_decos(doc, r, rect, color, tint)); } } if pp.rule_below.on @@ -1888,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, &below); - ft.decos.push(Deco { rect: Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint, ..Deco::default() }); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -4062,6 +4061,52 @@ fn hyphenation_points( out } +/// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under +/// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid +/// out along the rule draws solid. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec { + use designcraft_doc::StrokeType; + let kind = doc.stroke_kind(&r.kind); + let (w, len) = (rect.height(), rect.width()); + let mut out = Vec::new(); + if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { + out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() }); + } + let y = rect.center().y; + let mut line = designcraft_geom::BezPath::new(); + line.move_to((rect.x0, y)); + line.line_to((rect.x1, y)); + let path = match &kind { + StrokeType::Dashed { pattern } if StrokeType::expandable_dashes(pattern, len) => { + let mut p = designcraft_geom::BezPath::new(); + let (mut x, mut k, mut on) = (rect.x0, 0, true); + while x < rect.x1 { + let step = pattern[k % pattern.len()]; + if on && step > 0.0 { + let r = Rect::new(x, rect.y0, (x + step).min(rect.x1), rect.y1); + p.extend(designcraft_geom::Shape::path_elements(&r, 0.1)); + } + x += step; + k += 1; + on = !on; + } + Some(p) + } + StrokeType::Dotted if StrokeType::expandable_dashes(&[w, w], len) => { + let mut p = designcraft_geom::BezPath::new(); + let mut x = rect.x0 + w / 2.0; + while x <= rect.x1 - w / 2.0 + 1e-9 { + p.extend(designcraft_geom::Shape::path_elements(&designcraft_geom::kurbo::Circle::new((x, y), w / 2.0), 0.1)); + x += 2.0 * w; + } + Some(p) + } + k => k.outline(&line, w, 0.05), + }; + out.push(Deco { rect, color, tint, path, ..Deco::default() }); + out +} + /// `spanned`: a paragraph spans the frame's columns, so the lines move together. fn vertical_justify(ft: &mut FrameText, f: &FrameSpec, spanned: bool) { if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top { diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index ed4c7c476..e394e4fe1 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3694,6 +3694,42 @@ fn drop_cap_scales_with_its_point_size() { assert!(b2 - fitted * 1.5 < b0 - base[0].ascent, "a 15 pt cap rises above the first line"); } +/// Paragraph Rules › Type: a wavy, dashed or dotted rule is drawn as its shape (filled paths in +/// the rule's box), a striped one as its bands; Gap Color goes under it. Keep In Frame keeps a rule +/// above the first line inside the frame. +#[test] +fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { + use designcraft_doc::{Rule, StrokeType}; + let rule = |kind: StrokeType| Rule { on: true, weight: 4.0, kind, ..Default::default() }; + let decos = |r: Rule| { + let para = ParaAttrs { rule_below: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Recipe Categories", Rect::new(0.0, 0.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos.clone() + }; + let solid = decos(rule(StrokeType::Solid)); + assert!(solid.len() == 1 && solid[0].path.is_none()); + let wavy = decos(rule(StrokeType::Wavy)); + let p = wavy[0].path.as_ref().expect("a wavy rule is a shape"); + assert!(designcraft_geom::Shape::area(p).abs() > 0.0 && (wavy[0].rect.height() - 4.0).abs() < 1e-9); + let subpaths = |p: &designcraft_geom::BezPath| p.elements().iter().filter(|e| matches!(e, designcraft_geom::PathEl::MoveTo(_))).count(); + let dashed = decos(rule(StrokeType::Dashed { pattern: vec![12.0, 4.0] })); + assert!(subpaths(dashed[0].path.as_ref().unwrap()) > 5, "one box per dash"); + let dotted = decos(rule(StrokeType::Dotted)); + assert!(subpaths(dotted[0].path.as_ref().unwrap()) > 10, "one dot per 2 weights"); + let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); + assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); + assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // Keep In Frame: a rule above raised out of the frame comes back to its top. + let above = |keep: bool| { + let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + compose_story(&d, sid, &ComposeOptions::default()).frame(fid).unwrap().decos[0].rect + }; + assert!(above(false).y0 < 50.0, "{:?}", above(false)); + assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); +} + /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the /// rule above, the last character's for the rule below. A swatch colour is used as is. #[test] diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index 077637672..3fb2a2a00 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -776,11 +776,46 @@ pub struct Rule { pub offset: f64, pub left_indent: f64, pub right_indent: f64, + /// Type: the rule's stroke type (solid, dashed, wavy…, or a stroke style of the document). + #[serde(rename = "type", skip_serializing_if = "rule_solid")] + pub kind: crate::item::StrokeType, + /// Gap Color (a swatch; [None]: none) and Gap Tint: under the gaps of a type that has them. + #[serde(skip_serializing_if = "rule_no_gap")] + pub gap_color: String, + #[serde(skip_serializing_if = "rule_full_tint")] + pub gap_tint: f32, + /// Keep In Frame: a rule above the first line of a frame stays inside the frame. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub keep_in_frame: bool, +} + +// A rule's settings at their defaults are left out of the document (and of the attributes). +fn rule_solid(k: &crate::item::StrokeType) -> bool { + *k == crate::item::StrokeType::Solid +} +fn rule_no_gap(c: &str) -> bool { + c == designcraft_color::swatch::NONE +} +fn rule_full_tint(t: &f32) -> bool { + *t == 1.0 } impl Default for Rule { fn default() -> Self { - Rule { on: false, weight: 1.0, color: TEXT_COLOR.into(), tint: 1.0, column_width: true, offset: 0.0, left_indent: 0.0, right_indent: 0.0 } + Rule { + on: false, + weight: 1.0, + color: TEXT_COLOR.into(), + tint: 1.0, + column_width: true, + offset: 0.0, + left_indent: 0.0, + right_indent: 0.0, + kind: crate::item::StrokeType::Solid, + gap_color: designcraft_color::swatch::NONE.into(), + gap_tint: 1.0, + keep_in_frame: false, + } } } @@ -1385,8 +1420,11 @@ impl ParaAttrs { let serde_json::Value::Object(patch) = value else { return self.set_json(key, value) }; let mut merged = serde_json::to_value(rule).map_err(|e| format!("{key}: {e}"))?; let serde_json::Value::Object(fields) = &mut merged else { return Err(format!("{key}: not an object")) }; + // Every field, those at their defaults (left out of the JSON) included. + const RULE_FIELDS: [&str; 12] = + ["on", "weight", "color", "tint", "columnWidth", "offset", "leftIndent", "rightIndent", "type", "gapColor", "gapTint", "keepInFrame"]; for (k, v) in patch { - if !fields.contains_key(k) { + if !RULE_FIELDS.contains(&k.as_str()) { return Err(format!("{key}: unknown rule field `{k}`")); } fields.insert(k.clone(), v.clone()); diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index 86fef8e46..24822cecf 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1746,6 +1746,12 @@ impl<'a> Ex<'a> { el.set(&format!("{k}Offset"), num(r.offset)); el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); + el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); + if k == "RuleAbove" { + el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); + } props.push(if r.color == designcraft_doc::TEXT_COLOR { p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR) } else { diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index d770aef79..58cd70271 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1959,6 +1959,19 @@ impl<'r> Importer<'r> { r.offset = num(5).unwrap_or(0.0); r.left_indent = num(6).unwrap_or(0.0); r.right_indent = num(7).unwrap_or(0.0); + // Type, gap colour and tint, Keep In Frame (rule above). + if let Some(t) = self.para_setting(e, &format!("{k}Type")) { + r.kind = names::stroke_type_in(t.trim()); + } + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")).filter(|c| c.contains('/')) { + r.gap_color = self.swatch_ref(c.trim()); + } + if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { + r.gap_tint = t; + } + if k == "RuleAbove" { + r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); + } if let Some(c) = get(2) { let c = c.trim(); if c == designcraft_doc::TEXT_COLOR { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index 035333da4..e03eeffb7 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -327,6 +327,10 @@ fn indesign_text_defaults() -> Vec { "RuleBelow": "false" => false, "true" => true, |r| r.0.rule_below.on; "RuleBelowColor": "Text Color" => designcraft_doc::TEXT_COLOR, "Color/Black" => "[Black]", |r| r.0.rule_below.color; "RuleBelowTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.tint; + "RuleBelowType": "StrokeStyle/$ID/Solid" => designcraft_doc::StrokeType::Solid, "StrokeStyle/$ID/Wavy" => designcraft_doc::StrokeType::Wavy, |r| r.0.rule_below.kind; + "RuleBelowGapColor": "Swatch/None" => "[None]", "Color/Black" => "[Black]", |r| r.0.rule_below.gap_color; + "RuleBelowGapTint": "-1" => 1.0f32, "50" => 0.5f32, |r| r.0.rule_below.gap_tint; + "KeepRuleAboveInFrame": "false" => false, "true" => true, |r| r.0.rule_above.keep_in_frame; "ParagraphShadingOn": "false" => false, "true" => true, |r| r.0.shading_on; "ParagraphShadingColor": "Color/Black" => "[Black]", "Swatch/None" => "[None]", |r| r.0.shading_color; "ParagraphShadingTint": "20" => 0.2f32, "50" => 0.5f32, |r| r.0.shading_tint; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 8b4043787..de57d77e9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5882,22 +5882,64 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); ui.add_space(6.0); let on = rule["on"].as_bool().unwrap_or(false); + let kind: designcraft_doc::StrokeType = serde_json::from_value(rule["type"].clone()).unwrap_or_default(); + let doc = app.session.active().map(|st| &st.doc); + // As InDesign's: Weight and Type, Color and Tint, Gap Color and Gap Tint, Width and Offset, + // Left and Right Indent, then Keep In Frame (rule above). ui.add_enabled_ui(on, |ui| { egui::Grid::new(("rules", which)).num_columns(4).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("weight"), rule["weight"].as_f64(), " pt", 80.0, 2)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::number(&key("weight"), rule["weight"].as_f64(), " pt", 2).width(80.0).spinner().range(0.0, 1000.0).show(ui) + }) { d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0))); } + crate::rtl::label(ui, crate::i18n::tr(lang, "Type:")); + let custom: Vec = doc.map_or_else(Vec::new, |doc| doc.stroke_styles.iter().map(|s| s.name.clone()).collect()); + let shown = match &kind { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + egui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { + let label = match &k { + designcraft_doc::StrokeType::Style { name } => name.clone(), + k => crate::i18n::tr(lang, k.label()).to_string(), + }; + if ui.selectable_label(k == kind, crate::rtl::widget(ui, label)).clicked() { + d.fields.insert(key("type"), serde_json::to_value(&k).unwrap_or_default()); + } + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = app.session.active().and_then(|st| swatch_menu(ui, &st.doc, ("rule_color", which), &color, 130.0)) { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) { d.fields.insert(key("color"), json!(w)); } - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("tint"), rule["tint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) { d.fields.insert(key("tint"), json!((v / 100.0).clamp(0.0, 1.0))); } + ui.end_row(); + // A solid rule has no gaps. + let gaps = !matches!(kind, designcraft_doc::StrokeType::Solid); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Color:")); + let gap = rule["gapColor"].as_str().unwrap_or(designcraft_color::swatch::NONE).to_string(); + ui.add_enabled_ui(gaps, |ui| { + if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_gap", which), &gap, 130.0)) { + d.fields.insert(key("gapColor"), json!(w)); + } + }); + crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); + ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), rule["gapTint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) + { + d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); + } + }); + ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Width:")); let column = rule["columnWidth"].as_bool().unwrap_or(true); egui::ComboBox::from_id_salt(("rule_width", which)) @@ -5910,23 +5952,50 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & } } }); - ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key("offset"), rule["offset"].as_f64(), v_units, 80.0)) { + if let Some(v) = sized(ui, &mut |ui| { + crate::widgets::NumField::measure(&key("offset"), rule["offset"].as_f64(), v_units).width(80.0).spinner().show(ui) + }) { d.fields.insert(key("offset"), json!(v)); } ui.end_row(); for (label, field) in [("Left Indent:", "leftIndent"), ("Right Indent:", "rightIndent")] { crate::rtl::label(ui, crate::i18n::tr(lang, label)); - if let Some(v) = sized(ui, &mut |ui| crate::widgets::measure(ui, &key(field), rule[field].as_f64(), h_units, 80.0)) { + if let Some(v) = + sized(ui, &mut |ui| crate::widgets::NumField::measure(&key(field), rule[field].as_f64(), h_units).width(80.0).spinner().show(ui)) + { d.fields.insert(key(field), json!(v)); } } ui.end_row(); }); + if which == "ruleAbove" { + let mut keep = rule["keepInFrame"].as_bool().unwrap_or(false); + if ui.checkbox(&mut keep, crate::rtl::widget(ui, crate::i18n::tr(lang, "Keep In Frame"))).changed() { + d.fields.insert(key("keepInFrame"), json!(keep)); + } + } }); } +/// The built-in stroke types a paragraph rule can have (Paragraph Rules › Type). +fn rule_types() -> Vec { + use designcraft_doc::StrokeType as T; + vec![ + T::Solid, + T::ThickThick, + T::ThickThin, + T::ThickThinThick, + T::ThinThick, + T::ThinThickThin, + T::ThinThin, + T::Dashed { pattern: vec![12.0, 4.0] }, + T::Dotted, + T::Hashed, + T::Wavy, + ] +} + /// Document Footnote Options: Numbering and Formatting / Layout tabs. fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { @@ -7493,6 +7562,25 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap + /// colour, from Paragraph Rules › Type and Gap Color. + #[test] + fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index efe089ef9..a574af928 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -36,6 +36,7 @@ pub const LANGUAGES: &[(&str, &str)] = &[ /// English → [German, French, Spanish, Japanese, Simplified Chinese]. const TABLE: &[(&str, [&str; 5])] = &[ + ("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]), // Menus. ("File", ["Datei", "Fichier", "Archivo", "ファイル", "文件"]), ("Edit", ["Bearbeiten", "Édition", "Edición", "編集", "编辑"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index b050de38b..9e7f9594d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "قص"), ("Copy", "نسخ"), ("Paste", "لصق"), + ("Keep In Frame", "الإبقاء داخل الإطار"), ("Paste Vector Art", "لصق رسم متجه"), ("Baseline Options", "خيارات خط الأساس"), ("Fixed Number", "عدد ثابت"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index e77e72a1c..d1987e741 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Vyjmout"), ("Copy", "Kopírovat"), ("Paste", "Vložit"), + ("Keep In Frame", "Zachovat v rámečku"), + ("Gap Tint:", "Odstín mezery:"), ("Reverse Path", "Obrátit cestu"), ("Set Anchor", "Nastavit kotvu"), ("Clear", "Vymazat"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index a918a9829..e1858b6d8 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -28,6 +28,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Taglia"), ("Copy", "Copia"), ("Paste", "Incolla"), + ("Keep In Frame", "Mantieni nella cornice"), ("Paste Vector Art", "Incolla grafica vettoriale"), ("Baseline Options", "Opzioni linea di base"), ("Fixed Number", "Numero fisso"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index d938c2dc7..5c0afb06c 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -31,6 +31,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "ამოჭრა"), ("Copy", "კოპირება"), ("Paste", "ჩასმა"), + ("Keep In Frame", "ჩარჩოში შენარჩუნება"), + ("Gap Tint:", "ღრეჩოს ტონი:"), ("Inches Decimal", "ინჩები (ათობითი)"), ("Back", "უკან"), ("Warning", "გაფრთხილება"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 68d685d99..e563c58a8 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -29,6 +29,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Wytnij"), ("Copy", "Kopiuj"), ("Paste", "Wklej"), + ("Keep In Frame", "Zachowaj w ramce"), + ("Gap Tint:", "Odcień odstępu:"), ("Clear", "Wyczyść"), ("Duplicate", "Powiel"), ("Select All", "Zaznacz wszystko"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 8521de594..608656aed 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Recortar"), ("Copy", "Copiar"), ("Paste", "Colar"), + ("Keep In Frame", "Manter no quadro"), ("Paste Vector Art", "Colar arte vetorial"), ("Baseline Options", "Opções de linha de base"), ("Fixed Number", "Número fixo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 251555ca5..8876dd677 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -33,6 +33,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вырезать"), ("Copy", "Копировать"), ("Paste", "Вставить"), + ("Keep In Frame", "Сохранить во фрейме"), + ("Gap Tint:", "Оттенок пробелов:"), ("Clear", "Очистить"), ("Duplicate", "Дублировать"), ("Select All", "Выделить все"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index dd4a1434d..fc34659e9 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Cut", "Вирізати"), ("Copy", "Копіювати"), ("Paste", "Вставити"), + ("Keep In Frame", "Зберегти у фреймі"), ("Paste Vector Art", "Вставити векторну графіку"), ("Baseline Options", "Параметри базової лінії"), ("Fixed Number", "Фіксована кількість"), From 46d830cb3b753ee0b52eb77a0956ababd040d059 Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 13:33:24 +0200 Subject: [PATCH 2/8] Paragraph rules: gap in Text Color, IDML overrides from the new fields alone, Text Color in the Color menu Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 11 ++++-- crates/compose/src/tests.rs | 3 ++ crates/idml/src/import.rs | 8 +++- crates/idml/src/tests.rs | 27 +++++++++++++ crates/ui-egui/src/dialogs.rs | 74 ++++++++++++++++++++++++++++++++++- 5 files changed, 115 insertions(+), 8 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 1920b3020..c97171208 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1876,7 +1876,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); - ft.decos.extend(rule_decos(doc, r, rect, color, tint)); + ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above)); } } if pp.rule_below.on @@ -1887,7 +1887,7 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C let y = l.baseline + r.offset; let (x0, x1) = if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }; let (color, tint) = rule_color(r, &below); - ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), color, tint)); + ft.decos.extend(rule_decos(doc, r, Rect::new(x0 + r.left_indent, y, x1 - r.right_indent, y + r.weight), (color, tint), &below)); } // The end of a split block: text after it continues below its deepest sub-column, which // are balanced first. A block that ends the story fills its sub-columns in turn. @@ -4064,13 +4064,16 @@ fn hyphenation_points( /// A paragraph rule drawn in `rect` (its length and weight) with its Type: the gap colour under /// it, then the line, as dashes, dots, stripes, waves or hash marks. A pattern that can't be laid /// out along the rule draws solid. -fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, color: String, tint: f32) -> Vec { +/// `(color, tint)` is the rule's (Text Color already resolved); `text`, the colour of the text +/// the rule goes with, for a gap in Text Color. +fn rule_decos(doc: &Document, r: &designcraft_doc::Rule, rect: Rect, (color, tint): (String, f32), text: &(String, f32)) -> Vec { use designcraft_doc::StrokeType; let kind = doc.stroke_kind(&r.kind); let (w, len) = (rect.height(), rect.width()); let mut out = Vec::new(); if !matches!(kind, StrokeType::Solid) && r.gap_color != designcraft_color::swatch::NONE && w > 0.0 && len > 0.0 { - out.push(Deco { rect, color: r.gap_color.clone(), tint: r.gap_tint, ..Deco::default() }); + let gap = if r.gap_color == designcraft_doc::TEXT_COLOR { text.0.clone() } else { r.gap_color.clone() }; + out.push(Deco { rect, color: gap, tint: r.gap_tint, ..Deco::default() }); } let y = rect.center().y; let mut line = designcraft_geom::BezPath::new(); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index e394e4fe1..bd7235a69 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3719,6 +3719,9 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { let gap = decos(Rule { gap_color: "[Black]".into(), gap_tint: 0.3, ..rule(StrokeType::Dotted) }); assert_eq!((gap.len(), gap[0].color.as_str(), gap[0].tint, gap[0].path.is_none()), (2, "[Black]", 0.3, true), "the gap colour under the dots"); assert_eq!(decos(Rule { gap_color: "[Black]".into(), ..rule(StrokeType::Solid) }).len(), 1, "a solid rule has no gaps"); + // A gap in Text Color takes the text's colour, whatever swatch the rule itself has. + let gap = decos(Rule { color: "[Paper]".into(), gap_color: designcraft_doc::TEXT_COLOR.into(), ..rule(StrokeType::Dotted) }); + assert_eq!((gap[0].color.as_str(), gap[1].color.as_str()), ("[Black]", "[Paper]")); // Keep In Frame: a rule above raised out of the frame comes back to its top. let above = |keep: bool| { let r = Rule { on: true, weight: 2.0, offset: 30.0, keep_in_frame: keep, ..Default::default() }; diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 58cd70271..827b39ea8 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1939,7 +1939,12 @@ impl<'r> Importer<'r> { // A style or range can set some of a rule's settings (such as only its colour); the others // come from its paragraph style chain, so the rule is whole. let fields = ["", "LineWeight", "Color", "Tint", "Width", "Offset", "LeftIndent", "RightIndent"]; - if !fields.iter().any(|f| e.prop(&format!("{k}{f}")).is_some()) { + // Any of them, or of the type, gap and Keep In Frame settings, makes it an override (the + // rest then come from the style chain, or the defaults: off, 1 pt). + let more = ["Type", "GapColor", "GapTint"]; + let own = fields.iter().chain(&more).any(|f| e.prop(&format!("{k}{f}")).is_some()) + || (k == "RuleAbove" && e.prop("KeepRuleAboveInFrame").is_some()); + if !own { return None; } let v: Vec> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect(); @@ -1947,7 +1952,6 @@ impl<'r> Importer<'r> { let num = |i: usize| get(i).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite()); let on = get(0).map(|v| v.trim() == "true"); let weight = num(1); - on.or(weight.map(|_| false))?; let mut r = Rule { on: on.unwrap_or(false), ..Rule::default() }; if let Some(w) = weight { r.weight = w; diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index e03eeffb7..51ce9e7ff 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3705,6 +3705,33 @@ fn style_names_ending_in_a_space_and_partial_rule_overrides_import() { assert_ne!(black.color, white.color, "only the colour is overridden"); } +#[test] +fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { + let styles = STYLES.replace( + r#" "#, + r#" + $ID/[No paragraph style] + + + ParagraphStyle/Base + + + $ID/[No paragraph style] + + + $ID/[No paragraph style] + + "#, + ); + let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); + let style = |n: &str| d.styles.paragraph.iter().find(|s| s.name == n).unwrap().para.clone(); + let wavy = style("Wavy").rule_below.expect("the type alone overrides the rule"); + assert_eq!((wavy.on, wavy.weight, wavy.kind), (true, 3.0, designcraft_doc::StrokeType::Wavy), "the rest from the based-on style"); + let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); + assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); + assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); +} + #[test] fn missing_linked_graphics_show_their_preview_and_keep_their_link() { // InDesign's preview of the missing photo (a PNG header is all the importer reads: 4 × 3). diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index de57d77e9..3d984fc13 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4500,8 +4500,30 @@ fn para_sides(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, pv: &Value, key: &s /// A swatch menu with a colour chip per swatch (hidden swatches left out); returns the swatch /// picked. fn swatch_menu(ui: &mut egui::Ui, doc: &designcraft_doc::Document, id: impl egui::AsIdSalt, current: &str, width: f32) -> Option { + swatch_menu_with(ui, doc, id, current, width, None) +} + +/// [`swatch_menu`] with, given its translated label, Text Color first (a paragraph rule's +/// colour: the text's own), as InDesign's Paragraph Rules › Color. +fn swatch_menu_with( + ui: &mut egui::Ui, + doc: &designcraft_doc::Document, + id: impl egui::AsIdSalt, + current: &str, + width: f32, + text_color: Option<&str>, +) -> Option { let mut picked = None; - egui::ComboBox::from_id_salt(id).selected_text(current).width(width).show_ui(ui, |ui| { + let shown = match text_color { + Some(label) if current == designcraft_doc::TEXT_COLOR => label, + _ => current, + }; + egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + if let Some(label) = text_color + && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() + { + picked = Some(designcraft_doc::TEXT_COLOR.to_string()); + } for w in doc.swatches.iter().filter(|w| !w.hidden) { let (c, g) = crate::widgets::swatch_colors(doc, &w.name, 1.0); ui.horizontal(|ui| { @@ -5914,7 +5936,8 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); let color = rule["color"].as_str().unwrap_or("").to_string(); - if let Some(w) = doc.and_then(|doc| swatch_menu(ui, doc, ("rule_color", which), &color, 130.0)) { + let text_color = crate::i18n::tr(lang, "Text Color"); + if let Some(w) = doc.and_then(|doc| swatch_menu_with(ui, doc, ("rule_color", which), &color, 130.0, Some(text_color))) { d.fields.insert(key("color"), json!(w)); } crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); @@ -7562,6 +7585,53 @@ mod tests { assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); } + /// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can + /// go back to it; Gap Color lists only swatches. + #[test] + fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } + } + /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap /// colour, from Paragraph Rules › Type and Gap Color. #[test] From 8e980b1f87b974af3c9933f5f8684fb21d28f3b9 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 21 Oct 2026 23:52:23 +0200 Subject: [PATCH 3/8] Paragraph rules: Gap Tint shows 100% at its default A full gap tint is left out of the rule's JSON, so the field read nothing even after a Gap Color was chosen. It now reads a missing gap tint as 100%. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 3d984fc13..9e03c6b09 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -5956,9 +5956,9 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & }); crate::rtl::label(ui, crate::i18n::tr(lang, "Gap Tint:")); ui.add_enabled_ui(gaps && gap != designcraft_color::swatch::NONE, |ui| { - if let Some(v) = - sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), rule["gapTint"].as_f64().map(|t| t * 100.0), "%", 80.0, 0)) - { + // A full gap tint is left out of the rule's JSON: it reads 100%. + let gap_tint = rule["gapTint"].as_f64().unwrap_or(1.0) * 100.0; + if let Some(v) = sized(ui, &mut |ui| crate::widgets::number(ui, &key("gapTint"), Some(gap_tint), "%", 80.0, 0)) { d.fields.insert(key("gapTint"), json!((v / 100.0).clamp(0.0, 1.0))); } }); @@ -7651,6 +7651,29 @@ mod tests { assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); } + /// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). + #[test] + fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); + } + #[test] fn paragraph_style_options_edit_one_rule_field() { let (mut app, rule) = ruled_paragraph(); From 02749d3b7b9b9297b2c8615213416fc5c8cb93ba Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 22 Oct 2026 09:57:50 +0200 Subject: [PATCH 4/8] Paragraph rules: Keep In Frame stops at the frame's edge; Text Color gaps import and export Keep In Frame holds a rule above inside the frame (its top), not below the top inset. A rule's gap colour in Text Color (`Text Color` or `$ID/Text Color`) imports as Text Color, as the rule's colour already did, and exports as a string property rather than as no swatch. Co-Authored-By: Claude Opus 5.5 --- crates/compose/src/lib.rs | 5 +++-- crates/compose/src/tests.rs | 11 +++++++++++ crates/idml/src/export.rs | 7 ++++++- crates/idml/src/import.rs | 26 +++++++++++++++++--------- crates/idml/src/tests.rs | 12 ++++++++++++ 5 files changed, 49 insertions(+), 12 deletions(-) diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index 111910929..0e875e77b 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1873,8 +1873,9 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) .unwrap_or((0.0, 0.0)); let (color, tint) = rule_color(r, &above); - // Keep In Frame: not above the frame's text area. - let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0); + // Keep In Frame: not above the frame, as InDesign's (its top, not the text area's + // below the top inset). + let top = frames.get(fi).map_or(f64::NEG_INFINITY, |f| f.area.y0 - if f.vertical { 0.0 } else { f.opts.inset[0] }); let y = if r.keep_in_frame && y - r.weight < top { top + r.weight } else { y }; let rect = Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y); ft.decos.extend(rule_decos(doc, r, rect, (color, tint), &above)); diff --git a/crates/compose/src/tests.rs b/crates/compose/src/tests.rs index 6ea012270..2fc53cc0b 100644 --- a/crates/compose/src/tests.rs +++ b/crates/compose/src/tests.rs @@ -3731,6 +3731,17 @@ fn paragraph_rules_have_a_type_a_gap_colour_and_keep_in_frame() { }; assert!(above(false).y0 < 50.0, "{:?}", above(false)); assert!((above(true).y0 - 50.0).abs() < 1e-6, "{:?}", above(true)); + // With a top inset the rule may still rise into it: raised out of the frame, it comes back + // to the frame's edge, not to the text area's below the inset. + let r = Rule { on: true, weight: 2.0, offset: 60.0, keep_in_frame: true, ..Default::default() }; + let para = ParaAttrs { rule_above: Some(r), ..Default::default() }; + let (mut d, sid, fid) = doc_with("Title", Rect::new(0.0, 50.0, 300.0, 200.0), para); + if let Some(tf) = d.item_mut(fid).and_then(|it| it.text_frame_mut()) { + tf.options.inset = [20.0, 0.0, 0.0, 0.0]; + } + let cs = compose_story(&d, sid, &ComposeOptions::default()); + let rule = cs.frame(fid).unwrap().decos[0].rect; + assert!((rule.y0 - 50.0).abs() < 1e-6, "kept at the frame's top, in the inset: {rule:?}"); } /// A rule in Text Color takes the colour of the paragraph's text: the first character's for the diff --git a/crates/idml/src/export.rs b/crates/idml/src/export.rs index b247ac709..9f8298760 100644 --- a/crates/idml/src/export.rs +++ b/crates/idml/src/export.rs @@ -1747,7 +1747,12 @@ impl<'a> Ex<'a> { el.set(&format!("{k}LeftIndent"), num(r.left_indent)); el.set(&format!("{k}RightIndent"), num(r.right_indent)); el.set(&format!("{k}Type"), names::stroke_type_out(&r.kind)); - el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + // A gap in Text Color is a string property, as the rule's own colour is. + if r.gap_color == designcraft_doc::TEXT_COLOR { + props.push(p(&format!("{k}GapColor"), "string", designcraft_doc::TEXT_COLOR)); + } else { + el.set(&format!("{k}GapColor"), self.sw(&r.gap_color)); + } el.set(&format!("{k}GapTint"), pct(r.gap_tint as f64)); if k == "RuleAbove" { el.set("KeepRuleAboveInFrame", bool_s(r.keep_in_frame)); diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index c60380a27..7ee7c0e8a 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1970,8 +1970,10 @@ impl<'r> Importer<'r> { if let Some(t) = self.para_setting(e, &format!("{k}Type")) { r.kind = names::stroke_type_in(t.trim()); } - if let Some(c) = self.para_setting(e, &format!("{k}GapColor")).filter(|c| c.contains('/')) { - r.gap_color = self.swatch_ref(c.trim()); + if let Some(c) = self.para_setting(e, &format!("{k}GapColor")) + && let Some(c) = self.rule_color_ref(&c) + { + r.gap_color = c; } if let Some(t) = tint(self.para_setting(e, &format!("{k}GapTint")).and_then(|v| v.trim().parse::().ok()).filter(|v| v.is_finite())) { r.gap_tint = t; @@ -1979,17 +1981,23 @@ impl<'r> Importer<'r> { if k == "RuleAbove" { r.keep_in_frame = self.para_setting(e, "KeepRuleAboveInFrame").is_some_and(|v| v.trim() == "true"); } - if let Some(c) = get(2) { - let c = c.trim(); - if c == designcraft_doc::TEXT_COLOR { - r.color = c.to_string(); - } else if c.contains('/') { - r.color = self.swatch_ref(c); - } + if let Some(c) = get(2).and_then(|c| self.rule_color_ref(&c)) { + r.color = c; } Some(r) } + /// A rule's colour or gap colour: Text Color (written `Text Color` or `$ID/Text Color`), or + /// a swatch reference; anything else leaves the default. + fn rule_color_ref(&mut self, c: &str) -> Option { + let c = c.trim(); + if c == designcraft_doc::TEXT_COLOR || c.strip_prefix("$ID/") == Some(designcraft_doc::TEXT_COLOR) { + Some(designcraft_doc::TEXT_COLOR.to_string()) + } else { + c.contains('/').then(|| self.swatch_ref(c)) + } + } + /// Paragraph setting `k` of `e` (a paragraph style or a range): its own, else from its /// paragraph style chain (`BasedOn` of a style, the applied style of a range). fn para_setting(&self, e: &El, k: &str) -> Option { diff --git a/crates/idml/src/tests.rs b/crates/idml/src/tests.rs index ba0acb78d..924eba437 100644 --- a/crates/idml/src/tests.rs +++ b/crates/idml/src/tests.rs @@ -3733,6 +3733,9 @@ fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { $ID/[No paragraph style] + + $ID/[No paragraph style]Text Color + "#, ); let d = import_idml(&fixture_with_table_styles(&styles, STORY)).unwrap(); @@ -3742,6 +3745,15 @@ fn a_style_setting_only_a_rules_type_gap_or_keep_in_frame_overrides_it() { let gap = style("Gap").rule_below.expect("the gap colour alone, with no on or weight in the chain"); assert!(!gap.on && gap.gap_color != designcraft_color::swatch::NONE, "{gap:?}"); assert!(style("Kept").rule_above.expect("Keep In Frame alone").keep_in_frame); + // Text Color, in either spelling, for a gap and a rule colour. + let text = style("TextGap"); + let (below, above) = (text.rule_below.expect("rule below"), text.rule_above.expect("rule above")); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR); + assert_eq!((above.gap_color.as_str(), above.color.as_str()), (designcraft_doc::TEXT_COLOR, designcraft_doc::TEXT_COLOR)); + // And it is written back as Text Color, not as no swatch. + let back = import_idml(&export_idml(&d)).unwrap(); + let below = back.styles.paragraph.iter().find(|s| s.name == "TextGap").unwrap().para.rule_below.clone().unwrap(); + assert_eq!(below.gap_color, designcraft_doc::TEXT_COLOR, "round-trips"); } #[test] From 875c317e184cdad8888aa716f73b35bfa312a5d6 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 19:44:20 +0100 Subject: [PATCH 5/8] Tests: the branch's dialog tests beside main's dialog test helpers (dialogs/tests.rs) Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 89 ----------------------------- crates/ui-egui/src/dialogs/tests.rs | 89 +++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 89 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index abb4892c9..cbf7ea6a6 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -186,95 +186,6 @@ fn color_theme(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod color_theme_tests { use super::*; - /// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can - /// go back to it; Gap Color lists only swatches. - #[test] - fn the_rule_colour_menu_offers_text_color() { - let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); - app.run("file.new", json!({})).unwrap(); - let doc = app.session.doc().unwrap().doc.clone(); - let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { - let mut picked = None; - let mut labels = Vec::new(); - let mut events = Vec::new(); - if let Some(pos) = click { - events.push(egui::Event::PointerMoved(pos)); - events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); - events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); - } - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), - events, - ..Default::default() - }; - let mut out = ctx.run_ui(input, |ui| { - picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); - }); - out.textures_delta.clear(); - for s in out.shapes { - if let egui::Shape::Text(t) = s.shape { - labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); - } - } - (picked, labels) - }; - for text in [Some("Text Color"), None] { - let ctx = egui::Context::default(); - let (_, labels) = menu(&ctx, text, None); - let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; - menu(&ctx, text, Some(combo)); - let (_, labels) = menu(&ctx, text, None); - let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); - assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); - if let Some(pos) = item { - let (picked, _) = menu(&ctx, text, Some(pos)); - assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); - } - } - } - - /// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap - /// colour, from Paragraph Rules › Type and Gap Color. - #[test] - fn paragraph_style_rules_take_a_type_and_a_gap_colour() { - let (mut app, _) = ruled_paragraph(); - let ctx = egui::Context::default(); - app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); - draw(&mut app, &ctx); - if let Some(d) = app.ui.dialog.as_mut() { - d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); - d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); - d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); - } - draw(&mut app, &ctx); - confirm(&mut app).unwrap(); - let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; - assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); - } - - /// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). - #[test] - fn a_rules_default_gap_tint_shows_100() { - use egui_kittest::kittest::Queryable; - let (mut app, _) = ruled_paragraph(); - app.run( - "style.paragraph.edit", - json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), - ) - .unwrap(); - let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); - h.run_steps(4); - let label = h.get_by_label("Gap Tint:").rect(); - let field = h - .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) - .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) - .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) - .and_then(|n| n.value()); - assert_eq!(field.as_deref(), Some("100%")); - } - #[test] fn color_theme_cancel_is_read_only_and_confirm_adds_the_preview() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 84cbf3bea..37452dd27 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3521,3 +3521,92 @@ fn character_style_options_set_the_digits() { assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); assert_eq!(char_style(&app, "Emphasis").unwrap().chars.digits, Some(designcraft_doc::Digits::Farsi)); } + +/// Paragraph Rules › Color offers Text Color above the swatches, so a rule set to a swatch can +/// go back to it; Gap Color lists only swatches. +#[test] +fn the_rule_colour_menu_offers_text_color() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let doc = app.session.doc().unwrap().doc.clone(); + let menu = |ctx: &egui::Context, text: Option<&str>, click: Option| { + let mut picked = None; + let mut labels = Vec::new(); + let mut events = Vec::new(); + if let Some(pos) = click { + events.push(egui::Event::PointerMoved(pos)); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + } + let input = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), + events, + ..Default::default() + }; + let mut out = ctx.run_ui(input, |ui| { + picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); + }); + out.textures_delta.clear(); + for s in out.shapes { + if let egui::Shape::Text(t) = s.shape { + labels.push((t.galley.job.text.clone(), t.visual_bounding_rect().center())); + } + } + (picked, labels) + }; + for text in [Some("Text Color"), None] { + let ctx = egui::Context::default(); + let (_, labels) = menu(&ctx, text, None); + let combo = labels.iter().find(|(t, _)| t == "[Black]").unwrap().1; + menu(&ctx, text, Some(combo)); + let (_, labels) = menu(&ctx, text, None); + let item = labels.iter().find(|(t, _)| t == "Text Color").map(|l| l.1); + assert_eq!(item.is_some(), text.is_some(), "{labels:?}"); + if let Some(pos) = item { + let (picked, _) = menu(&ctx, text, Some(pos)); + assert_eq!(picked.as_deref(), Some(designcraft_doc::TEXT_COLOR)); + } + } +} + +/// The course's chapter 20: a paragraph style's rule below becomes wavy, 3 pt, with a gap +/// colour, from Paragraph Rules › Type and Gap Color. +#[test] +fn paragraph_style_rules_take_a_type_and_a_gap_colour() { + let (mut app, _) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + draw(&mut app, &ctx); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("p.ruleBelow.type".into(), json!({"kind": "wavy"})); + d.fields.insert("p.ruleBelow.weight".into(), json!(3.0)); + d.fields.insert("p.ruleBelow.gapColor".into(), json!("[Paper]")); + } + draw(&mut app, &ctx); + confirm(&mut app).unwrap(); + let r = app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0.rule_below; + assert_eq!((r.kind, r.weight, r.gap_color.as_str()), (designcraft_doc::StrokeType::Wavy, 3.0, "[Paper]")); +} + +/// A rule's Gap Tint shows 100% at its default (the rule's JSON leaves a full gap tint out). +#[test] +fn a_rules_default_gap_tint_shows_100() { + use egui_kittest::kittest::Queryable; + let (mut app, _) = ruled_paragraph(); + app.run( + "style.paragraph.edit", + json!({"name": "Ruled Child", "para": {"ruleBelow": {"type": {"kind": "dashed", "pattern": [4.0, 2.0]}, "gapColor": "[Paper]"}}}), + ) + .unwrap(); + let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); + h.state_mut().app.ui.dialog = + Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.run_steps(4); + let label = h.get_by_label("Gap Tint:").rect(); + let field = h + .get_all_by(|n| n.role() == egui::accesskit::Role::TextInput) + .filter(|n| n.rect().center().x > label.max.x && (n.rect().center().y - label.center().y).abs() < 8.0) + .min_by(|a, b| a.rect().min.x.total_cmp(&b.rect().min.x)) + .and_then(|n| n.value()); + assert_eq!(field.as_deref(), Some("100%")); +} From 573566fe658617a8e7bab0f45760272e49880ec3 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:12:48 +0100 Subject: [PATCH 6/8] rustfmt Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs/tests.rs | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/dialogs/tests.rs b/crates/ui-egui/src/dialogs/tests.rs index 37452dd27..3f9bbfc10 100644 --- a/crates/ui-egui/src/dialogs/tests.rs +++ b/crates/ui-egui/src/dialogs/tests.rs @@ -3538,11 +3538,8 @@ fn the_rule_colour_menu_offers_text_color() { events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); events.push(egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); } - let input = egui::RawInput { - screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), - events, - ..Default::default() - }; + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(600.0, 600.0))), events, ..Default::default() }; let mut out = ctx.run_ui(input, |ui| { picked = swatch_menu_with(ui, &doc, "m", "[Black]", 130.0, text); }); @@ -3599,8 +3596,7 @@ fn a_rules_default_gap_tint_shows_100() { ) .unwrap(); let mut h = crate::test_window::open(app, egui::vec2(1400.0, 900.0)); - h.state_mut().app.ui.dialog = - Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); + h.state_mut().app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": "Ruled Child", "section": "rules", "rule": "ruleBelow"}))); h.run_steps(4); let label = h.get_by_label("Gap Tint:").rect(); let field = h From cb29eddb363741ab6209647f0bc245b771266981 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Thu, 29 Oct 2026 22:24:22 +0100 Subject: [PATCH 7/8] Georgian: main's row for Gap Tint:, once Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/i18n/ka.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index 2304eb940..92f4843c2 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -33,7 +33,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Copy", "კოპირება"), ("Paste", "ჩასმა"), ("Keep In Frame", "ჩარჩოში შენარჩუნება"), - ("Gap Tint:", "ღრეჩოს ტონი:"), ("Restart Numbering", "ნუმერაციის თავიდან დაწყება"), ("Continue Numbering", "ნუმერაციის გაგრძელება"), ("Keep Options…", "შენარჩუნების პარამეტრები…"), From 63340f51a55d358773c72c52f03c4e1977e7647e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Fri, 30 Oct 2026 00:01:06 +0100 Subject: [PATCH 8/8] Dropdowns: craft_ui::ComboBox, as main's clippy configuration asks Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index cbf7ea6a6..82221a4d1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -4616,7 +4616,7 @@ fn swatch_menu_with( Some(label) if current == designcraft_doc::TEXT_COLOR => label, _ => current, }; - egui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(id).selected_text(shown).width(width).show_ui(ui, |ui| { if let Some(label) = text_color && ui.selectable_label(current == designcraft_doc::TEXT_COLOR, label).clicked() { @@ -6476,7 +6476,7 @@ fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: & designcraft_doc::StrokeType::Style { name } => name.clone(), k => crate::i18n::tr(lang, k.label()).to_string(), }; - egui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt(("rule_type", which)).selected_text(crate::rtl::widget(ui, shown)).width(130.0).show_ui(ui, |ui| { for k in rule_types().into_iter().chain(custom.into_iter().map(|name| designcraft_doc::StrokeType::Style { name })) { let label = match &k { designcraft_doc::StrokeType::Style { name } => name.clone(),