From 717ddb6070b650bd8433c4455fde9c042156d72c Mon Sep 17 00:00:00 2001 From: aiongg Date: Sat, 10 Oct 2026 01:35:54 +0000 Subject: [PATCH] Menus: menus taller than the window scroll At a large UI scale the menu bar menus ran off the bottom of the window, so items such as Edit > Preferences could not be reached (#267). Every menu_button popup (menu bar menus and submenus, workspace and zoom menus, panel menus) now goes through menus::menu_button. It caps the menu's height at the larger of the spaces below and above its button, and the rest scrolls with the mouse wheel. Short menus are unchanged. --- crates/ui-egui/src/chrome.rs | 84 +++++++++++++---------- crates/ui-egui/src/menus.rs | 89 ++++++++++++++++++++++++- crates/ui-egui/src/panels/pages.rs | 2 +- crates/ui-egui/src/panels/properties.rs | 4 +- crates/ui-egui/src/panels/styles.rs | 2 +- crates/ui-egui/src/panels/swatches.rs | 6 +- 6 files changed, 141 insertions(+), 46 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 abff58c62..f548b19af 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1743,6 +1743,42 @@ pub fn activate(app: &mut DesignApp, id: &str, params: &Value) { let _ = app.run(id, p); } +/// [`egui::Ui::menu_button`] for every DesignCraft menu: a menu taller than the window scrolls +/// with the mouse wheel inside the larger of the spaces below and above its button, as native +/// menus do. Shorter menus are unchanged. +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> { + // The button's rect from the last frame: a menu only opens on a button that was shown. + let button = ui.ctx().read_response(ui.next_auto_id()).map(|r| r.rect); + let max_height = menu_max_height(ui, button); + ui.menu_button(atoms, |ui| { + let mut scroll = egui::ScrollArea::vertical().max_height(max_height).auto_shrink([true, true]); + if ui.is_sizing_pass() { + // A menu that just opened starts at its top. + scroll = scroll.vertical_scroll_offset(0.0); + } + scroll.show(ui, add_contents).inner + }) +} + +/// The tallest a menu's contents may be. It depends only on the button and the window (never on +/// where the popup ended up), so the popup's placement cannot oscillate between frames. +fn menu_max_height(ui: &egui::Ui, button: Option) -> f32 { + let screen = ui.ctx().content_rect(); + let space = match button { + // Submenus open beside their item, going down from its top or up from its bottom. + Some(r) if egui::containers::menu::is_in_menu(ui) => (screen.bottom() - r.top()).max(r.bottom() - screen.top()), + Some(r) => (screen.bottom() - r.bottom()).max(r.top() - screen.top()), + None => screen.height(), + }; + // The popup's frame and a small gap to the window edge. + let chrome = egui::Frame::menu(ui.style()).total_margin().sum().y + 8.0; + (space - chrome).max(ui.spacing().interact_size.y) +} + /// 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(); @@ -1751,7 +1787,7 @@ pub fn menu_bar(app: &mut DesignApp, ui: &mut egui::Ui) { menus.reverse(); } for (menu, entries) in menus { - ui.menu_button(crate::rtl::widget(ui, crate::i18n::tr(&lang, menu)), |ui| { + 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); } @@ -1785,7 +1821,7 @@ 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(); - ui.menu_button(crate::rtl::widget(ui, shown), |ui| { + menu_button(ui, crate::rtl::widget(ui, shown), |ui| { hidden += menu_items(app, ui, children, &sub); }); } @@ -2501,4 +2537,53 @@ mod tests { assert!(app.ui.hidden_menu_items.is_empty()); assert!(items.len() > 100, "every menu's items are listed: {}", items.len()); } + + #[test] + fn menu_taller_than_the_window_scrolls() { + // Issue #267: at a large UI scale the menus ran off the bottom of the window. + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(400.0, 300.0)); + let last = std::cell::Cell::new(None::); + let button = std::cell::Cell::new(None::); + let frame = |t: f64, events: Vec| { + let input = egui::RawInput { screen_rect: Some(screen), time: Some(t), events, ..Default::default() }; + let mut out = ctx.run_ui(input, |ui| { + let r = menu_button(ui, "Long", |ui| { + for i in 0..60 { + let r = ui.button(format!("Item {i}")); + if i == 59 { + last.set(Some(r.rect)); + } + } + }); + button.set(Some(r.response)); + }); + out.textures_delta.clear(); + }; + frame(0.0, vec![]); + let b = button.take().unwrap(); + let p = b.rect.center(); + let click = |pressed| egui::Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(0.1, vec![egui::Event::PointerMoved(p), click(true)]); + frame(0.2, vec![click(false)]); + frame(0.3, vec![]); + frame(0.4, vec![]); + let popup = ctx.memory(|m| m.area_rect(egui::Popup::default_response_id(&b))).unwrap(); + assert!(popup.top() >= b.rect.bottom() - 1.0, "the menu opens below its button: {popup:?}"); + assert!(popup.bottom() <= screen.bottom(), "the menu fits in the window: {popup:?}"); + assert!(last.get().unwrap().top() > screen.bottom(), "the last item starts out of view"); + let inside = egui::pos2(popup.center().x, popup.bottom() - 20.0); + let wheel = egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -5000.0), + phase: egui::TouchPhase::Move, + modifiers: Default::default(), + }; + frame(0.5, vec![egui::Event::PointerMoved(inside), wheel]); + for i in 0..30 { + frame(0.6 + f64::from(i) * 0.1, vec![]); + } + let l = last.get().unwrap(); + assert!(popup.contains_rect(l), "the wheel scrolls the last item into view: {l:?} in {popup:?}"); + } } 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..59d0daca9 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,7 +1385,7 @@ 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; 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..e61d3339b 100644 --- a/crates/ui-egui/src/panels/swatches.rs +++ b/crates/ui-egui/src/panels/swatches.rs @@ -73,8 +73,8 @@ 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() { @@ -162,7 +162,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();