From f45b02fcc42ff9e92d05aa7feea30c50e5b27a5f Mon Sep 17 00:00:00 2001 From: paaspaas00 <151774837+paaspaas00@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:51:14 +0200 Subject: [PATCH] Object Styles panel: a story's paragraph style counts as an override; a new style with no effect on sets none Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/object_style_dialog.rs | 22 +++++++++++++++-- crates/ui-egui/src/panels/styles.rs | 29 +++++++++++++++++++++-- 2 files changed, 47 insertions(+), 4 deletions(-) diff --git a/crates/ui-egui/src/object_style_dialog.rs b/crates/ui-egui/src/object_style_dialog.rs index 038e9b7b4..2a9a23ad4 100644 --- a/crates/ui-egui/src/object_style_dialog.rs +++ b/crates/ui-egui/src/object_style_dialog.rs @@ -71,10 +71,11 @@ fn included(d: &Dialog, key: &str) -> bool { d.fields.get("inc").and_then(|i| i[key].as_bool()).unwrap_or(false) } -/// Whether the style sets effects: it did, or one is on now. +/// Whether the style sets effects: one is on now, or (editing) the style set them already. A new +/// style with every effect off sets none, so applying it leaves the objects' own effects. fn sets_effects(d: &Dialog) -> bool { let fx = d.fields.get("fx").cloned().unwrap_or(Value::Null); - d.b("hadEffects") || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k)) + (!d.b("new") && d.b("hadEffects")) || crate::effects_dialog::PAGES.iter().skip(1).any(|(k, _)| crate::effects_dialog::is_on(&fx, k)) } /// The style's attributes as the commands take them (null: not in the style). @@ -388,6 +389,23 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + /// A new style made from an object with no effect on sets no effects, so applying it keeps + /// other objects' own. + #[test] + fn a_new_style_without_effects_on_sets_none() { + let (mut app, _) = app_with_frame(); + open_new(&mut app); + let d = app.ui.dialog.clone().unwrap(); + assert!(d.b("hadEffects")); + assert_eq!(attrs(&d)["effects"], Value::Null); + if let Some(d) = app.ui.dialog.as_mut() { + d.fields.insert("name".into(), json!("Plain")); + d.fields.insert("apply".into(), json!(false)); + } + crate::dialogs::confirm(&mut app).unwrap(); + assert!(app.session.doc().unwrap().doc.styles.object_style("Plain").unwrap().effects.is_none()); + } + fn app_with_frame() -> (DesignApp, u64) { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); app.run("file.new", json!({})).unwrap(); diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index 0f2295340..f1c057c84 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -105,15 +105,20 @@ pub fn character(app: &mut DesignApp, ui: &mut egui::Ui) { list(app, ui, false); } -/// The selected object's style, and whether the object differs from it (an override: `+`). +/// The selected object's style, and whether the object differs from it (an override: `+`): its +/// own attributes, or a text frame's paragraphs in another style than the object style's. fn object_style_of_selection(app: &DesignApp) -> Option<(String, bool)> { let st = app.session.active()?; let it = st.selection.items.first().and_then(|i| st.doc.item(*i))?; let mut styled = it.clone(); + let mut over = false; if let Some(os) = st.doc.styles.object_style(&it.object_style) { os.apply_to(&mut styled); + if let (Some(ps), Some(tf)) = (&os.paragraph_style, it.text_frame()) { + over = st.doc.story(tf.story).is_some_and(|s| s.paras.iter().any(|f| f.style != *ps)); + } } - Some((it.object_style.clone(), styled != *it)) + Some((it.object_style.clone(), over || styled != *it)) } /// Window › Styles › Object Styles, as InDesign's: the selected object's style on top (`+` when @@ -307,6 +312,26 @@ mod tests { use super::*; + /// A text frame whose paragraphs left its object style's paragraph style shows the override + /// mark, and Clear Overrides puts them back. + #[test] + fn a_paragraph_style_change_is_an_object_style_override() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), Default::default()); + app.run("file.new", serde_json::json!({})).unwrap(); + app.run("style.paragraph.create", serde_json::json!({"name": "Caption"})).unwrap(); + app.run("style.object.create", serde_json::json!({"name": "Captioned", "fromSelection": false, "paragraphStyle": "Caption"})).unwrap(); + let f = app.run("frame.create", serde_json::json!({"rect": [36, 36, 300, 200], "content": "text", "text": "One"})).unwrap(); + app.run("style.object.apply", serde_json::json!({"name": "Captioned", "ids": [f["id"]]})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap(); + assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), false))); + app.run("text.select", serde_json::json!({"story": f["story"], "anchor": 0, "focus": 3})).unwrap(); + app.run("style.paragraph.apply", serde_json::json!({"name": designcraft_doc::BASIC_PARAGRAPH})).unwrap(); + app.run("selection.set", serde_json::json!({"ids": [f["id"]]})).unwrap(); + assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), true)), "the + mark"); + app.run("style.object.clearOverrides", serde_json::json!({})).unwrap(); + assert_eq!(object_style_of_selection(&app), Some(("Captioned".into(), false))); + } + #[test] fn selected_style_context_menu_redefines_its_format() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());