diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 478cf04..7a700fb 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -336,6 +336,15 @@ impl Styles { cp } + /// The attributes character style `name` sets, with those of its Based On chain beneath them. + pub fn char_style_attrs(&self, name: &str) -> CharAttrs { + let mut a = CharAttrs::default(); + for s in self.char_chain(name) { + a.merge(&s.chars); + } + a + } + /// Would making `name` based on `parent` create a cycle? pub fn para_based_on_cycles(&self, name: &str, parent: &str) -> bool { name == parent || self.para_chain(parent).iter().any(|s| s.name == name) @@ -402,6 +411,24 @@ mod tests { assert!(!st.para_based_on_cycles(BASIC_PARAGRAPH, NO_PARA_STYLE)); } + #[test] + fn character_style_attrs_include_its_based_on_chain_only() { + let mut st = Styles::default(); + let cs = |name: &str, based_on: Option<&str>, chars| CharacterStyle { + name: name.into(), + based_on: based_on.map(Into::into), + chars, + shortcut: String::new(), + }; + st.character.push(cs("Base", None, CharAttrs { font_style: Some("Bold".into()), size: Some(9.0), ..Default::default() })); + st.character.push(cs("Strong", Some("Base"), CharAttrs { size: Some(14.0), ..Default::default() })); + st.character.push(cs("Loop", Some("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() })); + assert_eq!(st.char_style_attrs("Strong"), CharAttrs { font_style: Some("Bold".into()), size: Some(14.0), ..Default::default() }); + assert_eq!(st.char_style_attrs("Loop"), CharAttrs { tracking: Some(5.0), ..Default::default() }); + assert!(st.char_style_attrs(NO_CHAR_STYLE).is_empty()); + assert!(st.char_style_attrs("Missing").is_empty()); + } + #[test] fn unique_names() { let names = ["Body", "Body 2"]; diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 3fb86fb..92641e4 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -100,8 +100,24 @@ pub fn specs() -> Vec { 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::>() { 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 { 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 { fn create_char(s: &mut Session, p: &Value) -> Result { 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 { fn edit_char(s: &mut Session, p: &Value) -> Result { 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 = 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> = 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(); diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 503c2c9..a2dc5c1 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -50,7 +50,7 @@ impl Dialog { fields.insert("separator".into(), json!(sep.replace('\t', "^t").replace('\u{2003}', "^m").replace('\u{2002}', "^>"))); fields.entry("tab".to_string()).or_insert(json!("numbering")); } - if id == "paragraphStyleOptions" { + if id == "paragraphStyleOptions" || id == "characterStyleOptions" { fields.entry("section".to_string()).or_insert(json!("general")); } if id == "frameSize" { @@ -821,6 +821,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "documentSetup" => crate::i18n::tr(&app.ui.language, "Document Setup"), "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "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"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), "findFont" => crate::i18n::tr(&app.ui.language, "Find/Replace Font"), @@ -1065,6 +1066,7 @@ 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), "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), @@ -1492,24 +1494,71 @@ pub fn confirm(app: &mut DesignApp) -> Result { chars.insert(a.into(), v.clone()); } } - let mut params = json!({"name": name, "para": para, "chars": chars}); let based = d.s("basedOn"); - if !based.is_empty() { - params["basedOn"] = if based == "[No Paragraph Style]" { Value::Null } else { json!(based) }; - } let rename = d.s("rename"); - if !rename.is_empty() && rename != name { - params["rename"] = json!(rename); - } - let r = app.run("style.paragraph.edit", params)?; + let (r, style) = if d.b("new") { + let mut params = json!({"name": if rename.trim().is_empty() { "Paragraph Style 1" } else { rename.trim() }, "para": para, "chars": chars}); + if !based.is_empty() && based != designcraft_doc::NO_PARA_STYLE { + params["basedOn"] = json!(based); + } + let r = app.run("style.paragraph.create", params)?; + let style = r["name"].as_str().unwrap_or_default().to_string(); + (r, style) + } else { + let mut params = json!({"name": name, "para": para, "chars": chars}); + if !based.is_empty() { + params["basedOn"] = if based == designcraft_doc::NO_PARA_STYLE { Value::Null } else { json!(based) }; + } + if !rename.is_empty() && rename != name { + params["rename"] = json!(rename); + } + let style = if !rename.is_empty() && rename != name { rename } else { name }; + (app.run("style.paragraph.edit", params)?, style) + }; if d.fields.contains_key("x.tag") { let tag = d.s("x.tag"); let tag = if tag == "[Automatic]" { String::new() } else { tag }; - let style = if !rename.is_empty() && rename != name { rename } else { name }; app.run("style.exportTag", json!({"style": style, "tag": tag, "class": d.s("x.class")}))?; } Ok(r) } + "characterStyleOptions" => { + let name = d.s("name"); + let mut chars = Map::new(); + for (k, v) in &d.fields { + if let Some(a) = k.strip_prefix("c.") { + chars.insert(a.into(), v.clone()); + } + } + let rename = d.s("rename").trim().to_string(); + let based = d.fields.get("basedOn").and_then(Value::as_str).map(str::to_string); + let r = if d.b("new") { + // A new style sets only what was filled in. + chars.retain(|_, v| !v.is_null()); + let mut params = json!({"name": if rename.is_empty() { "Character Style 1" } else { rename.as_str() }, "chars": chars}); + if let Some(b) = based.filter(|b| b != designcraft_doc::NO_CHAR_STYLE) { + params["basedOn"] = json!(b); + } + app.run("style.character.create", params) + } else { + let mut params = json!({"name": name, "chars": chars}); + if let Some(b) = based { + params["basedOn"] = if b == designcraft_doc::NO_CHAR_STYLE { Value::Null } else { json!(b) }; + } + if !rename.is_empty() && rename != name { + params["rename"] = json!(rename); + } + app.run("style.character.edit", params) + }; + if let Err(e) = &r { + // Keep the dialog open with the reason (a taken name, a based-on loop). + let mut d = d.clone(); + d.fields.insert("status".into(), json!(e)); + d.fields.insert("section".into(), json!("general")); + app.ui.dialog = Some(d); + } + r + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -1751,13 +1800,29 @@ fn regex_ok(p: &str) -> Result<(), ()> { fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); + let new = d.b("new"); let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return }; - let Some(style) = doc.styles.para(&name).cloned() else { - ui.label(format!("No style named {name}")); - return; + let style = match doc.styles.para(&name) { + _ if new => designcraft_doc::ParagraphStyle { + name: String::new(), + based_on: None, + next_style: None, + para: Default::default(), + chars: Default::default(), + shortcut: String::new(), + }, + Some(s) => s.clone(), + None => { + ui.label(format!("No style named {name}")); + return; + } }; - let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect(); - let (pp, cp) = doc.styles.resolve_para_style(&name); + if new && !d.fields.contains_key("rename") { + d.fields.insert("rename".into(), json!(next_style_name(&doc.styles, true))); + } + let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| new || *n != name).collect(); + // A new style shows what it would inherit from its Based On. + let (pp, cp) = doc.styles.resolve_para_style(if new { d.fields.get("basedOn").and_then(Value::as_str).unwrap_or("") } else { &name }); let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); @@ -1767,9 +1832,11 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo // A fixed height: the separator would otherwise stretch the dialog to the window. ui.set_min_height(380.0); ui.set_max_height(380.0); - ui.vertical(|ui| { - ui.set_width(170.0); - for (id, label) in [ + section_list( + &app.ui.language, + ui, + d, + &[ ("general", "General"), ("chars", "Basic Character Formats"), ("indents", "Indents and Spacing"), @@ -1779,12 +1846,8 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ("grep", "GREP Style"), ("color", "Character Color"), ("export", "Export Tagging"), - ] { - if ui.selectable_label(d.s("section") == id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("section".into(), json!(id)); - } - } - }); + ], + ); ui.separator(); let cnames: Vec = app.session.active().map(|s| s.doc.styles.character.iter().map(|c| c.name.clone()).collect()).unwrap_or_default(); ui.vertical(|ui| match d.s("section").as_str() { @@ -2030,44 +2093,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo d.fields.insert("p.grepStyles".into(), Value::Array(list)); } } - "chars" => { - let fonts = crate::panels::fonts(app); - egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); - let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string(); - if let Some(f) = crate::panels::font_combo(app, ui, "psfam", &fam, 200.0) { - d.fields.insert("c.fontFamily".into(), json!(f)); - } - ui.end_row(); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); - let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { - for s in fonts.styles(&fam) { - if ui.selectable_label(s == sty, &s).clicked() { - d.fields.insert("c.fontStyle".into(), json!(s)); - } - } - }); - ui.end_row(); - for (label, key, suffix) in [("Size:", "size", " pt"), ("Tracking:", "tracking", "")] { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, label)); - let v = cur(d, &format!("c.{key}"), &cv[key]).as_f64(); - if let Some(n) = crate::widgets::number(ui, &format!("ps{key}"), v, suffix, 80.0, 2) { - d.fields.insert(format!("c.{key}"), json!(n)); - } - ui.end_row(); - } - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Leading:")); - let lv = match cur(d, "c.leading", &cv["leading"]) { - v if v["kind"] == "points" => v["value"].as_f64(), - _ => None, - }; - if let Some(n) = crate::widgets::number(ui, "pslead", lv, " pt", 80.0, 2) { - d.fields.insert("c.leading".into(), json!({"kind": "points", "value": n})); - } - ui.end_row(); - }); - } + "chars" => basic_character_formats(app, ui, d, &CharFields { base: &cv, sparse: false }), "indents" => { egui::Grid::new("psi").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Alignment:")); @@ -2164,22 +2190,7 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo ui.end_row(); }); } - "color" => { - let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string(); - let swatches: Vec = doc.swatches.iter().map(|s| s.name.clone()).collect(); - egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { - for sw in swatches { - let (c, g) = crate::widgets::swatch_colors(&doc, &sw, 1.0); - ui.horizontal(|ui| { - let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); - crate::widgets::paint_chip(ui.painter(), r, c, g); - if ui.selectable_label(sw == cur_fill, &sw).clicked() { - d.fields.insert("c.fill".into(), json!(sw)); - } - }); - } - }); - } + "color" => character_color(&app.ui.language, ui, d, &CharFields { base: &cv, sparse: false }, &doc), _ => { egui::Grid::new("psg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style Name:")); @@ -2228,6 +2239,489 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } +/// The section list on the left of the style options dialogs. +fn section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) { + ui.vertical(|ui| { + ui.set_width(170.0); + for (id, label) in sections { + if ui.selectable_label(d.s("section") == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert("section".into(), json!(id)); + } + } + }); +} + +/// "Paragraph Style n" / "Character Style n": the first name no style has. +fn next_style_name(styles: &designcraft_doc::Styles, para: bool) -> String { + let (base, count) = if para { ("Paragraph Style", styles.paragraph.len()) } else { ("Character Style", styles.character.len()) }; + let taken = |n: &str| if para { styles.para(n).is_some() } else { styles.char_style(n).is_some() }; + // Among count + 1 numbers at least one is free. + (1..=count + 1).map(|i| format!("{base} {i}")).find(|n| !taken(n)).unwrap_or_else(|| format!("{base} {}", count + 1)) +} + +/// Paragraph or Character Style Options for an existing style (a double-click on it in the styles +/// panel, or Edit in its menu). [None] and [No Paragraph Style] have no options. +pub fn open_style_options(app: &mut DesignApp, para: bool, name: &str) { + if name == if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE } { + return; + } + let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; + app.ui.dialog = Some(Dialog::new(id, json!({"name": name}))); +} + +/// New Paragraph Style… / New Character Style…: the style options for a style made on OK, named +/// with the next free "Paragraph Style n" / "Character Style n". +pub fn open_new_style(app: &mut DesignApp, para: bool) { + let Some(st) = app.session.active() else { return }; + let name = next_style_name(&st.doc.styles, para); + let id = if para { "paragraphStyleOptions" } else { "characterStyleOptions" }; + app.ui.dialog = Some(Dialog::new(id, json!({"new": true, "rename": name}))); +} + +/// Character Style Options: General, then the character sections it shares with Paragraph Style +/// Options. A character style sets only some attributes: the fields show those it and its Based +/// On set and leave the rest blank. Edits are `c.` fields (`null` = no longer set), applied +/// on OK; `new` makes the style instead of editing `name`. +fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + let new = d.b("new"); + let Some(doc) = app.session.active().map(|st| st.doc.clone()) else { return }; + let styles = &doc.styles; + let style = match styles.char_style(&name) { + _ if new => designcraft_doc::CharacterStyle { name: String::new(), based_on: None, chars: Default::default(), shortcut: String::new() }, + Some(s) if name != designcraft_doc::NO_CHAR_STYLE => s.clone(), + _ => { + crate::rtl::label(ui, format!("No style named {name}")); + return; + } + }; + if !d.fields.contains_key("rename") { + d.fields.insert("rename".into(), json!(if new { next_style_name(styles, false) } else { name.clone() })); + } + let based = d + .fields + .get("basedOn") + .and_then(Value::as_str) + .map(str::to_string) + .or(style.based_on.clone()) + .unwrap_or_else(|| designcraft_doc::NO_CHAR_STYLE.into()); + // The fields show the style's own attributes over those its Based On gives it. + let mut shown = styles.char_style_attrs(&based); + shown.merge(&style.chars); + let base = serde_json::to_value(&shown).unwrap_or_default(); + let cf = CharFields { base: &base, sparse: true }; + let lang = app.ui.language.clone(); + ui.set_min_width(560.0); + ui.horizontal_top(|ui| { + // A fixed height: the separator would otherwise stretch the dialog to the window. + ui.set_min_height(380.0); + ui.set_max_height(380.0); + section_list( + &lang, + ui, + d, + &[ + ("general", "General"), + ("chars", "Basic Character Formats"), + ("advanced", "Advanced Character Formats"), + ("color", "Character Color"), + ("openType", "OpenType Features"), + ("underline", "Underline Options"), + ("strikethrough", "Strikethrough Options"), + ], + ); + ui.separator(); + ui.vertical(|ui| match d.s("section").as_str() { + "chars" => basic_character_formats(app, ui, d, &cf), + "advanced" => advanced_character_formats(&lang, ui, d, &cf), + "color" => character_color(&lang, ui, d, &cf, &doc), + "openType" => open_type_features(&lang, ui, d, &cf), + "underline" => line_options(&lang, ui, d, &cf, &doc, "underline"), + "strikethrough" => line_options(&lang, ui, d, &cf, &doc, "strikethrough"), + _ => { + egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&lang, "Style Name:")); + text_field(ui, d, "rename", 220.0); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&lang, "Based On:")); + egui::ComboBox::from_id_salt("csbased") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&lang, &based))) + .width(220.0) + .show_ui(ui, |ui| { + // Not itself, nor a style based on it. + for s in styles.character.iter().filter(|s| new || !styles.char_based_on_cycles(&name, &s.name)) { + if ui.selectable_label(s.name == based, crate::rtl::widget(ui, crate::i18n::style_name(&lang, &s.name))).clicked() { + d.fields.insert("basedOn".into(), json!(s.name)); + } + } + }); + ui.end_row(); + }); + let status = d.s("status"); + if !status.is_empty() { + crate::rtl::label(ui, egui::RichText::new(status).color(crate::theme::Tokens::get(ui.ctx()).text_strong)); + } + ui.add_space(8.0); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Style Settings:")).font(semibold(12.0))); + // What OK would leave the style setting, after its Based On. + let mut own = serde_json::to_value(&style.chars).unwrap_or_default(); + if let Some(o) = own.as_object_mut() { + for (k, v) in d.fields.iter().filter_map(|(k, v)| Some((k.strip_prefix("c.")?, v))) { + if v.is_null() { + o.remove(k); + } else { + o.insert(k.to_string(), v.clone()); + } + } + } + let mut parts = vec![crate::i18n::style_name(&lang, &based).to_string()]; + parts.extend(own.as_object().into_iter().flatten().map(|(k, v)| format!("{}: {}", setting_label(&lang, k), setting_text(v)))); + ui.add(egui::Label::new(egui::RichText::new(parts.join(" + ")).color(crate::theme::Tokens::get(ui.ctx()).text_dim)).wrap()); + } + }); + }); +} + +/// An attribute's name as Style Settings shows it: the field's label where the name reads poorly. +fn setting_label(lang: &str, key: &str) -> String { + match key { + "hScale" => crate::i18n::tr(lang, "Horizontal Scale:").trim_end_matches([':', ':']).to_string(), + "vScale" => crate::i18n::tr(lang, "Vertical Scale:").trim_end_matches([':', ':']).to_string(), + "capitalization" => crate::i18n::tr(lang, "Case:").trim_end_matches([':', ':']).to_string(), + "otfFeatures" => crate::i18n::tr(lang, "OpenType Features").to_string(), + _ => crate::i18n::tr(lang, &humanize(key)).to_string(), + } +} + +/// An attribute value as Style Settings shows it. +fn setting_text(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Number(n) => n.as_f64().map_or_else(|| n.to_string(), |x| format!("{}", (x * 1000.0).round() / 1000.0)), + Value::Bool(b) => if *b { "on" } else { "off" }.into(), + Value::Array(a) => a.iter().map(setting_text).collect::>().join(", "), + Value::Object(o) => o.get("value").or_else(|| o.get("kind")).map(setting_text).unwrap_or_default(), + Value::Null => String::new(), + } +} + +/// Where the character sections of the style options dialogs get the values they show: `base` +/// holds them by attribute name (resolved values for a paragraph style; for a character style the +/// attributes it sets, the others absent). `sparse` (a character style) lets a field be blank: +/// emptying it, or picking the blank entry, unsets the attribute. +struct CharFields<'a> { + base: &'a Value, + sparse: bool, +} + +impl CharFields<'_> { + /// The value shown for attribute `key`: the dialog's edit, else the base value. + fn get(&self, d: &Dialog, key: &str) -> Value { + d.fields.get(&format!("c.{key}")).cloned().unwrap_or_else(|| self.base.get(key).cloned().unwrap_or(Value::Null)) + } + fn set(&self, d: &mut Dialog, key: &str, v: Value) { + d.fields.insert(format!("c.{key}"), v); + } +} + +/// A number attribute shown multiplied by `scale` (100 for a fraction shown as a percentage). +fn char_number(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, suffix: &str, scale: f64, range: (f64, f64)) { + let v = cf.get(d, key).as_f64().map(|x| x * scale); + let field = crate::widgets::NumField::number(key, v, suffix, 2).width(80.0).range(range.0, range.1); + let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; + if let Some(n) = edit { + cf.set(d, key, n.map_or(Value::Null, |n| json!(n / scale))); + } +} + +/// An attribute chosen from `opts` (value, label); a character style also has a blank entry. +fn char_choice(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, opts: &[(Value, &str)]) { + let cur = cf.get(d, key); + let shown = opts.iter().find(|o| o.0 == cur).map_or("", |o| crate::i18n::tr(lang, o.1)); + egui::ComboBox::from_id_salt(("cs", key)).selected_text(crate::rtl::widget(ui, shown)).width(200.0).show_ui(ui, |ui| { + if cf.sparse && ui.selectable_label(cur.is_null(), " ").clicked() { + cf.set(d, key, Value::Null); + } + for (v, label) in opts { + if ui.selectable_label(*v == cur, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + cf.set(d, key, v.clone()); + } + } + }); +} + +/// An on/off attribute; for a character style a third, mixed state leaves it unset (clicks go +/// unset → on → off → unset). +fn char_check(ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, key: &str, label: &str) { + let cur = cf.get(d, key); + let mut on = cur.as_bool().unwrap_or(false); + let unset = cf.sparse && cur.is_null(); + if ui.add(egui::Checkbox::new(&mut on, crate::rtl::widget(ui, label)).indeterminate(unset)).clicked() { + let next = match cur.as_bool() { + _ if !cf.sparse => json!(on), + None => json!(true), + Some(true) => json!(false), + Some(false) => Value::Null, + }; + cf.set(d, key, next); + } +} + +/// A swatch attribute from a menu; `text_color` names the "" entry (the text's own colour). +fn char_swatch(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document, key: &str, text_color: Option<&str>) { + let mut opts: Vec<(Value, &str)> = text_color.map(|l| (json!(""), l)).into_iter().collect(); + opts.extend(doc.swatches.iter().map(|w| (json!(w.name), w.name.as_str()))); + char_choice(lang, ui, d, cf, key, &opts); +} + +/// Basic Character Formats: font, size, leading, kerning, tracking, case, position and the +/// underline, strikethrough, ligatures and no-break switches. +fn basic_character_formats(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) { + let lang = app.ui.language.clone(); + let lang = lang.as_str(); + let fonts = crate::panels::fonts(app); + let menu = crate::panels::font_menu(app); + egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Font Family:")); + let fam = cf.get(d, "fontFamily").as_str().unwrap_or("").to_string(); + let shown = if fam.is_empty() { "—".to_string() } else { crate::panels::font_label(app, &menu, &fam) }; + egui::ComboBox::from_id_salt("psfam") + .selected_text(shown) + .width(200.0) + .height(440.0) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| { + if cf.sparse && ui.selectable_label(fam.is_empty(), " ").clicked() { + cf.set(d, "fontFamily", Value::Null); + } + if let Some(f) = crate::panels::font_menu_body(app, ui, &menu, &fam, 200.0) { + cf.set(d, "fontFamily", json!(f)); + } + }); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Font Style:")); + let sty = cf.get(d, "fontStyle").as_str().unwrap_or("").to_string(); + if cf.sparse && fam.is_empty() { + // No family to list styles of: type the style ("Italic" in whatever font the text has). + let mut s = sty.clone(); + if ui.add(egui::TextEdit::singleline(&mut s).desired_width(200.0)).changed() { + cf.set(d, "fontStyle", if s.trim().is_empty() { Value::Null } else { json!(s) }); + } + } else { + egui::ComboBox::from_id_salt("pssty").selected_text(&sty).width(200.0).show_ui(ui, |ui| { + if cf.sparse && ui.selectable_label(sty.is_empty(), " ").clicked() { + cf.set(d, "fontStyle", Value::Null); + } + for s in fonts.styles(&fam) { + if ui.selectable_label(s == sty, &s).clicked() { + cf.set(d, "fontStyle", json!(s)); + } + } + }); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Size:")); + char_number(ui, d, cf, "size", " pt", 1.0, (0.1, 1296.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Leading:")); + let lv = match cf.get(d, "leading") { + v if v["kind"] == "points" => v["value"].as_f64(), + _ => None, + }; + let field = crate::widgets::NumField::number("pslead", lv, " pt", 2).width(80.0).range(0.0, 5000.0); + let edit = if cf.sparse { field.show_or_clear(ui) } else { field.show(ui).map(Some) }; + if let Some(n) = edit { + cf.set(d, "leading", n.map_or(Value::Null, |n| json!({"kind": "points", "value": n}))); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Kerning:")); + char_choice( + lang, + ui, + d, + cf, + "kerning", + &[(json!({"kind": "metrics"}), "Metrics"), (json!({"kind": "optical"}), "Optical"), (json!({"kind": "none"}), "0")], + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Tracking:")); + char_number(ui, d, cf, "tracking", "", 1.0, (-1000.0, 10000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Case:")); + char_choice( + lang, + ui, + d, + cf, + "capitalization", + &[ + (json!("normal"), "Normal"), + (json!("allCaps"), "All Caps"), + (json!("smallCaps"), "Small Caps"), + (json!("openTypeAllSmallCaps"), "OpenType All Small Caps"), + ], + ); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Position:")); + char_choice( + lang, + ui, + d, + cf, + "position", + &[ + (json!("normal"), "Normal"), + (json!("superscript"), "Superscript"), + (json!("subscript"), "Subscript"), + (json!("otSuperscript"), "OpenType Superscript"), + (json!("otSubscript"), "OpenType Subscript"), + (json!("otNumerator"), "OpenType Numerator"), + (json!("otDenominator"), "OpenType Denominator"), + ], + ); + ui.end_row(); + }); + ui.add_space(4.0); + egui::Grid::new("pscb").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| { + char_check(ui, d, cf, "underline", crate::i18n::tr(lang, "Underline")); + char_check(ui, d, cf, "ligatures", crate::i18n::tr(lang, "Ligatures")); + ui.end_row(); + char_check(ui, d, cf, "strikethrough", crate::i18n::tr(lang, "Strikethrough")); + char_check(ui, d, cf, "noBreak", crate::i18n::tr(lang, "No Break")); + ui.end_row(); + }); +} + +/// Advanced Character Formats: scaling, baseline shift, skew and language. +fn advanced_character_formats(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) { + egui::Grid::new("csa").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Horizontal Scale:")); + char_number(ui, d, cf, "hScale", "%", 100.0, (1.0, 1000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Vertical Scale:")); + char_number(ui, d, cf, "vScale", "%", 100.0, (1.0, 1000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Baseline Shift:")); + char_number(ui, d, cf, "baselineShift", " pt", 1.0, (-5000.0, 5000.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Skew:")); + char_number(ui, d, cf, "skew", "°", 1.0, (-85.0, 85.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Language:")); + let opts: Vec<(Value, &str)> = crate::panels::properties::LANGUAGES.iter().map(|l| (json!(l), *l)).collect(); + char_choice(lang, ui, d, cf, "language", &opts); + ui.end_row(); + }); +} + +/// Character Color: the fill or stroke swatch, its tint and the stroke weight. +fn character_color(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document) { + let stroke = d.s("colorTarget") == "stroke"; + ui.horizontal(|ui| { + for (target, label) in [("fill", "Fill"), ("stroke", "Stroke")] { + if ui.selectable_label((target == "stroke") == stroke, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() { + d.fields.insert("colorTarget".into(), json!(target)); + } + } + }); + let key = if stroke { "stroke" } else { "fill" }; + let cur = cf.get(d, key).as_str().unwrap_or("").to_string(); + egui::ScrollArea::vertical().id_salt(("cscolor", key)).max_height(220.0).show(ui, |ui| { + for sw in &doc.swatches { + let (c, g) = crate::widgets::swatch_colors(doc, &sw.name, 1.0); + ui.horizontal(|ui| { + let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); + crate::widgets::paint_chip(ui.painter(), r, c, g); + if ui.selectable_label(sw.name == cur, &sw.name).clicked() { + // A character style's chosen swatch, clicked again, is no longer set. + cf.set(d, key, if cf.sparse && sw.name == cur { Value::Null } else { json!(sw.name) }); + } + }); + } + }); + egui::Grid::new("cscg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); + char_number(ui, d, cf, if stroke { "strokeTint" } else { "fillTint" }, "%", 100.0, (0.0, 100.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); + char_number(ui, d, cf, "strokeWeight", " pt", 1.0, (0.0, 800.0)); + ui.end_row(); + }); +} + +/// OpenType Features: the feature switches, figure style and stylistic sets (one `otfFeatures` +/// list: changing any feature sets the whole list). +fn open_type_features(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields) { + use designcraft_doc::otf; + let mut list: Vec = + cf.get(d, "otfFeatures").as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); + let mut changed = false; + egui::Grid::new("csot").num_columns(2).spacing([16.0, 4.0]).show(ui, |ui| { + for (i, (tag, label, _)) in otf::TOGGLES.iter().enumerate() { + let mut on = otf::is_on(&list, tag); + if ui.checkbox(&mut on, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).changed() { + otf::set(&mut list, tag, on); + changed = true; + } + if i % 2 == 1 { + ui.end_row(); + } + } + }); + ui.add_space(6.0); + ui.horizontal(|ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Figure Style:")); + let fig = otf::figures(&list); + let shown = otf::FIGURES.iter().find(|f| f.0 == fig).map_or("", |f| f.1); + egui::ComboBox::from_id_salt("csfig").selected_text(crate::rtl::widget(ui, crate::i18n::tr(lang, shown))).width(180.0).show_ui(ui, |ui| { + for (id, label, ..) in otf::FIGURES { + if ui.selectable_label(fig == *id, crate::rtl::widget(ui, crate::i18n::tr(lang, label))).clicked() && otf::set_figures(&mut list, id) + { + changed = true; + } + } + }); + }); + ui.add_space(4.0); + crate::rtl::label(ui, crate::i18n::tr(lang, "Stylistic Sets")); + let mask = otf::stylistic_sets(&list); + egui::Grid::new("csss").num_columns(10).spacing([2.0, 2.0]).show(ui, |ui| { + for n in 1..=20u32 { + let bit = 1u32 << (n - 1); + if ui.add_sized([28.0, 20.0], egui::Button::selectable(mask & bit != 0, format!("{n}"))).clicked() { + otf::set_stylistic_sets(&mut list, mask ^ bit); + changed = true; + } + if n % 10 == 0 { + ui.end_row(); + } + } + }); + if changed { + cf.set(d, "otfFeatures", json!(list)); + } +} + +/// Underline Options / Strikethrough Options (`key` is `underline` or `strikethrough`). +fn line_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, doc: &designcraft_doc::Document, key: &str) { + char_check(ui, d, cf, key, crate::i18n::tr(lang, if key == "underline" { "Underline On" } else { "Strikethrough On" })); + ui.add_space(4.0); + egui::Grid::new(("csline", key)).num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(lang, "Weight:")); + char_number(ui, d, cf, &format!("{key}Weight"), " pt", 1.0, (0.0, 800.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Offset:")); + char_number(ui, d, cf, &format!("{key}Offset"), " pt", 1.0, (-800.0, 800.0)); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Color:")); + char_swatch(lang, ui, d, cf, doc, &format!("{key}Color"), Some("(Text Color)")); + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(lang, "Tint:")); + char_number(ui, d, cf, &format!("{key}Tint"), "%", 100.0, (0.0, 100.0)); + ui.end_row(); + }); +} + fn combo(ui: &mut egui::Ui, d: &mut Dialog, key: &str, opts: &[(&str, &str)]) { let cur = d.s(key); let shown = opts.iter().find(|o| o.0 == cur).map_or(cur.as_str(), |o| o.1).to_string(); @@ -2806,6 +3300,143 @@ mod tests { } } + use designcraft_doc::{CharAttrs, CharacterStyle}; + + /// A document with character styles Base (bold) and Emphasis (italic) on the first two letters. + fn styled_app() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("style.character.create", json!({"name": "Base", "chars": {"fontStyle": "Bold"}})).unwrap(); + app.run("style.character.create", json!({"name": "Emphasis", "chars": {"fontStyle": "Italic"}})).unwrap(); + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hi there"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 2})).unwrap(); + app.run("style.character.apply", json!({"name": "Emphasis"})).unwrap(); + app + } + + /// One control-channel request, as the control port or MCP would send it. + fn ctl(app: &mut DesignApp, method: &str, params: Value) -> Value { + let (req, _reply) = crate::control::ControlRequest::new(method, params); + match crate::control::handle(app, &egui::Context::default(), &req) { + crate::control::Outcome::Done(v) => v, + crate::control::Outcome::Screenshot { .. } => Value::Null, + } + } + + fn char_style(app: &DesignApp, name: &str) -> Option { + app.session.active().and_then(|st| st.doc.styles.char_style(name).cloned()) + } + + #[test] + fn character_style_options_set_exactly_the_edited_attributes() { + let mut app = styled_app(); + assert_eq!(ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Emphasis"}}))["ok"], true); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 14})); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.fill", "value": "[Paper]"})); + ctl(&mut app, "ui.dialog.set", json!({"field": "basedOn", "value": "Base"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert!(app.ui.dialog.is_none()); + let st = char_style(&app, "Emphasis").unwrap(); + assert_eq!(st.based_on.as_deref(), Some("Base")); + assert_eq!(st.chars, CharAttrs { font_style: Some("Italic".into()), size: Some(14.0), fill: Some("[Paper]".into()), ..Default::default() }); + } + + #[test] + fn character_style_options_unset_cleared_fields_and_rename_every_use() { + let mut app = styled_app(); + ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Emphasis"}})); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.fontStyle", "value": null})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rename", "value": "Strong"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + assert!(char_style(&app, "Emphasis").is_none()); + assert!(char_style(&app, "Strong").unwrap().chars.is_empty()); + let st = app.session.active().unwrap(); + let story = st.doc.stories.values().next().unwrap(); + assert!(story.chars.iter().any(|r| r.format.style == "Strong")); + // A taken name keeps the dialog open with the reason. + ctl(&mut app, "ui.dialog.open", json!({"id": "characterStyleOptions", "fields": {"name": "Strong"}})); + ctl(&mut app, "ui.dialog.set", json!({"field": "rename", "value": "Base"})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], false); + assert!(app.ui.dialog.as_ref().is_some_and(|d| !d.s("status").is_empty())); + assert!(char_style(&app, "Strong").is_some()); + } + + #[test] + fn new_character_style_opens_its_options_and_creates_the_style() { + let mut app = styled_app(); + crate::menus::activate(&mut app, "style.character.create", &Value::Null); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.b("new"), d.s("rename").as_str()), ("characterStyleOptions", true, "Character Style 1")); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 9})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let st = char_style(&app, "Character Style 1").unwrap(); + assert_eq!((st.based_on, st.chars), (None, CharAttrs { size: Some(9.0), ..Default::default() })); + crate::menus::activate(&mut app, "style.character.create", &Value::Null); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("rename"), "Character Style 2"); + } + + #[test] + fn new_paragraph_style_opens_its_options_and_creates_the_style() { + let mut app = styled_app(); + crate::menus::activate(&mut app, "style.paragraph.create", &Value::Null); + let d = app.ui.dialog.clone().unwrap(); + assert_eq!((d.id.as_str(), d.b("new"), d.s("rename").as_str()), ("paragraphStyleOptions", true, "Paragraph Style 1")); + ctl(&mut app, "ui.dialog.set", json!({"field": "c.size", "value": 20})); + ctl(&mut app, "ui.dialog.set", json!({"field": "basedOn", "value": designcraft_doc::BASIC_PARAGRAPH})); + assert_eq!(ctl(&mut app, "ui.dialog.confirm", json!({}))["ok"], true); + let st = app.session.active().unwrap().doc.styles.para("Paragraph Style 1").cloned().unwrap(); + assert_eq!(st.based_on.as_deref(), Some(designcraft_doc::BASIC_PARAGRAPH)); + assert_eq!(st.chars, CharAttrs { size: Some(20.0), ..Default::default() }); + } + + #[test] + fn style_rows_open_their_options_except_none() { + let mut app = styled_app(); + open_style_options(&mut app, false, "Emphasis"); + let d = app.ui.dialog.take().unwrap(); + assert_eq!((d.id.as_str(), d.s("name").as_str()), ("characterStyleOptions", "Emphasis")); + open_style_options(&mut app, false, designcraft_doc::NO_CHAR_STYLE); + assert!(app.ui.dialog.is_none()); + open_style_options(&mut app, true, designcraft_doc::BASIC_PARAGRAPH); + assert_eq!(app.ui.dialog.take().unwrap().id, "paragraphStyleOptions"); + } + + #[test] + fn style_options_sections_draw() { + let mut app = styled_app(); + let ctx = egui::Context::default(); + let frame = |app: &mut DesignApp| { + for _ in 0..2 { + let raw = egui::RawInput { + screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1440.0, 900.0))), + max_texture_side: Some(8192), + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| { + app.logic(&ui.ctx().clone()); + app.ui(ui); + }); + out.textures_delta.clear(); + } + }; + for lang in ["", "ar"] { + app.run("app.language", json!({"lang": lang})).unwrap(); + for fields in [json!({"name": "Emphasis"}), json!({"new": true}), json!({"name": "Emphasis", "c.size": null, "c.underline": false})] { + for section in ["general", "chars", "advanced", "color", "openType", "underline", "strikethrough"] { + let mut f = fields.clone(); + f["section"] = json!(section); + app.ui.dialog = Some(Dialog::new("characterStyleOptions", f)); + frame(&mut app); + assert!(app.ui.dialog.is_some(), "{section} stays open"); + } + } + for section in ["general", "chars", "color"] { + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"new": true, "section": section}))); + frame(&mut app); + } + } + } + #[test] fn shortcut_recorder_waits_for_the_key_after_its_modifiers() { let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 6eee4c2..36a33d8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2262,6 +2262,38 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Justify", ["Justify", "Justify", "Justify", "Justify", "两端对齐"]), ("Underline Options", ["Underline Options", "Underline Options", "Underline Options", "Underline Options", "下划线选项"]), ("Strikethrough Options", ["Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "Strikethrough Options", "删除线选项"]), + ( + "Character Style Options", + ["Character Style Options", "Character Style Options", "Character Style Options", "Character Style Options", "字符样式选项"], + ), + ( + "Advanced Character Formats", + ["Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "Advanced Character Formats", "高级字符格式"], + ), + ("OpenType Features", ["OpenType Features", "OpenType Features", "OpenType Features", "OpenType Features", "OpenType 功能"]), + ("Style Settings:", ["Style Settings:", "Style Settings:", "Style Settings:", "Style Settings:", "样式设置:"]), + ("Kerning:", ["Kerning:", "Kerning:", "Kerning:", "Kerning:", "字偶间距:"]), + ("Case:", ["Case:", "Case:", "Case:", "Case:", "大小写:"]), + ( + "OpenType All Small Caps", + ["OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType All Small Caps", "OpenType 全部小型大写字母"], + ), + ("Subscript", ["Subscript", "Subscript", "Subscript", "Subscript", "下标"]), + ("OpenType Subscript", ["OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType Subscript", "OpenType 下标"]), + ("OpenType Numerator", ["OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType Numerator", "OpenType 分子"]), + ("OpenType Denominator", ["OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType Denominator", "OpenType 分母"]), + ("Ligatures", ["Ligatures", "Ligatures", "Ligatures", "Ligatures", "连字"]), + ("No Break", ["No Break", "No Break", "No Break", "No Break", "不换行"]), + ("Horizontal Scale:", ["Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "Horizontal Scale:", "水平缩放:"]), + ("Vertical Scale:", ["Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "Vertical Scale:", "垂直缩放:"]), + ("Baseline Shift:", ["Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "Baseline Shift:", "基线偏移:"]), + ("Skew:", ["Skew:", "Skew:", "Skew:", "Skew:", "倾斜:"]), + ("Language:", ["Language:", "Language:", "Language:", "Language:", "语言:"]), + ("Tint:", ["Tint:", "Tint:", "Tint:", "Tint:", "色调:"]), + ("Figure Style:", ["Figure Style:", "Figure Style:", "Figure Style:", "Figure Style:", "数字样式:"]), + ("Underline On", ["Underline On", "Underline On", "Underline On", "Underline On", "启用下划线"]), + ("Strikethrough On", ["Strikethrough On", "Strikethrough On", "Strikethrough On", "Strikethrough On", "启用删除线"]), + ("Edit \"{name}\"…", ["„{name}“ bearbeiten…", "Modifier « {name} »…", "Editar \"{name}\"…", "\"{name}\" を編集…", "编辑\"{name}\"…"]), ( "Discretionary Ligatures", ["Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "Discretionary Ligatures", "自由连字"], diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 34a50b5..8625093 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -570,6 +570,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Justify", "ضبط"), ("Underline Options", "خيارات التسطير"), ("Strikethrough Options", "خيارات الخط المتوسط"), + ("Character Style Options", "خيارات نمط الأحرف"), + ("Advanced Character Formats", "تنسيقات الأحرف المتقدمة"), + ("OpenType Features", "ميزات OpenType"), + ("Style Settings:", "إعدادات النمط:"), + ("Kerning:", "تقنين الأزواج:"), + ("Case:", "حالة الأحرف:"), + ("OpenType All Small Caps", "كل الأحرف الكبيرة المصغّرة OpenType"), + ("Subscript", "النص المنخفض"), + ("OpenType Subscript", "منخفض OpenType"), + ("OpenType Numerator", "بسط OpenType"), + ("OpenType Denominator", "مقام OpenType"), + ("Ligatures", "الحروف المركبة"), + ("No Break", "بدون فصل"), + ("Horizontal Scale:", "المقياس الأفقي:"), + ("Vertical Scale:", "المقياس الرأسي:"), + ("Baseline Shift:", "إزاحة خط الأساس:"), + ("Skew:", "الإمالة:"), + ("Language:", "اللغة:"), + ("Tint:", "الصبغة:"), + ("Figure Style:", "نمط الأرقام:"), + ("Underline On", "تشغيل التسطير"), + ("Strikethrough On", "تشغيل الخط المتوسط"), + ("Edit \"{name}\"…", "تحرير \"{name}\"…"), ("Discretionary Ligatures", "روابط اختيارية"), ("Fractions", "كسور"), ("Ordinal", "ترتيبي"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index fa7973a..bb754dd 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -569,6 +569,29 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Justify", "Justificar"), ("Underline Options", "Opções de sublinhado"), ("Strikethrough Options", "Opções de tachado"), + ("Character Style Options", "Opções de estilo de caractere"), + ("Advanced Character Formats", "Formatos avançados de caracteres"), + ("OpenType Features", "Recursos OpenType"), + ("Style Settings:", "Configurações do estilo:"), + ("Kerning:", "Kerning:"), + ("Case:", "Caixa:"), + ("OpenType All Small Caps", "Todas em versalete OpenType"), + ("Subscript", "Subscrito"), + ("OpenType Subscript", "Subscrito OpenType"), + ("OpenType Numerator", "Numerador OpenType"), + ("OpenType Denominator", "Denominador OpenType"), + ("Ligatures", "Ligaturas"), + ("No Break", "Sem quebra"), + ("Horizontal Scale:", "Escala horizontal:"), + ("Vertical Scale:", "Escala vertical:"), + ("Baseline Shift:", "Deslocamento da linha de base:"), + ("Skew:", "Inclinação:"), + ("Language:", "Idioma:"), + ("Tint:", "Matiz:"), + ("Figure Style:", "Estilo de algarismos:"), + ("Underline On", "Sublinhado ativado"), + ("Strikethrough On", "Tachado ativado"), + ("Edit \"{name}\"…", "Editar \"{name}\"…"), ("Discretionary Ligatures", "Ligaduras discricionárias"), ("Fractions", "Frações"), ("Ordinal", "Ordinal"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 72c3abe..37db2fb 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1773,6 +1773,20 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { app.ui.dialog = Some(crate::dialogs::Dialog::new("textFrameOptions", json!({}))); return; } + // New Paragraph/Character Style…: the style options for the new style. + if params.is_null() && matches!(id, "style.paragraph.create" | "style.character.create") { + crate::dialogs::open_new_style(app, id == "style.paragraph.create"); + return; + } + // Paragraph/Character Style Options…: those of the selected text's style. + if params.is_null() && matches!(id, "style.paragraph.edit" | "style.character.edit") { + let para = id == "style.paragraph.edit"; + let cur = crate::panels::text_attrs(app).and_then(|a| a[if para { "paragraphStyle" } else { "characterStyle" }].as_str().map(str::to_string)); + if let Some(name) = cur { + crate::dialogs::open_style_options(app, para, &name); + return; + } + } if params.is_null() && ui_label(id).is_none() && let Some(c) = designcraft_engine::find_command(id) diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 5d81e29..09f759c 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1178,7 +1178,7 @@ fn text_frame_section(app: &mut DesignApp, ui: &mut Ui) { } /// Languages offered for text (InDesign-style names). -const LANGUAGES: &[&str] = &[ +pub(crate) const LANGUAGES: &[&str] = &[ "[No Language]", "English: USA", "English: UK", diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index c341209..82dc128 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -78,8 +78,8 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } -/// One style in the list: click applies (⌥ clears overrides), double-click edits (paragraph), -/// right-click: apply, break link, move to group. +/// One style in the list: click applies (⌥ clears overrides), double-click edits, +/// right-click: edit, apply, break link, move to group. fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, current: Option<&str>, groups: &[String], t: &Tokens) { let (row, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); if current == Some(n) { @@ -96,8 +96,9 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren egui::FontId::proportional(12.5), t.text, ); - if resp.double_clicked() && para { - app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + let editable = n != if para { designcraft_doc::NO_PARA_STYLE } else { designcraft_doc::NO_CHAR_STYLE }; + if resp.double_clicked() && editable { + crate::dialogs::open_style_options(app, para, n); } else if resp.clicked() { let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; let clear = ui.input(|i| i.modifiers.alt); @@ -105,17 +106,12 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren } let kind = if para { "paragraph" } else { "character" }; resp.context_menu(|ui| { - if ui - .button(crate::rtl::widget( - ui, - format!( - "{} \"{}\"", - crate::i18n::tr(&app.ui.language, "Apply"), - if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) } - ), - )) - .clicked() - { + let label = if n.contains('/') { shown } else { crate::i18n::style_name(&app.ui.language, shown) }; + if editable && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit \"{name}\"…").replace("{name}", label))).clicked() { + crate::dialogs::open_style_options(app, para, n); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, format!("{} \"{}\"", crate::i18n::tr(&app.ui.language, "Apply"), label))).clicked() { let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; let _ = app.run(cmd, json!({"name": n})); ui.close(); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index dd7f3d8..b13627c 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -134,12 +134,19 @@ impl<'a> NumField<'a> { /// Draw the field; returns the new value when the user commits an edit (Enter, focus loss, /// a spinner click, ↑/↓ while focused (Shift = ×10) or a preset). pub fn show(self, ui: &mut Ui) -> Option { + self.show_or_clear(ui).flatten() + } + + /// Like [`NumField::show`]; a field emptied while it had a value commits `Some(None)` (for + /// values that may be unset, like a character style's attributes). + pub fn show_or_clear(self, ui: &mut Ui) -> Option> { let t = Tokens::get(ui.ctx()); let key = ui.id().with(self.id); let (rect, _) = ui.allocate_exact_size(vec2(self.width, FIELD_H), Sense::hover()); let step = self.step.unwrap_or_else(|| self.kind.step()); let clamp = |v: f64| v.clamp(self.range.0, self.range.1); let mut out: Option = None; + let mut cleared = false; let mut text_rect = rect; let focused_id = key.with("te"); let focused = ui.memory(|m| m.has_focus(focused_id)); @@ -218,6 +225,8 @@ impl<'a> NumField<'a> { && self.value.is_none_or(|old| (old - v).abs() > 1e-9) { out = Some(v); + } else if buf.trim().is_empty() && self.value.is_some() { + cleared = true; } ui.data_mut(|d| d.remove::(key)); } else if !r.has_focus() { @@ -246,7 +255,7 @@ impl<'a> NumField<'a> { } }); } - out + if cleared { Some(None) } else { out.map(Some) } } } @@ -662,6 +671,36 @@ mod tests { assert_eq!(got, vec![12.0]); } + #[test] + fn emptying_a_clearable_field_clears_it() { + let ctx = egui::Context::default(); + let field = |ui: &mut Ui| NumField::number("c", Some(12.0), " pt", 2).width(80.0).show_or_clear(ui); + let mut rect = Rect::NOTHING; + run(&ctx, vec![], egui::Modifiers::NONE, &mut |ui| { + rect = ui.available_rect_before_wrap(); + field(ui); + }); + let key = |k, m| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: m }; + let mut frames: Vec> = click(rect.min + vec2(40.0, 10.0), egui::Modifiers::NONE); + frames.push(vec![key(egui::Key::A, egui::Modifiers::COMMAND)]); + frames.push(vec![key(egui::Key::Backspace, egui::Modifiers::NONE)]); + frames.push(vec![key(egui::Key::Enter, egui::Modifiers::NONE)]); + let mut got = vec![]; + for ev in frames { + let m = if ev.iter().any(|e| matches!(e, egui::Event::Key { modifiers, .. } if modifiers.command)) { + egui::Modifiers::COMMAND + } else { + egui::Modifiers::NONE + }; + run(&ctx, ev, m, &mut |ui| { + if let Some(v) = field(ui) { + got.push(v); + } + }); + } + assert_eq!(got, vec![None]); + } + #[test] fn number_kind_formats_and_parses() { let k = NumKind::Number { suffix: "%", decimals: 0 }; diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 9d53083..d3b6f62 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -30,7 +30,7 @@ so only enable it while you use it. Transport: `apps/designcraft/src/control_ser | `ui.key` / `ui.text` | `{key, shift?, alt?, cmd?}` / `{text}` | Synthetic keyboard input (typing into a text frame) | | `ui.move` / `ui.click` / `ui.drag` | screen points, `button?: left\|right\|middle` | Real egui pointer input — reaches every widget, menu and panel | | `ui.set` | `{brightness?, panel?, rulers?, guides?, frameEdges?, baselineGrid?, textThreads?, screenMode?, zoom?, page?, fit?}` | UI state | -| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` with `{name, section}`) | +| `ui.dialog.open` | `{id, fields?}` | Open a dialog by id (e.g. `paragraphStyleOptions` or `characterStyleOptions` with `{name, section}`, or `{new: true}` for a new style) | | `ui.dialog.set` / `ui.dialog.confirm` / `ui.dialog.cancel` | `{field, value}` | Fill and confirm the open dialog | | `ui.resize`, `ui.focus` | | Window control | | `ui.screenshot` | `{path?}` | PNG of the whole window |