diff --git a/ROADMAP.md b/ROADMAP.md index 5c5a7a9..91893b6 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -26,6 +26,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - EPUB 3 (reflowable) export: stories in reading order, CSS from paragraph/character styles, images, navigation (`file.exportEpub`, CLI `--export x.epub`). - Data Merge (one linked CSV, TSV, text, or Excel file; text, image, QR, and hyperlink placeholders; preview; a new document with one record per page or records tiled on a single page), spell checking (public-domain Moby list + document dictionary, suggestions), Step and Repeat (count or grid), snippets, object styles, Numbering & Section Options. - Footnotes: Type › Insert Footnote (caret moves into the note), Document Footnote Options (numbering style incl. symbols, start/restart per page/spread/section, prefix/suffix, reference position/character style, paragraph style, separator, spacing, first baseline, rule above); notes composed at the bottom of the referencing column with the body text making room; edited in place on the canvas; rendered, exported to PDF (real text) and IDML (InDesign's structure, verified opening in InDesign 2026) and imported from IDML; `footnote.*` commands. +- Paragraph Rules: Rule Above / Rule Below (on, weight, colour, tint, column or text width, offset, left/right indent) set in Type › Paragraph Rules…, the Paragraph panel menu and Paragraph Style Options › Paragraph Rules; inherited style values shown, and `type.para` / `style.paragraph.edit` change only the rule fields named. - Cross-references and text anchors: Insert Cross-Reference (paragraph by style, or text anchor), 11 formats with InDesign's building blocks (full/partial paragraph, paragraph text/number, page number, anchor name, chapter, file name) plus user formats; resolved live at composition — never out of date, no Update step; unresolved destinations show `??`; PDF link annotations to the destination page; IDML export/import as InDesign cross-reference sources, hyperlinks, text destinations and formats (verified in InDesign 2026); `xref.*` / `anchor.*` commands. - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. diff --git a/crates/compose/src/lib.rs b/crates/compose/src/lib.rs index b48531d..f6ee7ee 100644 --- a/crates/compose/src/lib.rs +++ b/crates/compose/src/lib.rs @@ -1051,7 +1051,12 @@ fn compose_with_db(doc: &Document, story: &Story, frames: &[FrameSpec], opts: &C if pp.rule_above.on { let r = &pp.rule_above; let y = bl - asc - r.offset; - let col = ft.lines.iter().rev().find(|l| l.para == pi).map(|l| (l.x0, l.x1)).unwrap_or((0.0, 0.0)); + let col = ft + .lines + .iter() + .find(|l| l.para == pi && l.first_in_para) + .map(|l| if r.column_width { (l.x0, l.x1) } else { (l.x0, l.end_x) }) + .unwrap_or((0.0, 0.0)); ft.decos.push(Deco { rect: Rect::new(col.0 + r.left_indent, y - r.weight, col.1 - r.right_indent, y), color: r.color.clone(), diff --git a/crates/doc/src/attrs.rs b/crates/doc/src/attrs.rs index ed3a5ec..ce658e9 100644 --- a/crates/doc/src/attrs.rs +++ b/crates/doc/src/attrs.rs @@ -817,10 +817,48 @@ attr_set! { } } +impl ParaAttrs { + /// [`set_json`](Self::set_json), except that a `ruleAbove` / `ruleBelow` object names only the + /// rule fields to change; the others keep their values in `current` (the resolved attributes + /// of the paragraph or style being edited). + pub fn set_json_over(&mut self, key: &str, value: &serde_json::Value, current: &ParaProps) -> Result<(), String> { + let rule = match key.chars().filter(|c| *c != '_').collect::().to_ascii_lowercase().as_str() { + "ruleabove" => ¤t.rule_above, + "rulebelow" => ¤t.rule_below, + _ => return self.set_json(key, value), + }; + 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")) }; + for (k, v) in patch { + if !fields.contains_key(k) { + return Err(format!("{key}: unknown rule field `{k}`")); + } + fields.insert(k.clone(), v.clone()); + } + self.set_json(key, &merged) + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn a_partial_rule_keeps_the_other_fields() { + let current = ParaProps { + rule_below: Rule { on: true, weight: 3.0, color: "Red".into(), offset: 4.0, column_width: false, ..Rule::default() }, + ..ParaProps::default() + }; + let mut a = ParaAttrs::default(); + a.set_json_over("ruleBelow", &serde_json::json!({"color": "Blue"}), ¤t).unwrap(); + assert_eq!(a.rule_below, Some(Rule { color: "Blue".into(), ..current.rule_below.clone() })); + assert_eq!(a.rule_above, None); + assert!(a.set_json_over("ruleAbove", &serde_json::json!({"colour": "Blue"}), ¤t).is_err(), "unknown field"); + a.set_json_over("ruleBelow", &serde_json::Value::Null, ¤t).unwrap(); + assert_eq!(a.rule_below, None, "null clears the override"); + } + #[test] fn digits_map_to_their_script() { assert_eq!(Digits::Default.map('7', "Arabic"), '7'); diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 92641e4..94bd92c 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -95,7 +95,7 @@ pub fn specs() -> Vec { "Paragraph Style Options…", [], None, - "{name, rename?, basedOn?, nextStyle?, para?: {…}, chars?: {…}}", + "{name, rename?, basedOn?, nextStyle?, para?: {… ruleAbove?/ruleBelow?: only the named rule fields change}, chars?: {…}}", has_doc, edit_para ), @@ -469,7 +469,6 @@ fn CharProps_to_attrs(p: &designcraft_doc::CharProps) -> CharAttrs { fn edit_para(s: &mut Session, p: &Value) -> Result { let name = str_param(p, "name").ok_or_else(|| bad("style.paragraph.edit", "missing name"))?.to_string(); - let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json(k, v))?; let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?; let rename = str_param(p, "rename").map(str::to_string); let based = p.get("basedOn").cloned(); @@ -479,6 +478,9 @@ fn edit_para(s: &mut Session, p: &Value) -> Result { { return Err(bad("style.paragraph.edit", "based-on would create a cycle")); } + // A rule object changes only the rule fields it names, over the style's resolved rule. + let (current, _) = d.styles.resolve_para_style(&name); + let para: ParaAttrs = attrs(p.get("para"), |a: &mut ParaAttrs, k, v| a.set_json_over(k, v, ¤t))?; let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?; st.para.merge(¶); st.chars.merge(&chars); diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 552ce51..92ddaf6 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -135,7 +135,7 @@ pub fn specs() -> Vec { "Paragraph Formatting", [], None, - "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, …}}", + "{attrs: {align?, leftIndent?, firstLineIndent?, spaceBefore?, spaceAfter?, dropCapLines?, hyphenate?, composer?, tabs?, ruleAbove?/ruleBelow?: {on?, weight?, color?, tint?, columnWidth?, offset?, leftIndent?, rightIndent?} (only the named rule fields change), …}}", has_text_or_frames, |s, p| format_paras(s, p.get("attrs").unwrap_or(p)) ), @@ -772,19 +772,27 @@ pub(crate) fn format_chars(s: &mut Session, attrs: &Value) -> Result { } pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result { + let empty = serde_json::Map::new(); + let obj = attrs.as_object().unwrap_or(&empty); + // A rule object changes only the rule fields it names, so each paragraph gets its own attrs + // over its resolved rule; this pass validates them. let mut a = ParaAttrs::default(); - if let Some(o) = attrs.as_object() { - for (k, v) in o { - a.set_json(k, v).map_err(|e| bad("type.para", e))?; - } + for (k, v) in obj { + a.set_json_over(k, v, &designcraft_doc::ParaProps::default()).map_err(|e| bad("type.para", e))?; } // `null` removes the override (back to the style's value). - let cleared: Vec = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default(); + let cleared: Vec = obj.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect(); let targets = format_targets(s); s.edit(|d, _| { + let styles = d.styles.clone(); for t in &targets { if let Some(st) = d.text_story_mut(t.story, t.cell) { st.format_paras(t.range.clone(), |p| { + let (current, _) = styles.resolve_para(p); + let mut a = a.clone(); + for (k, v) in obj { + let _ = a.set_json_over(k, v, ¤t); + } p.para.merge(&a); for k in &cleared { let _ = p.para.set_json(k, &Value::Null); @@ -1227,6 +1235,56 @@ mod nested_line_style_tests { } } +#[cfg(test)] +mod rule_tests { + use serde_json::json; + + use crate::Session; + + #[test] + fn a_partial_rule_edit_keeps_the_other_fields() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); + s.execute("style.paragraph.create", &json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One\nTwo"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": "Ruled"})).unwrap(); + // Local: only the colour changes; the rest is inherited from the style. + s.execute("type.para", &json!({"attrs": {"ruleBelow": {"color": "[Registration]"}}})).unwrap(); + let attrs = s.execute("type.selectionAttrs", &json!({})).unwrap(); + let mut want = rule.clone(); + want["color"] = json!("[Registration]"); + assert_eq!(attrs["para"]["ruleBelow"], want); + assert_eq!(attrs["para"]["ruleAbove"]["on"], false, "the other rule is untouched"); + // Style: only the weight changes. + s.execute("style.paragraph.edit", &json!({"name": "Ruled", "para": {"ruleBelow": {"weight": 1.5}}})).unwrap(); + let st = s.doc().unwrap().doc.styles.para("Ruled").cloned().unwrap(); + let below = serde_json::to_value(st.para.rule_below.unwrap()).unwrap(); + let mut want = rule.clone(); + want["weight"] = json!(1.5); + assert_eq!(below, want); + assert!(s.execute("type.para", &json!({"attrs": {"ruleBelow": {"thickness": 2}}})).is_err(), "unknown rule field"); + } + + #[test] + fn rule_above_width_follows_column_or_text() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + let r = s.execute("frame.create", &json!({"rect": [72, 72, 400, 400], "content": "text", "text": "Short"})).unwrap(); + s.execute("text.select", &json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + let sid = designcraft_doc::StoryId(r["story"].as_u64().unwrap()); + let mut widths = vec![]; + for column in [true, false] { + s.execute("type.para", &json!({"ruleAbove": {"on": true, "columnWidth": column}})).unwrap(); + let cs = s.cache.get(&s.doc().unwrap().doc, sid, None); + widths.push(cs.frames[0].decos[0].rect.width()); + } + assert!((widths[0] - 328.0).abs() < 1e-6, "column: {widths:?}"); + assert!(widths[1] < 100.0, "text: {widths:?}"); + } +} + #[cfg(test)] mod border_tests { use serde_json::json; diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index a2dc5c1..1e1ae0e 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -823,6 +823,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), "footnoteOptions" => crate::i18n::tr(&app.ui.language, "Footnote Options"), + "paragraphRules" => crate::i18n::tr(&app.ui.language, "Paragraph Rules"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), "polygonSettings" => crate::i18n::tr(&app.ui.language, "Polygon Settings"), @@ -1068,6 +1069,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), "characterStyleOptions" => character_style_options(app, ui, &mut d), "footnoteOptions" => footnote_options(app, ui, &mut d), + "paragraphRules" => { + let current = d.fields.get("current").cloned().unwrap_or_default(); + paragraph_rules(app, ui, &mut d, "", ¤t); + } "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), @@ -1489,7 +1494,17 @@ pub fn confirm(app: &mut DesignApp) -> Result { let mut chars = serde_json::Map::new(); for (k, v) in &d.fields { if let Some(a) = k.strip_prefix("p.") { - para.insert(a.into(), v.clone()); + // `p.ruleAbove.weight`: one field of a rule (the command keeps the others). + match a.split_once('.') { + Some((attr, field)) => { + if let Value::Object(m) = para.entry(attr).or_insert_with(|| json!({})) { + m.insert(field.into(), v.clone()); + } + } + None => { + para.insert(a.into(), v.clone()); + } + } } else if let Some(a) = k.strip_prefix("c.") { chars.insert(a.into(), v.clone()); } @@ -1522,6 +1537,13 @@ pub fn confirm(app: &mut DesignApp) -> Result { } Ok(r) } + "paragraphRules" => { + let attrs = rule_edits(&d, ""); + if attrs.is_empty() { + return Ok(Value::Null); + } + app.run("type.para", json!({"attrs": attrs})) + } "characterStyleOptions" => { let name = d.s("name"); let mut chars = Map::new(); @@ -1840,6 +1862,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ("general", "General"), ("chars", "Basic Character Formats"), ("indents", "Indents and Spacing"), + ("rules", "Paragraph Rules"), ("hyph", "Hyphenation"), ("justify", "Justification"), ("nested", "Drop Caps and Nested Styles"), @@ -2125,6 +2148,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } }); } + "rules" => paragraph_rules(app, ui, d, "p.", &pv), "hyph" => { let mut h = cur(d, "p.hyphenate", &pv["hyphenate"]).as_bool().unwrap_or(true); if ui.checkbox(&mut h, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Hyphenate"))).changed() { @@ -2760,6 +2784,131 @@ fn style_combo(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, key: &str, ch combo(ui, d, key, &opts); } +const RULES: [(&str, &str); 2] = [("ruleAbove", "Rule Above"), ("ruleBelow", "Rule Below")]; + +/// A rule as the dialog shows it: `current[which]` (the resolved rule, inherited values included) +/// with the fields edited in the dialog (`{prefix}{which}.{field}`) on top. +fn shown_rule(d: &Dialog, prefix: &str, which: &str, current: &Value) -> Value { + let mut r = match current.get(which) { + Some(Value::Object(m)) => m.clone(), + _ => Map::new(), + }; + let edited = format!("{prefix}{which}."); + for (k, v) in &d.fields { + if let Some(field) = k.strip_prefix(&edited) { + r.insert(field.into(), v.clone()); + } + } + Value::Object(r) +} + +/// The rule fields edited in the dialog, as `type.para` attrs: `{ruleAbove?: {…}, ruleBelow?: {…}}`. +fn rule_edits(d: &Dialog, prefix: &str) -> Map { + let mut out = Map::new(); + for (which, _) in RULES { + let edited = format!("{prefix}{which}."); + let fields: Map = d.fields.iter().filter_map(|(k, v)| k.strip_prefix(&edited).map(|f| (f.to_string(), v.clone()))).collect(); + if !fields.is_empty() { + out.insert(which.into(), Value::Object(fields)); + } + } + out +} + +/// Paragraph Rules (the dialog, and the Paragraph Style Options section): Rule Above or Rule Below, +/// chosen at the top. `current` holds the resolved `ruleAbove` / `ruleBelow`; each edit is stored as +/// `{prefix}{rule}.{field}`, so OK changes only the fields edited. +fn paragraph_rules(app: &DesignApp, ui: &mut egui::Ui, d: &mut Dialog, prefix: &str, current: &Value) { + let lang = app.ui.language.as_str(); + let (swatches, h_units, v_units) = app + .session + .active() + .map(|st| { + let sw: Vec = st.doc.swatches.iter().filter(|w| !w.hidden).map(|w| w.name.clone()).collect(); + (sw, st.doc.settings.horizontal_units, st.doc.settings.vertical_units) + }) + .unwrap_or((Vec::new(), Unit::Points, Unit::Points)); + let which = if d.s("rule") == "ruleBelow" { "ruleBelow" } else { "ruleAbove" }; + let rule = shown_rule(d, prefix, which, current); + let key = |field: &str| format!("{prefix}{which}.{field}"); + // A number field takes the rest of the row; keep its grid column to the field's width. + let sized = |ui: &mut egui::Ui, add: &mut dyn FnMut(&mut egui::Ui) -> Option| { + ui.allocate_ui_with_layout(egui::vec2(80.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| add(ui)).inner + }; + ui.horizontal(|ui| { + let shown = RULES.iter().find(|r| r.0 == which).map_or(which, |r| r.1); + egui::ComboBox::from_id_salt("rule_which").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(130.0).show_ui( + ui, + |ui| { + for (v, label) in RULES { + if ui.selectable_label(v == which, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert("rule".into(), json!(v)); + } + } + }, + ); + ui.add_space(12.0); + let mut on = rule["on"].as_bool().unwrap_or(false); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, "Rule On"))).changed() { + d.fields.insert(key("on"), json!(on)); + } + }); + ui.add_space(6.0); + let on = rule["on"].as_bool().unwrap_or(false); + 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)) { + d.fields.insert(key("weight"), json!(v.clamp(0.0, 1000.0))); + } + crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); + let color = rule["color"].as_str().unwrap_or("").to_string(); + egui::ComboBox::from_id_salt(("rule_color", which)).selected_text(&color).width(130.0).show_ui(ui, |ui| { + for w in &swatches { + let (c, g) = app.session.active().map_or((None, None), |st| crate::widgets::swatch_colors(&st.doc, w, 1.0)); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(12.0, 12.0), egui::Sense::hover()); + crate::widgets::paint_chip(ui.painter(), r, c, g); + if ui.selectable_label(*w == color, w).clicked() { + 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))); + } + 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)) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, if column { "Column" } else { "Text" }))) + .width(130.0) + .show_ui(ui, |ui| { + for (v, label) in [(true, "Column"), (false, "Text")] { + if ui.selectable_label(v == column, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert(key("columnWidth"), json!(v)); + } + } + }); + 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)) { + 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)) { + d.fields.insert(key(field), json!(v)); + } + } + ui.end_row(); + }); + }); +} + /// Document Footnote Options: Numbering and Formatting / Layout tabs. fn footnote_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.horizontal(|ui| { @@ -3205,6 +3354,79 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + fn draw(app: &mut DesignApp, ctx: &egui::Context) { + let input = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1400.0, 900.0))), ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + + /// A paragraph styled "Ruled Child", whose Rule Below comes from its parent "Ruled". + fn ruled_paragraph() -> (DesignApp, Value) { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let rule = json!({"on": true, "weight": 3.0, "color": "[Black]", "tint": 0.5, "columnWidth": false, "offset": 4.0, "leftIndent": 6.0, "rightIndent": 2.0}); + app.run("style.paragraph.create", json!({"name": "Ruled", "para": {"ruleBelow": rule}})).unwrap(); + app.run("style.paragraph.create", json!({"name": "Ruled Child", "basedOn": "Ruled"})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 400], "content": "text", "text": "One"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 0})).unwrap(); + app.run("style.paragraph.apply", json!({"name": "Ruled Child"})).unwrap(); + (app, rule) + } + + fn undo_steps(app: &DesignApp) -> usize { + app.session.doc().map_or(0, |st| st.history.undo.len()) + } + + #[test] + fn paragraph_rules_dialog_shows_the_rule_and_applies_one_field() { + let (mut app, rule) = ruled_paragraph(); + let ctx = egui::Context::default(); + app.run("app.paragraphRulesDialog", json!({"rule": "ruleBelow"})).unwrap(); + draw(&mut app, &ctx); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!(d.id, "paragraphRules"); + let current = d.fields["current"].clone(); + assert_eq!(shown_rule(&d, "", "ruleBelow", ¤t), rule, "the inherited rule"); + assert_eq!(shown_rule(&d, "", "ruleAbove", ¤t)["on"], false); + // Pick a colour, as the Color menu does. + app.ui.dialog.as_mut().unwrap().fields.insert("ruleBelow.color".into(), json!("[Registration]")); + draw(&mut app, &ctx); + let before = undo_steps(&app); + confirm(&mut app).unwrap(); + assert_eq!(undo_steps(&app), before + 1, "one undo step"); + let mut want = rule.clone(); + want["color"] = json!("[Registration]"); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], want); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], rule); + } + + #[test] + fn paragraph_style_options_edit_one_rule_field() { + let (mut app, rule) = 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); + let resolved = |app: &DesignApp| serde_json::to_value(app.session.doc().unwrap().doc.styles.resolve_para_style("Ruled Child").0).unwrap(); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!(shown_rule(&d, "p.", "ruleBelow", &resolved(&app)), rule, "inherited from the parent style"); + app.ui.dialog.as_mut().unwrap().fields.insert("p.ruleBelow.weight".into(), json!(1.5)); + draw(&mut app, &ctx); + let before = undo_steps(&app); + confirm(&mut app).unwrap(); + assert_eq!(undo_steps(&app), before + 1, "one undo step"); + let mut want = rule.clone(); + want["weight"] = json!(1.5); + assert_eq!(resolved(&app)["ruleBelow"], want); + assert_eq!(app.run("type.selectionAttrs", json!({})).unwrap()["para"]["ruleBelow"], want, "the paragraph follows its style"); + app.run("edit.undo", json!({})).unwrap(); + assert_eq!(resolved(&app)["ruleBelow"], rule); + } + fn command_app(id: &str, fields: Value) -> DesignApp { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.ui.dialog = Some(Dialog::new(&format!("cmd:{id}"), fields)); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 36a33d8..ae486a3 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2765,6 +2765,12 @@ const TABLE: &[(&str, [&str; 5])] = &[ "请通过控制通道传入 csv、rows 或 bytes。", ], ), + // Paragraph Rules. + ("Paragraph Rules…", ["Absatzlinien …", "Filets de paragraphe…", "Filetes de párrafo…", "段落境界線…", "段落线…"]), + ("Paragraph Rules", ["Absatzlinien", "Filets de paragraphe", "Filetes de párrafo", "段落境界線", "段落线"]), + ("Rule Below", ["Linie unterhalb", "Filet après", "Filete después", "後境界線", "下方横线"]), + ("Tint:", ["Farbton:", "Teinte:", "Matiz:", "濃淡:", "色调:"]), + ("Column", ["Spalte", "Colonne", "Columna", "段", "栏"]), ]; /// Interface direction; independent of document binding and paragraph direction. diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 8625093..6e1f86c 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1439,4 +1439,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ "اكتب كلمة وضع ~ عند كل موضع مسموح لتقسيمها (ex~am~ple)؛ دون ~ لن تُقسّم الكلمة أبدًا.", ), ("Utilities", "أدوات مساعدة"), + ("Paragraph Rules…", "خطوط الفقرة…"), + ("Paragraph Rules", "خطوط الفقرة"), + ("Rule Below", "خط سفلي"), + ("Tint:", "الصبغة:"), ]; diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index bb754dd..ea0bec6 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1044,4 +1044,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("No conflicts", "Nenhum conflito"), ("Page (1–{count}):", "Página (1–{count}):"), ("Fonts in Document: {count} Missing: {missing}", "Fontes no documento: {count} Ausentes: {missing}"), + ("Paragraph Rules…", "Linhas de parágrafo…"), + ("Paragraph Rules", "Linhas de parágrafo"), + ("Rule Below", "Linha abaixo"), + ("Tint:", "Matiz:"), ]; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 37db2fb..2449224 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -62,6 +62,12 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("app.insertTableDialog", "Create Table…", None, "{} — Insert Table dialog (body/header/footer rows, columns)"), ("app.footnoteOptionsDialog", "Document Footnote Options…", None, "{} — numbering, formatting and layout of footnotes"), ("app.rubyDialog", "Ruby…", None, "{} — the reading set over the selected text"), + ( + "app.paragraphRulesDialog", + "Paragraph Rules…", + None, + "{rule?: ruleAbove|ruleBelow} — Rule Above / Rule Below of the selected paragraphs (type.para)", + ), ("app.findFontDialog", "Find/Replace Font…", None, "{} — fonts used (missing ones flagged) and replacing them"), ("app.insertXrefDialog", "Insert Cross-Reference…", None, "{} — New Cross-Reference dialog (paragraph or text anchor, format)"), ("app.deleteAllGuides", "Delete All Guides on Spread", None, "{} — the spread in view"), @@ -366,6 +372,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.alignCenter", "cmd:type.alignRight", "cmd:type.justify", + "ui:app.paragraphRulesDialog", "-", "cmd:type.bold", "cmd:type.italic", @@ -914,6 +921,13 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option { + let Some(a) = crate::panels::text_attrs(app) else { return Some(Err("select text or a text frame".into())) }; + let rule = if p.get("rule").and_then(Value::as_str) == Some("ruleBelow") { "ruleBelow" } else { "ruleAbove" }; + let current = json!({"ruleAbove": a["para"]["ruleAbove"], "ruleBelow": a["para"]["ruleBelow"]}); + app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphRules", json!({"rule": rule, "current": current}))); + Ok(Value::Null) + } "app.footnoteOptionsDialog" => { let Some(st) = app.session.active() else { return Some(Err("no document open".into())) }; let o = serde_json::to_value(&st.doc.footnote_options).unwrap_or_default(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 68452d9..5b8a710 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1407,7 +1407,17 @@ pub fn paragraph_panel(app: &mut DesignApp, ui: &mut egui::Ui) { }; let u = units(app); let p = a["para"].clone(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong()); + ui.horizontal(|ui| { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Paragraph")).strong()); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.menu_button("☰", |ui| { + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Paragraph Rules…"))).clicked() { + let _ = app.run("app.paragraphRulesDialog", json!({})); + ui.close(); + } + }); + }); + }); let cur: Align = serde_json::from_value(p["align"].clone()).unwrap_or_default(); ui.horizontal(|ui| { for (al, icon) in [