mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
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:
11 files changed
+375
-11
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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" => ¤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');
|
||||
|
||||
@@ -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, ¤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);
|
||||
|
||||
@@ -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, ¤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;
|
||||
|
||||
@@ -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<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", ¤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));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1439,4 +1439,8 @@ pub(super) const TABLE: &[(&str, &str)] = &[
|
||||
"اكتب كلمة وضع ~ عند كل موضع مسموح لتقسيمها (ex~am~ple)؛ دون ~ لن تُقسّم الكلمة أبدًا.",
|
||||
),
|
||||
("Utilities", "أدوات مساعدة"),
|
||||
("Paragraph Rules…", "خطوط الفقرة…"),
|
||||
("Paragraph Rules", "خطوط الفقرة"),
|
||||
("Rule Below", "خط سفلي"),
|
||||
("Tint:", "الصبغة:"),
|
||||
];
|
||||
@@ -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:"),
|
||||
];
|
||||
@@ -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();
|
||||
|
||||
@@ -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 [
|
||||
|
||||
Reference in new issue
Block a user