From cbe960ce55c675e5bc22dfa90f62fd5dbd3711c3 Mon Sep 17 00:00:00 2001 From: aiongg Date: Fri, 9 Oct 2026 23:42:11 +0000 Subject: [PATCH] Paragraph Rules: dialog for the selected paragraphs and a Paragraph Style Options section --- ROADMAP.md | 1 + crates/ui-egui/src/dialogs.rs | 224 +++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 6 + crates/ui-egui/src/i18n/ar.rs | 4 + crates/ui-egui/src/i18n/pt_br.rs | 4 + crates/ui-egui/src/menus.rs | 14 ++ crates/ui-egui/src/panels/properties.rs | 12 +- 7 files changed, 263 insertions(+), 2 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index aa4c17396..47435fb29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -23,6 +23,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/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db14..0665288f8 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -814,6 +814,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph 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"), @@ -1054,6 +1055,10 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "paragraphStyleOptions" => paragraph_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), @@ -1459,7 +1464,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()); } @@ -1482,6 +1497,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})) + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -1736,6 +1758,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"), @@ -2041,6 +2064,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() { @@ -2208,6 +2232,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| { @@ -2654,6 +2803,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); + } + #[test] fn parses_command_params() { let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates"); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 878657a5f..8dbb538c6 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2710,6 +2710,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 783f33e5e..5d54773cf 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1414,4 +1414,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 fa7973a71..c25193938 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1021,4 +1021,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 abff58c62..f13cde981 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"), @@ -358,6 +364,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "cmd:type.alignCenter", "cmd:type.alignRight", "cmd:type.justify", + "ui:app.paragraphRulesDialog", "-", "cmd:type.bold", "cmd:type.italic", @@ -904,6 +911,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 8644fe796..5a4a0a201 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1404,7 +1404,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 [