diff --git a/crates/engine/src/cmd/style.rs b/crates/engine/src/cmd/style.rs index 6fff9744f..1652898ad 100644 --- a/crates/engine/src/cmd/style.rs +++ b/crates/engine/src/cmd/style.rs @@ -118,6 +118,7 @@ pub fn specs() -> Vec { 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!({ @@ -685,6 +686,27 @@ fn edit_char(s: &mut Session, p: &Value) -> Result { }) } +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() + }) +} + fn create_swatch(s: &mut Session, p: &Value) -> Result { let c = p.get("color").ok_or_else(|| bad("swatch.create", "missing color"))?; let color = parse_color(c).ok_or_else(|| bad("swatch.create", "bad color"))?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 31ad7bb2e..b3142316c 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -703,3 +703,114 @@ fn preflight_mojikumi_uses_the_same_supported_rules_as_composition() { assert_eq!(!warnings.is_empty(), unsupported, "{name}: {warnings:?}"); } } + +#[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"); +} + +#[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 dee4eed41..84cc9a0d9 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -68,6 +68,9 @@ impl Dialog { if id == "paragraphStyleOptions" || id == "characterStyleOptions" { 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) { @@ -1129,7 +1132,9 @@ 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"), "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"), "paragraphRules" => crate::i18n::tr(&app.ui.language, "Paragraph Rules"), "insertXref" => crate::i18n::tr(&app.ui.language, "New Cross-Reference"), @@ -1387,7 +1392,9 @@ 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), "paragraphRules" => { let current = d.fields.get("current").cloned().unwrap_or_default(); @@ -1859,6 +1866,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) }}), + ) + } "paragraphRules" => { let attrs = rule_edits(&d, ""); if attrs.is_empty() { @@ -1903,6 +1918,14 @@ pub fn confirm(app: &mut DesignApp) -> Result { } r } + "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"), "text": d.b("text")})) @@ -2707,7 +2730,51 @@ 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 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 section_list(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, sections: &[(&str, &str)]) { ui.vertical(|ui| { ui.set_width(170.0); @@ -3190,6 +3257,51 @@ fn line_options(lang: &str, ui: &mut egui::Ui, d: &mut Dialog, cf: &CharFields, }); } +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(); @@ -4320,6 +4432,86 @@ mod tests { } } + 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()); + } + + #[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()); + } + #[test] fn pdf_export_defaults() { let d = Dialog::new("pdfExport", json!({})); diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 25a596644..70e999c30 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -148,5 +148,19 @@ 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, "Delete Paragraph Style…"))).clicked() { + app.ui.dialog = Some(crate::dialogs::Dialog::new("deleteParagraphStyle", json!({"name": n}))); + ui.close(); + } + } + if !para && !n.starts_with('[') { + ui.separator(); + 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(); + } + } }); }