diff --git a/crates/ui-egui/src/menus/state.rs b/crates/ui-egui/src/menus/state.rs index a4998e993..3ca86c0a3 100644 --- a/crates/ui-egui/src/menus/state.rs +++ b/crates/ui-egui/src/menus/state.rs @@ -40,8 +40,7 @@ pub fn checked(app: &VectorcraftApp, id: &str, p: &Value) -> Option { "type.orientation.vertical" | "type.orientation.horizontal" => { selected_text(false).is_some_and(|t| t.vertical == (id == "type.orientation.vertical")) } - // Checked when the first type object `selected_text` finds has it on. - "type.opticalMarginAlignment" => selected_text(false).is_some_and(|t| t.optical_margin), + "type.opticalMarginAlignment" => crate::panels::character::shared_text_flag(app, |t| t.optical_margin) == Some(true), "view.outline" => v.outline, "view.pixelPreview" => v.pixel_preview, "view.trimView" => v.trim_view, diff --git a/crates/ui-egui/src/menus/tests.rs b/crates/ui-egui/src/menus/tests.rs index f4baafa1d..71bdca4a2 100644 --- a/crates/ui-egui/src/menus/tests.rs +++ b/crates/ui-egui/src/menus/tests.rs @@ -239,6 +239,22 @@ fn optical_margin_alignment_is_a_checked_type_menu_item() { assert_eq!(checked(&app, "type.orientation.horizontal", &Value::Null), Some(false)); } +/// Type › Optical Margin Alignment menu check uses `shared_text_flag`; mixed values stay unchecked. +#[test] +fn optical_margin_alignment_menu_unchecked_when_shared_flag_none() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let off = app.run("text.create", json!({"x": 10, "y": 40, "text": "Off"})).unwrap()["id"].clone(); + let on = app.run("text.create", json!({"x": 10, "y": 80, "text": "On"})).unwrap()["id"].clone(); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": on})).unwrap(); + app.run("select.set", json!({"ids": [off, on]})).unwrap(); + let id = "type.opticalMarginAlignment"; + assert_eq!(crate::panels::character::shared_text_flag(&app, |t| t.optical_margin), None); + assert_eq!(checked(&app, id, &Value::Null), Some(false)); + app.run("select.set", json!({"ids": [on]})).unwrap(); + assert_eq!(checked(&app, id, &Value::Null), Some(true)); +} + #[test] fn panel_ids_are_case_insensitive_and_labels_work() { assert_eq!(normalize_panel("layers"), Some("layers")); diff --git a/crates/ui-egui/src/panels/character.rs b/crates/ui-egui/src/panels/character.rs index 3eddf8258..39fd16b9e 100644 --- a/crates/ui-egui/src/panels/character.rs +++ b/crates/ui-egui/src/panels/character.rs @@ -54,6 +54,38 @@ pub(crate) fn first_selected_text(app: &VectorcraftApp) -> Option<&vectorcraft_d None } +/// A boolean on every selected text object (`Some(true)` / `Some(false)`), or `None` when they +/// differ. When the Type tool edits text, only that object counts. +pub(crate) fn shared_text_flag(app: &VectorcraftApp, f: impl Fn(&vectorcraft_doc::TextObject) -> bool) -> Option { + if let Some((id, _, _)) = text_editing(app) { + return app.session.active().and_then(|st| st.doc.node(id)).and_then(|n| match &n.kind { + NodeKind::Text(t) => Some(f(t)), + _ => None, + }); + } + let st = app.session.active()?; + let mut value: Option = None; + let mut mixed = false; + let mut saw = false; + for node in st.selection.objects.iter().filter_map(|id| st.doc.node(*id)) { + node.walk(&mut |n| { + if let NodeKind::Text(t) = &n.kind { + saw = true; + if mixed { + return; + } + let v = f(t); + match value { + None => value = Some(v), + Some(a) if a == v => {} + Some(_) => mixed = true, + } + } + }); + } + if !saw || mixed { None } else { value } +} + /// `f` of the character styles the panels act on: the runs the Type tool's selection covers (the /// caret's style at a caret), else every run of the selected type, groups' included. `None` (a /// blank field) where they differ, or with no type. diff --git a/crates/ui-egui/src/panels/character/tests.rs b/crates/ui-egui/src/panels/character/tests.rs index b823b87d1..53f55cef4 100644 --- a/crates/ui-egui/src/panels/character/tests.rs +++ b/crates/ui-egui/src/panels/character/tests.rs @@ -488,6 +488,30 @@ fn character_direction_checks_the_direction_of_the_selection() { assert_eq!(checked(&mut app), ["Default"]); } +#[test] +fn shared_text_flag_optical_margin_three_object_selections() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({})).unwrap(); + let a = app.run("text.create", json!({"x": 10, "y": 40, "text": "a"})).unwrap()["id"].clone(); + let b = app.run("text.create", json!({"x": 10, "y": 80, "text": "b"})).unwrap()["id"].clone(); + let c = app.run("text.create", json!({"x": 10, "y": 120, "text": "c"})).unwrap()["id"].clone(); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": a})).unwrap(); + app.run("type.opticalMarginAlignment", json!({"on": false, "id": b})).unwrap(); + app.run("type.opticalMarginAlignment", json!({"on": false, "id": c})).unwrap(); + app.run("select.set", json!({"ids": [a.clone(), b.clone(), c.clone()]})).unwrap(); + assert_eq!(shared_text_flag(&app, |t| t.optical_margin), None); + app.run("type.opticalMarginAlignment", json!({"on": false, "id": a})).unwrap(); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": b})).unwrap(); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": c})).unwrap(); + app.run("select.set", json!({"ids": [a.clone(), b.clone(), c.clone()]})).unwrap(); + assert_eq!(shared_text_flag(&app, |t| t.optical_margin), None); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": a})).unwrap(); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": b})).unwrap(); + app.run("type.opticalMarginAlignment", json!({"on": true, "id": c})).unwrap(); + app.run("select.set", json!({"ids": [a, b, c]})).unwrap(); + assert_eq!(shared_text_flag(&app, |t| t.optical_margin), Some(true)); +} + /// Small Caps, Superscript and Subscript buttons and menu items stay off when the selection mixes them. #[test] fn character_mixed_small_caps_and_position_show_off() {