From fde78978a49224de25fe78eb9d18c3c254d9f1b3 Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Thu, 8 Oct 2026 14:55:11 -0700 Subject: [PATCH 1/2] Paragraph Styles: wire save and delete into the UI (#169) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The engine already supported style.paragraph.edit and style.paragraph.delete, but the UI never exposed a delete action and edit was only reachable by double-clicking a style row, with no visible affordance. - Add "Edit Paragraph Style…" and "Delete Paragraph Style…" entries to the paragraph style row context menu (hidden for built-in styles). - Add a new deleteParagraphStyle confirmation dialog that detects whether the style is currently in use and, if so, lets the user pick a replacement style (defaulting to [Basic Paragraph]) for affected text before deleting. - Add engine tests covering paragraph style edit (attribute merge and rename propagation), based-on cycle rejection, delete with text reassignment, and rejection of built-in style deletion. - Add UI-level tests covering the edit dialog persisting changes and the delete dialog removing a style and reassigning text, including the default replacement style. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/engine/src/tests.rs | 54 ++++++++++++++++ crates/ui-egui/src/dialogs.rs | 98 +++++++++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 11 ++++ 3 files changed, 163 insertions(+) diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 8f2f39b0f..9eeb2ae03 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -537,3 +537,57 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn paragraph_style_edit_persists_attrs_and_rename() { + let mut s = session(); + let name = + s.execute("style.paragraph.create", &json!({"name": "Body Copy", "para": {"align": "left"}})).unwrap()["name"].as_str().unwrap().to_string(); + s.execute("style.paragraph.edit", &json!({"name": name, "para": {"align": "center"}})).unwrap(); + { + let st = s.doc().unwrap(); + let style = st.doc.styles.para(&name).expect("style still exists"); + assert_eq!(style.para.align, Some(designcraft_doc::Align::Center), "edited attribute persisted"); + } + // Renaming updates the style definition itself, and every place it's referenced. + s.execute("style.paragraph.edit", &json!({"name": name, "rename": "Body Text"})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.para("Body Text").is_some(), "renamed style exists under the new name"); + assert!(st.doc.styles.para(&name).is_none(), "old name is gone"); +} + +#[test] +fn paragraph_style_edit_rejects_based_on_cycle() { + let mut s = session(); + let a = s.execute("style.paragraph.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string(); + let b = s.execute("style.paragraph.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string(); + let r = s.execute("style.paragraph.edit", &json!({"name": a, "basedOn": b})); + assert!(r.is_err(), "A based on B based on A must be rejected"); +} + +#[test] +fn paragraph_style_delete_reassigns_affected_text() { + let mut s = session(); + let name = s.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + let sid = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + s.execute("style.paragraph.apply", &json!({"name": name})).unwrap(); + { + let st = s.doc().unwrap(); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.paras[0].style, name, "style applied before delete"); + } + s.execute("style.paragraph.delete", &json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.para(&name).is_none(), "deleted style is gone"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "affected paragraph reassigned to the replacement style"); +} + +#[test] +fn paragraph_style_delete_rejects_builtin_styles() { + let mut s = session(); + let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH})); + assert!(r.is_err(), "built-in styles can't be deleted"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db14..b94c64445 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -813,6 +813,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"), + "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), "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"), @@ -1053,6 +1054,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } } "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), + "deleteParagraphStyle" => delete_paragraph_style(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), @@ -1482,6 +1484,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { } Ok(r) } + "deleteParagraphStyle" => { + let name = d.s("name"); + let replace = d.s("replaceWith"); + app.run( + "style.paragraph.delete", + json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}), + ) + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -2170,6 +2180,51 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo }); } +fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + ui.set_min_width(380.0); + crate::rtl::label( + ui, + egui::RichText::new( + crate::i18n::tr(&app.ui.language, "Delete the paragraph style \"{name}\"?") + .replace("{name}", crate::i18n::style_name(&app.ui.language, &name)), + ) + .size(12.5), + ); + ui.add_space(8.0); + let Some(st) = app.session.active() else { return }; + let in_use = st.doc.stories.values().any(|s| s.paras.iter().any(|f| f.style == name)); + let names: Vec = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect(); + if in_use { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:")) + .size(11.0) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } else { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } + ui.add_space(4.0); + if !d.fields.contains_key("replaceWith") { + d.fields.insert("replaceWith".into(), json!(designcraft_doc::BASIC_PARAGRAPH)); + } + let replace = d.s("replaceWith"); + egui::ComboBox::from_id_salt("del_para_replace") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace))) + .width(260.0) + .show_ui(ui, |ui| { + for n in &names { + if ui.selectable_label(*n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + d.fields.insert("replaceWith".into(), json!(n)); + } + } + }); +} + 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(); @@ -2681,4 +2736,47 @@ mod tests { let _ = command_fields(c.params); } } + + fn test_app() -> crate::DesignApp { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.session.execute("file.new", &json!({"pages": 1})).unwrap(); + app + } + + #[test] + fn paragraph_style_options_dialog_saves_edits() { + let mut app = test_app(); + let name = app.session.execute("style.paragraph.create", &json!({"name": "Body Copy"})).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.dialog = Some(Dialog::new("paragraphStyleOptions", json!({"name": name, "p.align": "center"}))); + confirm(&mut app).expect("OK saves the style edit"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.styles.para(&name).unwrap().para.align, Some(designcraft_doc::Align::Center)); + } + + #[test] + fn delete_paragraph_style_dialog_removes_the_style_and_reassigns_text() { + let mut app = test_app(); + let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); + app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 0})).unwrap(); + app.session.execute("style.paragraph.apply", &json!({"name": name})).unwrap(); + app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name, "replaceWith": designcraft_doc::BASIC_PARAGRAPH}))); + confirm(&mut app).expect("OK deletes the style"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.para(&name).is_none(), "style removed"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap(); + assert_eq!(story.paras[0].style, designcraft_doc::BASIC_PARAGRAPH, "text reassigned to the replacement style"); + } + + #[test] + fn delete_paragraph_style_dialog_defaults_replacement_to_basic_paragraph() { + let mut app = test_app(); + let name = app.session.execute("style.paragraph.create", &json!({"name": "Caption"})).unwrap()["name"].as_str().unwrap().to_string(); + // No `replaceWith` set — the dialog's default field. + app.ui.dialog = Some(Dialog::new("deleteParagraphStyle", json!({"name": name}))); + confirm(&mut app).expect("OK deletes the style with the default replacement"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.para(&name).is_none()); + } } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index c3412095e..2f41f7422 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -152,5 +152,16 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.data_mut(|d| d.insert_temp(id, name)); }); } + if para && !n.starts_with('[') { + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit Paragraph Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Paragraph Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteParagraphStyle", json!({"name": n}))); + ui.close(); + } + } }); } From 3018c56e0c8428fead12d0e72e1de9160579dbd4 Mon Sep 17 00:00:00 2001 From: zydvc26s67-web Date: Thu, 8 Oct 2026 17:01:14 -0700 Subject: [PATCH 2/2] Character Styles: wire save and delete into the UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mirrors the Paragraph Styles fix (#169): Character Styles had no save or delete affordance at all — not even an edit dialog existed. - Add a new characterStyleOptions dialog (General: rename/based-on, Basic Character Formats, Character Color) reachable by double-click or the row's new "Edit Character Style…" context-menu entry. - Add a style.character.delete engine command (mirrors style.paragraph.delete): rejects built-in styles, reassigns any text runs using the deleted style to a replacement (default [None]). - Add rename and basedOn (with cycle rejection via the existing char_based_on_cycles) support to style.character.edit, matching style.paragraph.edit. - Add a deleteCharacterStyle confirmation dialog with the same in-use detection and replacement-style picker as the paragraph one, wired via a new "Delete Character Style…" context-menu entry. - Add Styles::resolve_char_style to resolve a character style's effective attributes for display, mirroring resolve_para_style. - Add engine tests (edit persistence/rename, based-on cycle rejection, delete with run reassignment, built-in rejection) and UI-level dialog tests for both new dialogs. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- crates/doc/src/styles.rs | 5 + crates/engine/src/cmd/style.rs | 38 +++++ crates/engine/src/tests.rs | 57 +++++++ crates/ui-egui/src/dialogs.rs | 231 ++++++++++++++++++++++++++++ crates/ui-egui/src/panels/styles.rs | 13 ++ 5 files changed, 344 insertions(+) diff --git a/crates/doc/src/styles.rs b/crates/doc/src/styles.rs index 478cf0452..932da5ce2 100644 --- a/crates/doc/src/styles.rs +++ b/crates/doc/src/styles.rs @@ -336,6 +336,11 @@ impl Styles { cp } + /// Character-style-only resolution (no paragraph base, no local overrides). + pub fn resolve_char_style(&self, name: &str) -> CharProps { + self.resolve_char(&CharProps::default(), &CharFormat { style: name.into(), over: CharAttrs::default() }) + } + /// 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) diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 3fb86fb6c..d1ffe2b4f 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -102,6 +102,7 @@ pub fn specs() -> Vec { 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.delete", "Delete Character Style", [], None, "{name, replaceWith?}", has_doc, delete_char), cmd!(query "style.list", "List Styles", [], None, "{} → paragraph and character style names", has_doc, |s, _| { let st = s.doc()?; Ok(json!({ @@ -594,9 +595,46 @@ 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(); 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(); s.edit(|d, _| { + if let Some(Value::String(b)) = &based + && d.styles.char_based_on_cycles(&name, b) + { + return Err(bad("style.character.edit", "based-on would create a cycle")); + } let st = d.styles_mut().char_style_mut(&name).ok_or_else(|| bad("style.character.edit", "no such style"))?; st.chars.merge(&chars); + match based { + Some(Value::String(b)) => st.based_on = Some(b), + Some(Value::Null) => st.based_on = None, + _ => {} + } + if let Some(n) = rename.clone() { + // Every use: other styles, stories, nested/GREP styles, export tags. + rename_style(d, false, &name, &n); + } + ok() + }) +} + +fn delete_char(s: &mut Session, p: &Value) -> Result { + let name = str_param(p, "name").unwrap_or("").to_string(); + let repl = str_param(p, "replaceWith").unwrap_or(designcraft_doc::NO_CHAR_STYLE).to_string(); + if name.starts_with('[') { + return Err(bad("style.character.delete", "built-in styles can't be deleted")); + } + s.edit(|d, _| { + d.styles_mut().character.retain(|x| x.name != name); + for sid in d.stories.keys().copied().collect::>() { + if let Some(story) = d.story_mut(sid) { + story.for_each_text_mut(&mut |st| { + for r in st.chars.iter_mut().filter(|r| r.format.style == name) { + r.format.style = repl.clone(); + } + }); + } + } ok() }) } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9eeb2ae03..692cb926d 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -591,3 +591,60 @@ fn paragraph_style_delete_rejects_builtin_styles() { let r = s.execute("style.paragraph.delete", &json!({"name": designcraft_doc::BASIC_PARAGRAPH})); assert!(r.is_err(), "built-in styles can't be deleted"); } + +#[test] +fn character_style_edit_persists_attrs_and_rename() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term", "chars": {"fontStyle": "Bold"}})).unwrap()["name"] + .as_str() + .unwrap() + .to_string(); + s.execute("style.character.edit", &json!({"name": name, "chars": {"tracking": 20.0}})).unwrap(); + { + let st = s.doc().unwrap(); + let style = st.doc.styles.char_style(&name).expect("style still exists"); + assert_eq!(style.chars.tracking, Some(20.0), "edited attribute persisted"); + } + // Renaming updates the style definition itself, and every place it's referenced. + s.execute("style.character.edit", &json!({"name": name, "rename": "Emphasis"})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.char_style("Emphasis").is_some(), "renamed style exists under the new name"); + assert!(st.doc.styles.char_style(&name).is_none(), "old name is gone"); +} + +#[test] +fn character_style_edit_rejects_based_on_cycle() { + let mut s = session(); + let a = s.execute("style.character.create", &json!({"name": "A"})).unwrap()["name"].as_str().unwrap().to_string(); + let b = s.execute("style.character.create", &json!({"name": "B", "basedOn": a})).unwrap()["name"].as_str().unwrap().to_string(); + let r = s.execute("style.character.edit", &json!({"name": a, "basedOn": b})); + assert!(r.is_err(), "A based on B based on A must be rejected"); +} + +#[test] +fn character_style_delete_reassigns_affected_text() { + let mut s = session(); + let name = s.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + let r = s.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap(); + let sid = r["story"].as_u64().unwrap(); + s.execute("text.insert", &json!({"text": "Hello, world"})).unwrap(); + s.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + s.execute("style.character.apply", &json!({"name": name})).unwrap(); + { + let st = s.doc().unwrap(); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.chars[0].format.style, name, "style applied before delete"); + } + s.execute("style.character.delete", &json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE})).unwrap(); + let st = s.doc().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none(), "deleted style is gone"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).expect("story exists"); + assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "affected run reassigned to the replacement style"); +} + +#[test] +fn character_style_delete_rejects_builtin_styles() { + let mut s = session(); + let r = s.execute("style.character.delete", &json!({"name": designcraft_doc::NO_CHAR_STYLE})); + assert!(r.is_err(), "built-in styles can't be deleted"); +} diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index b94c64445..646e80dc7 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -53,6 +53,9 @@ impl Dialog { if id == "paragraphStyleOptions" { fields.entry("section".to_string()).or_insert(json!("general")); } + if id == "characterStyleOptions" { + fields.entry("section".to_string()).or_insert(json!("general")); + } if id == "frameSize" { for k in ["width", "height"] { if let Some(v) = fields.get(k).and_then(Value::as_f64) { @@ -814,6 +817,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "findChange" => crate::i18n::tr(&app.ui.language, "Find/Change"), "paragraphStyleOptions" => crate::i18n::tr(&app.ui.language, "Paragraph Style Options"), "deleteParagraphStyle" => crate::i18n::tr(&app.ui.language, "Delete Paragraph Style"), + "characterStyleOptions" => crate::i18n::tr(&app.ui.language, "Character Style Options"), + "deleteCharacterStyle" => crate::i18n::tr(&app.ui.language, "Delete Character Style"), "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"), @@ -1055,6 +1060,8 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { } "paragraphStyleOptions" => paragraph_style_options(app, ui, &mut d), "deleteParagraphStyle" => delete_paragraph_style(app, ui, &mut d), + "characterStyleOptions" => character_style_options(app, ui, &mut d), + "deleteCharacterStyle" => delete_character_style(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,6 +1499,33 @@ pub fn confirm(app: &mut DesignApp) -> Result { json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::BASIC_PARAGRAPH) } else { json!(replace) }}), ) } + "characterStyleOptions" => { + let name = d.s("name"); + let mut chars = serde_json::Map::new(); + for (k, v) in &d.fields { + if let Some(a) = k.strip_prefix("c.") { + chars.insert(a.into(), v.clone()); + } + } + let mut params = json!({"name": name, "chars": chars}); + let based = d.s("basedOn"); + if !based.is_empty() { + params["basedOn"] = if based == designcraft_doc::NO_CHAR_STYLE { Value::Null } else { json!(based) }; + } + let rename = d.s("rename"); + if !rename.is_empty() && rename != name { + params["rename"] = json!(rename); + } + app.run("style.character.edit", params) + } + "deleteCharacterStyle" => { + let name = d.s("name"); + let replace = d.s("replaceWith"); + app.run( + "style.character.delete", + json!({"name": name, "replaceWith": if replace.is_empty() { json!(designcraft_doc::NO_CHAR_STYLE) } else { json!(replace) }}), + ) + } "colorPicker" => { let hex = d.s("hex"); app.run("object.color", json!({"color": hex, "target": d.s("target")})) @@ -2225,6 +2259,166 @@ fn delete_paragraph_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog }); } +fn character_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + let Some(st) = app.session.active() else { return }; + let Some(style) = st.doc.styles.char_style(&name).cloned() else { + ui.label(format!("No style named {name}")); + return; + }; + let names: Vec = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect(); + let cp = st.doc.styles.resolve_char_style(&name); + let cv = serde_json::to_value(&cp).unwrap_or_default(); + let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); + ui.set_min_width(480.0); + ui.horizontal_top(|ui| { + ui.set_min_height(280.0); + ui.set_max_height(280.0); + ui.vertical(|ui| { + ui.set_width(150.0); + for (id, label) in [("general", "General"), ("chars", "Basic Character Formats"), ("color", "Character Color")] { + 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(); + ui.vertical(|ui| match d.s("section").as_str() { + "chars" => { + let fonts = crate::panels::fonts(app); + let menu = crate::panels::font_menu(app); + egui::Grid::new("csc").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(); + egui::ComboBox::from_id_salt("csfam").selected_text(crate::panels::font_label(app, &menu, &fam)).width(200.0).show_ui(ui, |ui| { + if let Some(f) = crate::panels::font_menu_rows(app, ui, &menu, &fam) { + 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("cssty").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!("cs{key}"), v, suffix, 80.0, 2) { + d.fields.insert(format!("c.{key}"), json!(n)); + } + ui.end_row(); + } + }); + } + "color" => { + let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string(); + let swatches: Vec = st.doc.swatches.iter().map(|s| s.name.clone()).collect(); + egui::ScrollArea::vertical().max_height(220.0).show(ui, |ui| { + for sw in swatches { + let (c, g) = crate::widgets::swatch_colors(&st.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)); + } + }); + } + }); + } + _ => { + egui::Grid::new("csg").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Style Name:")); + if !d.fields.contains_key("rename") { + d.fields.insert("rename".into(), json!(name)); + } + let mut rn = d.s("rename"); + if ui.add(egui::TextEdit::singleline(&mut rn).desired_width(220.0)).changed() { + d.fields.insert("rename".into(), json!(rn)); + } + ui.end_row(); + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Based On:")); + 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()); + egui::ComboBox::from_id_salt("csbased") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &based))) + .width(220.0) + .show_ui(ui, |ui| { + for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) { + if ui.selectable_label(n == based, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + d.fields.insert("basedOn".into(), json!(n)); + } + } + }); + ui.end_row(); + }); + ui.add_space(8.0); + crate::rtl::label( + ui, + egui::RichText::new(format!("{} {} {:.1} pt", cp.font_family, cp.font_style, cp.size)) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } + }); + }); +} + +fn delete_character_style(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { + let name = d.s("name"); + ui.set_min_width(380.0); + crate::rtl::label( + ui, + egui::RichText::new( + crate::i18n::tr(&app.ui.language, "Delete the character style \"{name}\"?") + .replace("{name}", crate::i18n::style_name(&app.ui.language, &name)), + ) + .size(12.5), + ); + ui.add_space(8.0); + let Some(st) = app.session.active() else { return }; + let in_use = st.doc.stories.values().any(|s| s.chars.iter().any(|r| r.format.style == name)); + let names: Vec = st.doc.styles.character.iter().map(|c| c.name.clone()).filter(|n| *n != name).collect(); + if in_use { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "This style is in use. Text using it will be reassigned to:")) + .size(11.0) + .color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } else { + crate::rtl::label( + ui, + egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace with:")).size(11.0).color(crate::theme::Tokens::get(ui.ctx()).text_dim), + ); + } + ui.add_space(4.0); + if !d.fields.contains_key("replaceWith") { + d.fields.insert("replaceWith".into(), json!(designcraft_doc::NO_CHAR_STYLE)); + } + let replace = d.s("replaceWith"); + egui::ComboBox::from_id_salt("del_char_replace") + .selected_text(crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, &replace))) + .width(260.0) + .show_ui(ui, |ui| { + for n in [designcraft_doc::NO_CHAR_STYLE].into_iter().chain(names.iter().map(String::as_str)) { + if ui.selectable_label(n == replace, crate::rtl::widget(ui, crate::i18n::style_name(&app.ui.language, n))).clicked() { + d.fields.insert("replaceWith".into(), json!(n)); + } + } + }); +} + 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(); @@ -2779,4 +2973,41 @@ mod tests { let st = app.session.active().unwrap(); assert!(st.doc.styles.para(&name).is_none()); } + + #[test] + fn character_style_options_dialog_saves_edits() { + let mut app = test_app(); + let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + app.ui.dialog = Some(Dialog::new("characterStyleOptions", json!({"name": name, "c.tracking": 20.0}))); + confirm(&mut app).expect("OK saves the style edit"); + let st = app.session.active().unwrap(); + assert_eq!(st.doc.styles.char_style(&name).unwrap().chars.tracking, Some(20.0)); + } + + #[test] + fn delete_character_style_dialog_removes_the_style_and_reassigns_text() { + let mut app = test_app(); + let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + let sid = app.session.execute("frame.create", &json!({"rect": [36, 36, 300, 200], "content": "text"})).unwrap()["story"].as_u64().unwrap(); + app.session.execute("text.insert", &json!({"text": "Hello"})).unwrap(); + app.session.execute("text.select", &json!({"story": sid, "anchor": 0, "focus": 5})).unwrap(); + app.session.execute("style.character.apply", &json!({"name": name})).unwrap(); + app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name, "replaceWith": designcraft_doc::NO_CHAR_STYLE}))); + confirm(&mut app).expect("OK deletes the style"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none(), "style removed"); + let story = st.doc.stories.get(&designcraft_doc::StoryId(sid)).unwrap(); + assert_eq!(story.chars[0].format.style, designcraft_doc::NO_CHAR_STYLE, "text reassigned to the replacement style"); + } + + #[test] + fn delete_character_style_dialog_defaults_replacement_to_none() { + let mut app = test_app(); + let name = app.session.execute("style.character.create", &json!({"name": "Key Term"})).unwrap()["name"].as_str().unwrap().to_string(); + // No `replaceWith` set — the dialog's default field. + app.ui.dialog = Some(Dialog::new("deleteCharacterStyle", json!({"name": name}))); + confirm(&mut app).expect("OK deletes the style with the default replacement"); + let st = app.session.active().unwrap(); + assert!(st.doc.styles.char_style(&name).is_none()); + } } diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 2f41f7422..c44ff4886 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -98,6 +98,8 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ); if resp.double_clicked() && para { app.ui.dialog = Some(crate::dialogs::Dialog::new("paragraphStyleOptions", json!({"name": n}))); + } else if resp.double_clicked() && !n.starts_with('[') { + app.ui.dialog = Some(crate::dialogs::Dialog::new("characterStyleOptions", json!({"name": n}))); } else if resp.clicked() { let cmd = if para { "style.paragraph.apply" } else { "style.character.apply" }; let clear = ui.input(|i| i.modifiers.alt); @@ -163,5 +165,16 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.close(); } } + if !para && !n.starts_with('[') { + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Edit Character Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("characterStyleOptions", json!({"name": n}))); + ui.close(); + } + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Character Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteCharacterStyle", json!({"name": n}))); + ui.close(); + } + } }); }