Paragraph Rules: a dialog for the selected paragraphs and a section in Paragraph Style Options (#263)

* Paragraph rules: a partial ruleAbove/ruleBelow edit keeps the other rule fields; Rule Above honours Text width

* Paragraph Rules: dialog for the selected paragraphs and a Paragraph Style Options section

---------

Co-authored-by: Brandon Thomas <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-10 02:28:09 -04:00
1 parent 464a442802
commit 01500ceb04
11 files changed
+375 -11

No files matched your search

+1
View File
@@ -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.
+6 -1
View File
@@ -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(),
+38
View File
@@ -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::<String>().to_ascii_lowercase().as_str() {
"ruleabove" => &current.rule_above,
"rulebelow" => &current.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"}), &current).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"}), &current).is_err(), "unknown field");
a.set_json_over("ruleBelow", &serde_json::Value::Null, &current).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');
+4 -2
View File
@@ -95,7 +95,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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<Value> {
{
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, &current))?;
let st = d.styles_mut().para_mut(&name).ok_or_else(|| bad("style.paragraph.edit", format!("no style `{name}`")))?;
st.para.merge(&para);
st.chars.merge(&chars);
+64 -6
View File
@@ -135,7 +135,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
}
pub(crate) fn format_paras(s: &mut Session, attrs: &Value) -> Result<Value> {
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<String> = attrs.as_object().map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect()).unwrap_or_default();
let cleared: Vec<String> = 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, &current);
}
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;
+223 -1
View File
@@ -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, "", &current);
}
"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<Value, String> {
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<Value, String> {
}
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<String, Value> {
let mut out = Map::new();
for (which, _) in RULES {
let edited = format!("{prefix}{which}.");
let fields: Map<String, Value> = 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<String> = 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<f64>| {
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", &current), rule, "the inherited rule");
assert_eq!(shown_rule(&d, "", "ruleAbove", &current)["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));
+6
View File
@@ -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.
+4
View File
@@ -1439,4 +1439,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
"اكتب كلمة وضع ~ عند كل موضع مسموح لتقسيمها (ex~am~ple)؛ دون ~ لن تُقسّم الكلمة أبدًا.",
),
("Utilities", "أدوات مساعدة"),
("Paragraph Rules…", "خطوط الفقرة…"),
("Paragraph Rules", "خطوط الفقرة"),
("Rule Below", "خط سفلي"),
("Tint:", "الصبغة:"),
];
+4
View File
@@ -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:"),
];
+14
View File
@@ -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<Result<Value,
app.ui.dialog = Some(crate::dialogs::Dialog::new("ruby", json!({"text": cur.unwrap_or_default()})));
Ok(Value::Null)
}
"app.paragraphRulesDialog" => {
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();
+11 -1
View File
@@ -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 [