mirror of
https://github.com/storytold/designcraft.git
synced 2026-11-02 06:36:51 -06:00
Paragraph rules: a partial ruleAbove/ruleBelow edit keeps the other rule fields; Rule Above honours Text width
This commit is contained in:
@@ -939,7 +939,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
|
||||
),
|
||||
@@ -453,7 +453,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();
|
||||
@@ -463,6 +462,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;
|
||||
|
||||
Reference in New Issue
Block a user