mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-27 03:36:51 -06:00
Styles: editing a character style renames it everywhere it is used, changes its Based On and unsets attributes; [None] can't be edited
This commit is contained in:
@@ -100,8 +100,24 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
edit_para
|
||||
),
|
||||
cmd!("style.paragraph.delete", "Delete Paragraph Style", [], None, "{name, replaceWith?}", has_doc, delete_para),
|
||||
cmd!("style.character.create", "New Character Style…", [], None, "{name, basedOn?, chars?: {…}}", has_doc, create_char),
|
||||
cmd!("style.character.edit", "Character Style Options…", [], None, "{name, rename?, basedOn?, chars?}", has_doc, edit_char),
|
||||
cmd!(
|
||||
"style.character.create",
|
||||
"New Character Style…",
|
||||
[],
|
||||
None,
|
||||
"{name, basedOn?: style | \"[None]\", chars?: {…}} — the style sets only `chars` → {name} (made unique)",
|
||||
has_doc,
|
||||
create_char
|
||||
),
|
||||
cmd!(
|
||||
"style.character.edit",
|
||||
"Character Style Options…",
|
||||
[],
|
||||
None,
|
||||
"{name, rename?, basedOn?: style | \"[None]\" | null, chars?: {attr: value | null (no longer set)}} — renaming updates every use",
|
||||
has_doc,
|
||||
edit_char
|
||||
),
|
||||
cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| {
|
||||
let st = s.doc()?;
|
||||
Ok(json!({
|
||||
@@ -507,6 +523,12 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
|
||||
s.based_on = Some(to.to_string());
|
||||
}
|
||||
}
|
||||
for s in &mut st.paragraph {
|
||||
rename_char_refs(&mut s.para, from, to);
|
||||
}
|
||||
if d.footnote_options.ref_char_style == from {
|
||||
d.footnote_options.ref_char_style = to.to_string();
|
||||
}
|
||||
}
|
||||
for sid in d.stories.keys().copied().collect::<Vec<_>>() {
|
||||
let Some(story) = d.story_mut(sid) else { continue };
|
||||
@@ -519,12 +541,26 @@ fn rename_style(d: &mut designcraft_doc::Document, para: bool, from: &str, to: &
|
||||
for r in st.chars.iter_mut().filter(|r| r.format.style == from) {
|
||||
r.format.style = to.to_string();
|
||||
}
|
||||
for f in &mut st.paras {
|
||||
rename_char_refs(&mut f.para, from, to);
|
||||
}
|
||||
}
|
||||
st.rev += 1;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Point the nested, nested line and GREP styles of paragraph attributes at a renamed character style.
|
||||
fn rename_char_refs(p: &mut ParaAttrs, from: &str, to: &str) {
|
||||
let names = p.nested_styles.iter_mut().flatten().map(|n| &mut n.style);
|
||||
let names = names.chain(p.nested_line_styles.iter_mut().flatten().map(|n| &mut n.style));
|
||||
for n in names.chain(p.grep_styles.iter_mut().flatten().map(|g| &mut g.style)) {
|
||||
if n == from {
|
||||
*n = to.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn group_styles(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let para = match str_param(p, "kind").unwrap_or("paragraph") {
|
||||
"paragraph" => true,
|
||||
@@ -583,8 +619,14 @@ fn delete_para(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let base = str_param(p, "name").unwrap_or("Character Style 1").to_string();
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
let based_on = str_param(p, "basedOn").map(str::to_string);
|
||||
// Based on [None] is based on nothing.
|
||||
let based_on = str_param(p, "basedOn").filter(|b| *b != designcraft_doc::NO_CHAR_STYLE).map(str::to_string);
|
||||
s.edit(|d, _| {
|
||||
if let Some(b) = &based_on
|
||||
&& d.styles.char_style(b).is_none()
|
||||
{
|
||||
return Err(bad("style.character.create", format!("no character style `{b}`")));
|
||||
}
|
||||
let name = Styles::unique_name(|n| d.styles.char_style(n).is_some(), &base);
|
||||
d.styles_mut().character.push(CharacterStyle { name: name.clone(), based_on, chars, shortcut: String::new() });
|
||||
Ok(json!({"name": name}))
|
||||
@@ -593,10 +635,50 @@ fn create_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
|
||||
fn edit_char(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let name = str_param(p, "name").ok_or_else(|| bad("style.character.edit", "missing name"))?.to_string();
|
||||
if name == designcraft_doc::NO_CHAR_STYLE {
|
||||
return Err(bad("style.character.edit", "[None] can't be edited"));
|
||||
}
|
||||
let chars: CharAttrs = attrs(p.get("chars"), |a: &mut CharAttrs, k, v| a.set_json(k, v))?;
|
||||
// `null` unsets an attribute (the style no longer sets it).
|
||||
let unset: Vec<String> = p
|
||||
.get("chars")
|
||||
.and_then(Value::as_object)
|
||||
.map(|o| o.iter().filter(|(_, v)| v.is_null()).map(|(k, _)| k.clone()).collect())
|
||||
.unwrap_or_default();
|
||||
let rename = str_param(p, "rename").filter(|n| *n != name).map(|n| n.trim().to_string());
|
||||
let based: Option<Option<String>> = match p.get("basedOn") {
|
||||
None => None,
|
||||
Some(Value::Null) => Some(None),
|
||||
Some(Value::String(b)) if b == designcraft_doc::NO_CHAR_STYLE => Some(None),
|
||||
Some(Value::String(b)) => Some(Some(b.clone())),
|
||||
Some(_) => return Err(bad("style.character.edit", "`basedOn` is a style name or null")),
|
||||
};
|
||||
s.edit(|d, _| {
|
||||
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?;
|
||||
if let Some(Some(b)) = &based {
|
||||
if d.styles.char_style(b).is_none() {
|
||||
return Err(bad("style.character.edit", format!("no character style `{b}`")));
|
||||
}
|
||||
if d.styles.char_based_on_cycles(&name, b) {
|
||||
return Err(bad("style.character.edit", "based-on would create a cycle"));
|
||||
}
|
||||
}
|
||||
if let Some(n) = &rename
|
||||
&& (n.is_empty() || n.starts_with('[') || d.styles.char_style(n).is_some())
|
||||
{
|
||||
return Err(bad("style.character.edit", format!("can't rename to `{n}`")));
|
||||
}
|
||||
let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", format!("no character style `{name}`")))?;
|
||||
st.chars.merge(&chars);
|
||||
for k in &unset {
|
||||
st.chars.set_json(k, &Value::Null).map_err(|e| bad("style.character.edit", e))?;
|
||||
}
|
||||
if let Some(b) = based.clone() {
|
||||
st.based_on = b;
|
||||
}
|
||||
if let Some(n) = &rename {
|
||||
// Every use: other styles, stories, table cells, nested and GREP styles, footnotes.
|
||||
rename_style(d, false, &name, n);
|
||||
}
|
||||
ok()
|
||||
})
|
||||
}
|
||||
@@ -977,6 +1059,73 @@ mod color_tests {
|
||||
assert!(s.execute("style.group", &json!({"names": ["[Basic Paragraph]"], "group": "X"})).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn character_style_edit_sets_unsets_rebases_and_renames_every_use() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap();
|
||||
s.execute("style.character.create", &json!({"name": "Emphasis", "chars": {"fontStyle": "Italic", "tracking": 20}})).unwrap();
|
||||
s.execute(
|
||||
"style.paragraph.create",
|
||||
&json!({"name": "Lead", "para": {
|
||||
"nestedStyles": [{"style": "Emphasis", "through": true, "count": 1, "until": {"kind": "words"}}],
|
||||
"grepStyles": [{"style": "Emphasis", "pattern": "\\d+"}],
|
||||
"nestedLineStyles": [{"style": "Emphasis", "lines": 1}]}}),
|
||||
)
|
||||
.unwrap();
|
||||
s.execute("footnote.options", &json!({"refCharStyle": "Emphasis"})).unwrap();
|
||||
let r = s.execute("frame.create", &json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap();
|
||||
let sid = r["story"].as_u64().unwrap();
|
||||
s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 2})).unwrap();
|
||||
s.execute("style.character.apply", &json!({"name": "Emphasis"})).unwrap();
|
||||
|
||||
s.execute(
|
||||
"style.character.edit",
|
||||
&json!({"name": "Emphasis", "rename": "Strong", "basedOn": "Base", "chars": {"size": 14, "fill": "[Paper]", "fontStyle": null}}),
|
||||
)
|
||||
.unwrap();
|
||||
let d = s.doc().unwrap().doc.clone();
|
||||
assert!(d.styles.char_style("Emphasis").is_none());
|
||||
let st = d.styles.char_style("Strong").unwrap();
|
||||
assert_eq!(st.based_on.as_deref(), Some("Base"));
|
||||
// Exactly the attributes set: `null` unset the font style, the rest stayed unset.
|
||||
assert_eq!(st.chars, CharAttrs { size: Some(14.0), fill: Some("[Paper]".into()), tracking: Some(20.0), ..Default::default() });
|
||||
let story = d.story(designcraft_doc::StoryId(sid)).unwrap();
|
||||
assert!(story.chars.iter().any(|r| r.format.style == "Strong"));
|
||||
assert!(story.chars.iter().all(|r| r.format.style != "Emphasis"));
|
||||
let lead = d.styles.para("Lead").unwrap();
|
||||
assert_eq!(lead.para.nested_styles.as_ref().unwrap()[0].style, "Strong");
|
||||
assert_eq!(lead.para.grep_styles.as_ref().unwrap()[0].style, "Strong");
|
||||
assert_eq!(lead.para.nested_line_styles.as_ref().unwrap()[0].style, "Strong");
|
||||
assert_eq!(d.footnote_options.ref_char_style, "Strong");
|
||||
|
||||
// Based On [None] (null or the name) clears it.
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": null})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "[None]"})).unwrap();
|
||||
assert_eq!(s.doc().unwrap().doc.styles.char_style("Strong").unwrap().based_on, None);
|
||||
|
||||
// Refused: a based-on cycle, an unknown parent, a taken or empty name, editing [None].
|
||||
s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Base"})).unwrap();
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Base", "basedOn": "Strong"})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "basedOn": "Nope"})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": "Base"})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "Strong", "rename": " "})).is_err());
|
||||
assert!(s.execute("style.character.edit", &json!({"name": "[None]", "chars": {"size": 9}})).is_err());
|
||||
assert!(s.doc().unwrap().doc.styles.char_style("[None]").unwrap().chars.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn new_character_style_checks_its_based_on() {
|
||||
let mut s = Session::new();
|
||||
s.execute("file.new", &json!({})).unwrap();
|
||||
assert!(s.execute("style.character.create", &json!({"name": "Orphan", "basedOn": "Nope"})).is_err());
|
||||
s.execute("style.character.create", &json!({"name": "Plain", "basedOn": "[None]", "chars": {"size": 9}})).unwrap();
|
||||
let st = s.doc().unwrap().doc.styles.char_style("Plain").cloned().unwrap();
|
||||
assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() }));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn swatches_save_and_load_ase() {
|
||||
let mut s = Session::new();
|
||||
|
||||
Reference in New Issue
Block a user