From d5c1755329f00cb9b31f361de7aae8d64b561c62 Mon Sep 17 00:00:00 2001 From: skylartaylor <26588250+skylartaylor@users.noreply.github.com> Date: Fri, 9 Oct 2026 20:55:54 -0600 Subject: [PATCH] UI: share viewport-aware scrolling across menus Incorporate @aiongg's shared menu-button, anchor-aware sizing, and reopen-reset work from #279 (717ddb6), retaining #281's clipped-submenu and scrollbar interaction fixes. Use the wrapper for workspace, zoom, and panel menus as well as the menu bar. Preserve checkbox dismissal and cover anchor placement, scroll reset, and very short viewports with regression tests. --- crates/ui-egui/src/chrome.rs | 84 ++++---- crates/ui-egui/src/menus.rs | 267 ++++++++++++++++++++---- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 5 +- crates/ui-egui/src/panels/styles.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 8 +- 6 files changed, 280 insertions(+), 88 deletions(-) diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index dfb5e699b..1aeb879f9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -51,46 +51,56 @@ pub fn app_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_space(8.0); let current = app.ui.workspace.clone(); let shown = crate::i18n::workspace_name(&app.ui.language, ¤t); - ui.menu_button(crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), |ui| { - let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); - for w in - ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] - .into_iter() - .map(str::to_string) - .chain(customs.iter().cloned()) - { - if ui.selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))).clicked() + crate::menus::menu_button( + ui, + crate::rtl::widget(ui, egui::RichText::new(format!("{shown} ▾")).font(semibold(11.5)).color(t.text)), + |ui| { + let customs: Vec = app.ui.custom_workspaces.iter().map(|w| w.name.clone()).collect(); + for w in + ["Essentials", "Advanced", "Book", "Digital Publishing", "Interactive for PDF", "Printing and Proofing", "Typography"] + .into_iter() + .map(str::to_string) + .chain(customs.iter().cloned()) { - let _ = app.run("window.workspace", json!({"name": w})); + if ui + .selectable_label(w == current, crate::rtl::widget(ui, crate::i18n::workspace_name(&app.ui.language, &w))) + .clicked() + { + let _ = app.run("window.workspace", json!({"name": w})); + ui.close(); + } + } + ui.separator(); + if ui + .button(crate::rtl::widget( + ui, + format!( + "{} {}", + crate::i18n::tr(&app.ui.language, "Reset"), + crate::i18n::workspace_name(&app.ui.language, ¤t) + ), + )) + .clicked() + { + let _ = app.run("window.resetWorkspace", json!({})); ui.close(); } - } - ui.separator(); - if ui - .button(crate::rtl::widget( - ui, - format!("{} {}", crate::i18n::tr(&app.ui.language, "Reset"), crate::i18n::workspace_name(&app.ui.language, ¤t)), - )) - .clicked() - { - let _ = app.run("window.resetWorkspace", json!({})); - ui.close(); - } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { - let _ = app.run("window.newWorkspace", json!({})); - ui.close(); - } - if !customs.is_empty() { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { - for w in &customs { - if ui.button(w).clicked() { - let _ = app.run("window.deleteWorkspace", json!({"name": w})); - ui.close(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "New Workspace…"))).clicked() { + let _ = app.run("window.newWorkspace", json!({})); + ui.close(); + } + if !customs.is_empty() { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Delete Workspace")), |ui| { + for w in &customs { + if ui.button(w).clicked() { + let _ = app.run("window.deleteWorkspace", json!({"name": w})); + ui.close(); + } } - } - }); - } - }); + }); + } + }, + ); ui.add_space(6.0); if icons::button(ui, "share", 22.0, false, crate::i18n::tr(&app.ui.language, "Share")).clicked() { app.status("Export a PDF, IDML or package to share — no cloud account needed."); @@ -511,7 +521,7 @@ pub fn status_bar(app: &mut DesignApp, ui: &mut egui::Ui) { return; } let z = app.view().map(|v| v.zoom).unwrap_or(1.0); - ui.menu_button(egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| { + crate::menus::menu_button(ui, egui::RichText::new(format!("{:.2}% ▾", z * 100.0)).font(small.clone()), |ui| { for p in [0.05, 0.125, 0.25, 0.5, 0.75, 1.0, 2.0, 4.0, 8.0, 16.0] { if ui.button(format!("{}%", p * 100.0)).clicked() { let _ = app.run("view.zoom", json!({"zoom": p})); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9f200fbd6..f95fcb83e 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1743,45 +1743,59 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { let _ = app.run(id, p); } -/// The menu bar contents (inside the app bar; macOS uses the native menu instead). -pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { - let lang = app.ui.language.clone(); - let mut menus = menu_tree(); - if crate::i18n::is_rtl(&lang) { - menus.reverse(); - } - for (menu, entries) in menus { - egui::containers::menu::MenuButton::new(crate::rtl::widget(ui, crate::i18n::tr(&lang, menu))) - .config(egui::containers::menu::MenuConfig::new().close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)) - .ui(ui, |ui| { - if crate::i18n::is_rtl(&lang) { - ui.set_max_width(320.0); - } - scroll_menu(ui, |ui| { - ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { - ui.set_min_width(240.0); - let hidden = menu_items(app, ui, &entries, menu); - if hidden > 0 && !app.ui.show_full_menus { - ui.separator(); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() { - app.ui.show_full_menus = true; - } - } - }); - }); - }); - } +/// A menu button whose popup stays within the viewport and scrolls when needed. +/// All menu buttons share this wrapper, including workspace, zoom and panel menus. +pub fn menu_button<'a, R>( + ui: &mut egui::Ui, + atoms: impl egui::IntoAtoms<'a>, + add_contents: impl FnOnce(&mut egui::Ui) -> R, +) -> egui::InnerResponse> { + use egui::containers::menu::{MenuButton, MenuConfig}; + + // A menu opens from a button already shown in the previous frame. Base its height on + // that anchor, not the popup's eventual position, so placement cannot oscillate. + let button = ui.ctx().read_response(ui.next_auto_id()).map(|response| response.rect); + let max_height = menu_max_height(ui, button); + let contents = |ui: &mut egui::Ui| { + // Override the popup's initial available height, including when it opens upwards. + ui.set_max_height(max_height); + let mut scroll = egui::ScrollArea::vertical().max_height(max_height).min_scrolled_height(1.0); + if ui.is_sizing_pass() { + // Closing and reopening a menu starts at its first command again. + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, add_contents).inner + }; + // Scrollbar clicks must not dismiss the popup; command rows close it explicitly. + let config = MenuConfig::find(ui).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside); + let (response, inner) = if egui::containers::menu::is_in_menu(ui) { + clipped_submenu(ui, atoms, config, contents) + } else { + MenuButton::new(atoms).config(config).ui(ui, contents) + }; + egui::InnerResponse { response, inner: inner.map(|response| response.inner) } } -/// Keep every command reachable when the viewport is short or the UI is enlarged. -fn scroll_menu(ui: &mut egui::Ui, contents: impl FnOnce(&mut egui::Ui) -> R) -> R { - let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum().y; - let max_height = (ui.ctx().content_rect().height() - frame_margin).max(1.0); - ui.set_max_height(max_height); - egui::ScrollArea::vertical().max_height(max_height).show(ui, contents).inner +/// The tallest menu that fits on either side of its anchor, including the popup frame. +fn menu_max_height(ui: &egui::Ui, button: Option) -> f32 { + let screen = ui.ctx().content_rect(); + let space = match button { + // Submenus open beside their row, aligned with its top or bottom. + Some(rect) if egui::containers::menu::is_in_menu(ui) => (screen.bottom() - rect.top()).max(rect.bottom() - screen.top()), + Some(rect) => (screen.bottom() - rect.bottom()).max(rect.top() - screen.top()), + None => screen.height(), + }; + let chrome = egui::Frame::menu(ui.style()).total_margin().sum().y + 8.0; + (space.min(screen.height()) - chrome).max(1.0) } -fn clipped_submenu(ui: &mut egui::Ui, label: egui::WidgetText, contents: impl FnOnce(&mut egui::Ui) -> R) -> Option { +/// egui's submenu hover test uses the full row rect, even outside a scroll area's clip. +fn clipped_submenu<'a, R>( + ui: &mut egui::Ui, + atoms: impl egui::IntoAtoms<'a>, + config: egui::containers::menu::MenuConfig, + contents: impl FnOnce(&mut egui::Ui) -> R, +) -> (egui::Response, Option>) { use egui::containers::menu::{MenuState, SubMenu, SubMenuButton}; let id = SubMenu::id_from_widget_id(ui.next_auto_id()); @@ -1790,21 +1804,49 @@ fn clipped_submenu(ui: &mut egui::Ui, label: egui::WidgetText, contents: impl if open { ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open; } - let button = SubMenuButton::new(label); - let mut response = ui.add(button.button); + let button = SubMenuButton::new(atoms).config(config); + let response = ui.add(button.button); ui.style_mut().visuals.widgets.inactive = inactive; let clip = ui.clip_rect(); if !response.rect.intersect(clip).is_positive() { if open { MenuState::from_ui(ui, |state, _| state.open_item = None); } - return None; + return (response, None); } - // egui expands an unclipped hover rectangle by half the item spacing. + // Undo the half-spacing expansion used by SubMenu::show after clamping to the clip. let hover_margin = ui.spacing().item_spacing / 2.0; - response.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin); - response.interact_rect = response.interact_rect.intersect(clip); - button.sub_menu.show(ui, &response, contents).map(|response| response.inner) + let mut clipped = response.clone(); + clipped.rect = response.rect.expand2(hover_margin).intersect(clip).shrink2(hover_margin); + clipped.interact_rect = response.interact_rect.intersect(clip); + let inner = button.sub_menu.show(ui, &clipped, contents); + (response, inner) +} + +/// The menu bar contents (inside the app bar; macOS uses the native menu instead). +pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { + let lang = app.ui.language.clone(); + let mut menus = menu_tree(); + if crate::i18n::is_rtl(&lang) { + menus.reverse(); + } + for (menu, entries) in menus { + menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| { + if crate::i18n::is_rtl(&lang) { + ui.set_max_width(320.0); + } + ui.with_layout(egui::Layout::top_down(if crate::i18n::is_rtl(&lang) { egui::Align::Max } else { egui::Align::Min }), |ui| { + ui.set_min_width(240.0); + let hidden = menu_items(app, ui, &entries, menu); + if hidden > 0 && !app.ui.show_full_menus { + ui.separator(); + if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show All Menu Items"))).clicked() { + app.ui.show_full_menus = true; + } + } + }); + }); + } } /// The key a menu item is hidden by (Edit › Menus). @@ -1823,8 +1865,9 @@ fn menu_items(app: &mut DesignApp, ui: &mut egui::Ui, items: &[Item], path: &str Item::Sub(name, children) => { let sub = format!("{path}/{name}"); let shown = crate::i18n::tr(&app.ui.language, name).to_owned(); - hidden += - clipped_submenu(ui, crate::rtl::widget(ui, shown), |ui| scroll_menu(ui, |ui| menu_items(app, ui, children, &sub))).unwrap_or(0); + menu_button(ui, crate::rtl::widget(ui, shown), |ui| { + hidden += menu_items(app, ui, children, &sub); + }); } Item::Cmd { label, .. } if !app.ui.show_full_menus && app.ui.hidden_menu_items.contains(&menu_key(path, label)) => hidden += 1, Item::Cmd { label, id, params, shortcut: _ } => { @@ -2630,6 +2673,142 @@ mod tests { assert!(items.len() > 100, "every menu's items are listed: {}", items.len()); } + #[test] + fn menu_button_fits_its_anchor_and_resets_scroll_on_reopen() { + // Workspace/panel menus may be in the middle; the status-bar zoom menu opens upwards. + for anchor_y in [0.0, 130.0, 260.0] { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0)); + let mut tick = 0; + let mut frame = |events: Vec| { + tick += 1; + let mut button = None; + let mut first = None; + let mut last = None; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + ui.add_space(anchor_y); + let response = menu_button(ui, "Long menu", |ui| { + for i in 0..60 { + let response = ui.button(format!("Item {i}")); + if i == 0 { + first = Some((response.rect, ui.clip_rect())); + } + if i == 59 { + last = Some((response.rect, ui.clip_rect())); + } + } + }); + button = Some(response.response); + }); + output.textures_delta.clear(); + (button.expect("menu button"), first, last) + }; + let click = + |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }; + frame(vec![]); + let (button, _, _) = frame(vec![]); + let anchor = button.rect; + for pressed in [true, false] { + frame(vec![egui::Event::PointerMoved(anchor.center()), click(anchor.center(), pressed)]); + } + for _ in 0..3 { + frame(vec![]); + } + let popup = ctx.memory(|memory| memory.area_rect(egui::Popup::default_response_id(&button))).expect("open menu"); + assert!(screen.contains_rect(popup), "menu at {anchor_y} fits in the viewport: {popup:?}"); + assert!( + popup.top() >= anchor.bottom() - 1.0 || popup.bottom() <= anchor.top() + 1.0, + "menu at {anchor_y} stays on one side of its button: {popup:?}, {anchor:?}" + ); + let (_, first, last) = frame(vec![]); + let (first, clip) = first.expect("first row"); + assert!(clip.contains_rect(first), "newly opened menu starts at the first row"); + assert!(!clip.intersects(last.expect("last row").0), "last row starts below the scroll area"); + + frame(vec![ + egui::Event::PointerMoved(popup.center()), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ]); + for _ in 0..60 { + frame(vec![]); + } + let (_, first, last) = frame(vec![]); + let (last, clip) = last.expect("scrolled last row"); + assert!(popup.contains_rect(last) && clip.contains_rect(last), "last row is reachable: {last:?}, {popup:?}, {clip:?}"); + assert!(!clip.intersects(first.expect("scrolled first row").0), "menu really scrolled away from its start"); + + let outside = egui::pos2(screen.right() - 10.0, screen.center().y); + for pressed in [true, false] { + frame(vec![egui::Event::PointerMoved(outside), click(outside, pressed)]); + } + assert!(!egui::Popup::is_any_open(&ctx), "outside click closes the menu"); + assert!(frame(vec![]).1.is_none(), "closed menu is no longer drawn"); + for pressed in [true, false] { + frame(vec![egui::Event::PointerMoved(anchor.center()), click(anchor.center(), pressed)]); + } + for _ in 0..3 { + frame(vec![]); + } + let (_, first, last) = frame(vec![]); + let (first, clip) = first.expect("reopened first row"); + assert!(popup.contains_rect(first) && clip.contains_rect(first), "reopening resets to the first row: {first:?}, {clip:?}"); + assert!(!clip.intersects(last.expect("reopened last row").0), "reopening discards the old scroll offset"); + } + } + + #[test] + fn tiny_menu_viewport_does_not_force_the_default_scroll_height() { + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 100.0)); + let mut tick = 0; + let mut frame = |events: Vec| { + tick += 1; + let mut button = None; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + ui.add_space(35.0); + let response = menu_button(ui, "Long menu", |ui| { + for i in 0..20 { + let _ = ui.button(format!("Item {i}")); + } + }); + button = Some(response.response); + }); + output.textures_delta.clear(); + button.expect("menu button") + }; + frame(vec![]); + let button = frame(vec![]); + let pos = button.rect.center(); + for pressed in [true, false] { + frame(vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ]); + } + for _ in 0..3 { + frame(vec![]); + } + let popup = ctx.memory(|memory| memory.area_rect(egui::Popup::default_response_id(&button))).expect("open menu"); + let space = (screen.bottom() - button.rect.bottom()).max(button.rect.top() - screen.top()); + assert!(space < 64.0, "fixture has less space than egui's default minimum scroll height"); + // A complete command row may not fit, but the scroll area's minimum must not make + // the popup exceed its height budget or cover the button that dismisses it. + assert!(popup.height() <= space, "tiny menu respects its available height: {popup:?}, {space}"); + assert!(screen.contains_rect(popup), "tiny menu remains inside the viewport: {popup:?}"); + assert!( + popup.top() >= button.rect.bottom() - 1.0 || popup.bottom() <= button.rect.top() + 1.0, + "tiny menu stays beside its anchor: {popup:?}, {:?}", + button.rect + ); + } + #[test] fn clipped_submenu_does_not_open_when_hovering_outside_menu() { let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); diff --git a/crates/ui-egui/src/panels/pages.rs b/crates/ui-egui/src/panels/pages.rs index 6019e7883..6bbad9066 100644 --- a/crates/ui-egui/src/panels/pages.rs +++ b/crates/ui-egui/src/panels/pages.rs @@ -256,7 +256,7 @@ fn page_menu(app: &mut DesignApp, ui: &mut egui::Ui, doc: &designcraft_doc::Docu ui.close(); } } - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Apply Parent")), |ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[None]"))).clicked() { let _ = app.run("layout.pages.applyParent", json!({"pages": [abs], "parent": null})); ui.close(); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index 8644fe796..8b1eb4788 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -1330,7 +1330,7 @@ pub fn character_panel(app: &mut DesignApp, ui: &mut egui::Ui) { } let features: Vec = c["otfFeatures"].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_string)).collect()).unwrap_or_default(); - ui.menu_button("OpenType", |ui| open_type_menu(app, ui, &features)); + crate::menus::menu_button(ui, "OpenType", |ui| open_type_menu(app, ui, &features)); }); // Underline / Strikethrough Options. for (key, title) in [("underline", "Underline Options"), ("strikethrough", "Strikethrough Options")] { @@ -1385,13 +1385,14 @@ pub fn open_type_menu(app: &mut DesignApp, ui: &mut egui::Ui, features: &[String } } ui.separator(); - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Stylistic Sets")), |ui| { let mask = otf::stylistic_sets(features); for n in 1..=20u32 { let mut on = mask & (1 << (n - 1)) != 0; if ui.checkbox(&mut on, crate::rtl::widget(ui, format!("{} {n}", crate::i18n::tr(&app.ui.language, "Set")))).clicked() { let sets: Vec = (1..=20).filter(|k| if *k == n { on } else { mask & (1 << (k - 1)) != 0 }).collect(); let _ = app.run("type.openType", json!({"stylisticSets": sets})); + ui.close(); } } }); diff --git a/crates/ui-egui/src/panels/styles.rs b/crates/ui-egui/src/panels/styles.rs index c3412095e..77593d902 100644 --- a/crates/ui-egui/src/panels/styles.rs +++ b/crates/ui-egui/src/panels/styles.rs @@ -125,7 +125,7 @@ fn style_row(app: &mut DesignApp, ui: &mut egui::Ui, para: bool, n: &str, curren ui.close(); } if !n.starts_with('[') { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Group")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Group")), |ui| { if n.contains('/') && ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "[No Group]"))).clicked() { let _ = app.run("style.group", json!({"kind": kind, "names": [n], "group": ""})); ui.close(); diff --git a/crates/ui-egui/src/panels/swatches.rs b/crates/ui-egui/src/panels/swatches.rs index d80b72883..1aa11ddb3 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -73,12 +73,13 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { if icons::button(ui, "plus", 20.0, false, crate::i18n::tr(&app.ui.language, "New Swatch")).clicked() { let _ = app.run("swatch.create", json!({"color": {"c": 0, "m": 50, "y": 100, "k": 0}})); } - ui.menu_button("☰", |ui| { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { + crate::menus::menu_button(ui, "☰", |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Ink Manager")), |ui| { let Ok(l) = app.session.execute("ink.list", &json!({})) else { return }; let mut all = l["allToProcess"].as_bool().unwrap_or(false); if ui.checkbox(&mut all, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "All Spots to Process"))).changed() { let _ = app.run("ink.options", json!({"allToProcess": all})); + ui.close(); } ui.separator(); let inks = l["inks"].as_array().cloned().unwrap_or_default(); @@ -91,6 +92,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { ui.add_enabled_ui(!all, |ui| { if ui.checkbox(&mut process, format!("{name} → process")).changed() { let _ = app.run("ink.options", json!({"ink": name, "toProcess": process})); + ui.close(); } }); } @@ -162,7 +164,7 @@ fn swatch_row( ui.close(); } if !sw.locked { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| { + crate::menus::menu_button(ui, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Move to Color Group")), |ui| { if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "(Top Level)"))).clicked() { let _ = app.run("swatch.moveToGroup", json!({"swatches": [sw.name], "group": null})); ui.close();