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();