Merge PR #1463: Paragraph rules: Type, Gap Color and Tint, Keep In Frame

crates/ui-egui/src/dialogs/tests.rs: kept both sides' appended tests
This commit is contained in:
aiongg
2026-10-30 19:44:13 +00:00
19 changed files with 431 additions and 20 deletions
+1
View File
@@ -456,6 +456,7 @@ Newest first.
- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change.
- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change.
- **2026-10-21** · Paragraph Border and Paragraph Shading link their four sides as InDesign's: the stroke weights, offsets and corners have the "Make all settings the same" link, on when the four are the same, so a side typed in sets all four; with the link off a side is set alone (a border with only a thick left side). Found in the Learn Skills Daily course, chapter 40. No readiness percentage change.
- **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-21** · Properties › Character and Paragraph: ••• (More Options) expands the section in place, as InDesign's: Character adds vertical and horizontal scale, baseline shift, skew, language and OpenType, and All Caps, Small Caps, Superscript, Subscript, Underline and Strikethrough; Paragraph adds the left, right, first line and last line indents, space before and after, the drop cap's lines and characters, Align to Baseline Grid and Hyphenate. It used to open the Character or Paragraph panel; the language field moved under ••• as in InDesign. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-21** · The text Control panel has InDesign's two modes: A (Character Formatting Controls: font and style, size and leading, All Caps/Superscript/Underline and Small Caps/Subscript/Strikethrough, kerning and tracking, vertical and horizontal scale, baseline shift and skew, character style and language) and ¶ (Paragraph Formatting Controls: the nine alignments, left/first line and right/last line indents, space before and after, the drop cap's lines and characters, paragraph style, Hyphenate and Align to Baseline Grid). It used to show a fixed mix of both. Found in the Learn Skills Daily course, chapter 16. No readiness percentage change.
- **2026-10-20** · With the Type tool, the Control panel's font size, leading and tracking fields have a spinner and a list of common values, as in InDesign (auto leading in parentheses). No readiness percentage change.
+56 -2
View File
@@ -2569,7 +2569,12 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish {
.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, 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));
}
}
if pp.rule_below.on
@@ -2580,7 +2585,7 @@ fn finish_paragraph(input: ParagraphFinishInput<'_>) -> ParagraphFinish {
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), 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.
@@ -5326,6 +5331,55 @@ 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.
/// `(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<Deco> {
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 {
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();
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, notes: &designcraft_doc::FootnoteOptions) {
if ft.lines.is_empty() || f.opts.vertical_justification == VerticalJustification::Top {
+50
View File
@@ -4135,6 +4135,56 @@ 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");
// 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() };
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));
// 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
/// rule above, the last character's for the rule below. A swatch colour is used as is.
#[test]
+4 -1
View File
@@ -222,8 +222,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());
+36 -1
View File
@@ -17,11 +17,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,
}
}
}
+11
View File
@@ -335,6 +335,17 @@ 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));
// 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));
}
props.push(if r.color == designcraft_doc::TEXT_COLOR {
p(&format!("{k}Color"), "string", designcraft_doc::TEXT_COLOR)
} else {
+34 -9
View File
@@ -238,7 +238,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<Option<String>> = fields.iter().map(|f| self.para_setting(e, &format!("{k}{f}"))).collect();
@@ -246,7 +251,6 @@ impl<'r> Importer<'r> {
let num = |i: usize| get(i).and_then(|v| v.trim().parse::<f64>().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;
@@ -258,17 +262,38 @@ 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);
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);
}
// 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"))
&& 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::<f64>().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).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<String> {
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<String> {
+43
View File
@@ -330,6 +330,10 @@ fn indesign_text_defaults() -> Vec<AttrRow> {
"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;
@@ -3934,6 +3938,45 @@ 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#" </RootParagraphStyleGroup>"#,
r#" <ParagraphStyle Self="ParagraphStyle/Base" Name="Base" RuleBelow="true" RuleBelowLineWeight="3">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Wavy" Name="Wavy" RuleBelowType="StrokeStyle/$ID/Wavy">
<Properties><BasedOn type="object">ParagraphStyle/Base</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Gap" Name="Gap" RuleBelowGapColor="Color/Black">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/Kept" Name="Kept" KeepRuleAboveInFrame="true">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn></Properties>
</ParagraphStyle>
<ParagraphStyle Self="ParagraphStyle/TextGap" Name="TextGap" RuleBelowGapColor="$ID/Text Color" RuleAboveColor="$ID/Text Color">
<Properties><BasedOn type="string">$ID/[No paragraph style]</BasedOn><RuleAboveGapColor type="string">Text Color</RuleAboveGapColor></Properties>
</ParagraphStyle>
</RootParagraphStyleGroup>"#,
);
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);
// 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]
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).
+99 -7
View File
@@ -4629,8 +4629,30 @@ fn link_toggle(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, key: &str, linked:
/// 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<String> {
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<String> {
let mut picked = None;
craft_ui::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,
};
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()
{
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| {
@@ -6481,22 +6503,65 @@ 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<String> = 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(),
};
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(),
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)) {
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));
}
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| {
// 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)));
}
});
ui.end_row();
crate::rtl::label(ui, crate::i18n::tr(lang, "Width:"));
let column = rule["columnWidth"].as_bool().unwrap_or(true);
craft_ui::ComboBox::from_id_salt(("rule_width", which))
@@ -6509,23 +6574,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<designcraft_doc::StrokeType> {
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| {
+85
View File
@@ -3597,3 +3597,88 @@ fn paragraph_border_sides_and_corners_link_as_indesigns() {
let style = h.state().app.session.doc().unwrap().doc.styles.para("Ruled Child").cloned().unwrap();
assert_eq!(style.para.border_weights, Some([3.0, 10.0, 3.0, 3.0]));
}
/// 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<egui::Pos2>| {
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%"));
}
+1
View File
@@ -31,6 +31,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "قص"),
("Copy", "نسخ"),
("Paste", "لصق"),
("Keep In Frame", "الإبقاء داخل الإطار"),
("Restart Numbering", "إعادة الترقيم"),
("Continue Numbering", "متابعة الترقيم"),
("Keep Options…", "خيارات الإبقاء…"),
+2
View File
@@ -32,6 +32,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:"),
("Restart Numbering", "Začít číslování znovu"),
("Continue Numbering", "Pokračovat v číslování"),
("Keep Options…", "Volby zachování…"),
+1
View File
@@ -29,6 +29,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Taglia"),
("Copy", "Copia"),
("Paste", "Incolla"),
("Keep In Frame", "Mantieni nella cornice"),
("Restart Numbering", "Riavvia numerazione"),
("Continue Numbering", "Continua numerazione"),
("Keep Options…", "Opzioni di separazione…"),
+1
View File
@@ -32,6 +32,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "ამოჭრა"),
("Copy", "კოპირება"),
("Paste", "ჩასმა"),
("Keep In Frame", "ჩარჩოში შენარჩუნება"),
("Restart Numbering", "ნუმერაციის თავიდან დაწყება"),
("Continue Numbering", "ნუმერაციის გაგრძელება"),
("Keep Options…", "შენარჩუნების პარამეტრები…"),
+2
View File
@@ -30,6 +30,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Wytnij"),
("Copy", "Kopiuj"),
("Paste", "Wklej"),
("Keep In Frame", "Zachowaj w ramce"),
("Gap Tint:", "Odcień odstępu:"),
("Restart Numbering", "Zacznij numerowanie od nowa"),
("Continue Numbering", "Kontynuuj numerowanie"),
("Keep Options…", "Opcje utrzymywania…"),
+1
View File
@@ -32,6 +32,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Recortar"),
("Copy", "Copiar"),
("Paste", "Colar"),
("Keep In Frame", "Manter no quadro"),
("Restart Numbering", "Reiniciar numeração"),
("Continue Numbering", "Continuar numeração"),
("Keep Options…", "Opções de manutenção…"),
+2
View File
@@ -34,6 +34,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вырезать"),
("Copy", "Копировать"),
("Paste", "Вставить"),
("Keep In Frame", "Сохранить во фрейме"),
("Gap Tint:", "Оттенок пробелов:"),
("Restart Numbering", "Начать нумерацию заново"),
("Continue Numbering", "Продолжить нумерацию"),
("Keep Options…", "Параметры удержания…"),
+1
View File
@@ -1,5 +1,6 @@
/// English → [German, French, Spanish, Japanese, Simplified Chinese].
pub(super) const TABLE: &[(&str, [&str; 5])] = &[
("Keep In Frame", ["Im Rahmen behalten", "Conserver dans le bloc", "Mantener en el marco", "フレーム内に保持", "保持在框架内"]),
(
"Character Formatting Controls",
[
+1
View File
@@ -30,6 +30,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[
("Cut", "Вирізати"),
("Copy", "Копіювати"),
("Paste", "Вставити"),
("Keep In Frame", "Зберегти у фреймі"),
("Restart Numbering", "Почати нумерацію знову"),
("Continue Numbering", "Продовжити нумерацію"),
("Keep Options…", "Параметри утримання…"),