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]