From 07b33edbef7881e2c9bdfa561cf830f10dc4e62e Mon Sep 17 00:00:00 2001 From: skylar taylor-barrick Date: Fri, 9 Oct 2026 20:29:01 -0600 Subject: [PATCH] UI: keep menus and dialogs reachable at larger scales --- crates/ui-egui/src/dialogs.rs | 297 ++++++++++++++++++++++++++++++++-- crates/ui-egui/src/menus.rs | 225 ++++++++++++++++++++++++-- 2 files changed, 493 insertions(+), 29 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 34832db1..ae5f7c23 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -392,7 +392,7 @@ fn document_setup(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { /// Preferences: a section list and the section's options. Application options always; units and /// increments when a document is open (InDesign keeps those with the document). -fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { +fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog, max_height: f32) { let has_doc = d.fields.contains_key("horizontalUnits"); let sections: &[(&str, &str)] = if has_doc { &[ @@ -431,21 +431,26 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { }; let cur = d.s("section"); ui.horizontal_top(|ui| { - // A fixed height: the separator would otherwise take the whole window. - ui.set_min_height(240.0); - ui.set_max_height(240.0); + // Bound both panes independently so selecting a lower section keeps its options visible. + let height = max_height.min(440.0); + ui.set_min_height(240.0_f32.min(height)); + ui.set_max_height(height); ui.vertical(|ui| { ui.set_width(150.0); - for (id, label) in sections { - if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { - d.fields.insert("section".into(), json!(id)); + egui::ScrollArea::vertical().id_salt("preferences_sections").min_scrolled_height(1.0).max_height(height).show(ui, |ui| { + for (id, label) in sections { + if ui.selectable_label(cur == *id, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).clicked() { + d.fields.insert("section".into(), json!(id)); + } } - } + }); }); ui.separator(); ui.vertical(|ui| { ui.set_min_width(300.0); - match cur.as_str() { + egui::ScrollArea::vertical().id_salt(("preferences_options", &cur)).min_scrolled_height(1.0).max_height(height).show(ui, |ui| match cur + .as_str() + { "dictionary" => { ui.label(crate::rtl::widget( ui, @@ -779,7 +784,7 @@ fn preferences(app: &crate::DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Page Numbering")).font(semibold(12.0))); check(ui, d, "absolutePageNumbers", crate::i18n::tr(&app.ui.language, "Absolute Numbering (instead of Section Numbering)")); } - } + }); }); }); } @@ -836,8 +841,12 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { }, }; egui::Modal::new(egui::Id::new("dialog")).show(ctx, |ui| { - ui.set_min_width(380.0); - ui.set_max_width(if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 }); + let frame_margin = egui::Frame::popup(ui.style()).total_margin().sum(); + let available = (ctx.content_rect().size() - frame_margin - egui::vec2(16.0, 16.0)).max(egui::Vec2::splat(1.0)); + let preferred_width: f32 = if d.id == "newDocument" && crate::i18n::is_rtl(&app.ui.language) { 380.0 } else { 640.0 }; + let width = preferred_width.min(available.x); + ui.set_min_width(380.0_f32.min(width)); + ui.set_max_width(width); if crate::i18n::is_rtl(&app.ui.language) { ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -848,6 +857,11 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { crate::rtl::label(ui, egui::RichText::new(title).font(semibold(16.0))); } ui.add_space(10.0); + // Keep the title and action buttons outside the scrollable body. Oversized + // forms remain reachable at large UI scales, without shrinking their text. + let footer_height = ui.spacing().interact_size.y.max(ui.text_style_height(&egui::TextStyle::Button) + 2.0 * ui.spacing().button_padding.y); + let body_height = (available.y - ui.min_size().y - footer_height - 12.0 - 2.0 * ui.spacing().item_spacing.y).max(1.0); + egui::ScrollArea::both().id_salt(("dialog_body", &d.id)).min_scrolled_width(1.0).min_scrolled_height(1.0).max_width(width).max_height(body_height).show(ui, |ui| { match d.id.as_str() { "newDocument" => { ui.horizontal(|ui| { @@ -1057,7 +1071,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "insertXref" => insert_xref(app, ui, &mut d), "findFont" => find_font(app, ui, &mut d), "colorPicker" => color_picker(ui, &mut d), - "preferences" => preferences(app, ui, &mut d), + "preferences" => preferences(app, ui, &mut d, body_height), "print" => print_dialog(app, ui, &mut d), "pdfImport" => { ui.label(egui::RichText::new(d.s("path")).size(11.0)); @@ -1368,6 +1382,7 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context) { "documentSetup" => document_setup(app, ui, &mut d), _ => {} } + }); ui.add_space(12.0); ui.horizontal(|ui| { ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -2654,6 +2669,262 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { mod tests { use super::*; + fn dialog_context(language: &str) -> egui::Context { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx, language); + crate::theme::apply(&ctx, &crate::theme::Tokens::for_brightness(crate::theme::Brightness::default())); + ctx + } + + fn dialog_frame(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, events: Vec) -> Vec<(String, egui::Rect, egui::Rect)> { + let time = ctx.input(|i| i.time) + 1.0 / 60.0; + let mut output = ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(time), events, ..Default::default() }, |ui| { + show(app, ui.ctx()); + }); + output.textures_delta.clear(); + fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)), + egui::Shape::Vec(shapes) => { + for s in shapes { + collect(s, clip, labels); + } + } + _ => {} + } + } + let mut labels = Vec::new(); + for shape in output.shapes { + collect(&shape.shape, shape.clip_rect, &mut labels); + } + labels + } + + fn visible_label(labels: &[(String, egui::Rect, egui::Rect)], screen: egui::Rect, expected: &str) -> egui::Rect { + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text.trim() == expected).unwrap_or_else(|| panic!("{expected} is painted")); + assert!(screen.contains_rect(*rect), "{expected} must stay on screen: {rect:?}"); + assert!(clip.contains_rect(*rect), "{expected} must not be clipped: {rect:?}, {clip:?}"); + *rect + } + + fn click_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2) { + for pressed in [true, false] { + dialog_frame( + app, + ctx, + screen, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + } + + fn scroll_dialog(app: &mut DesignApp, ctx: &egui::Context, screen: egui::Rect, pos: egui::Pos2, delta: egui::Vec2) { + dialog_frame( + app, + ctx, + screen, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Point, delta, phase: egui::TouchPhase::Move, modifiers: egui::Modifiers::NONE }, + ], + ); + // Let smooth wheel scrolling and scrollbar visibility settle before clicking. + for _ in 0..30 { + dialog_frame(app, ctx, screen, vec![]); + } + } + + #[test] + fn dialog_title_and_actions_fit_scaled_viewports() { + for language in ["en", "ar"] { + for size in [egui::vec2(1178.0, 814.0), egui::vec2(589.0, 407.0), egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + for (command, title) in [("app.preferences", "Preferences"), ("app.newDocumentDialog", "New Document")] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = language.into(); + app.run("file.new", json!({})).unwrap(); + app.run(command, json!({})).unwrap(); + let ctx = dialog_context(language); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in [title, "OK", "Cancel"] { + visible_label(&labels, screen, crate::i18n::tr(language, expected)); + } + } + } + } + } + + #[test] + fn preferences_sidebar_scroll_keeps_selected_options_visible() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.run("app.preferences", json!({})).unwrap(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let sidebar = visible_label(&labels, screen, "General").center(); + assert!( + !labels.iter().any(|(text, rect, clip)| text == "File Handling" && clip.contains_rect(*rect)), + "last section starts below the sidebar" + ); + scroll_dialog(&mut app, &ctx, screen, sidebar, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let files = visible_label(&labels, screen, "File Handling").center(); + click_dialog(&mut app, &ctx, screen, files); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("section"), "files"); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Document Recovery Data", "Save recovery data every:", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + } + + #[test] + fn preferences_horizontal_scroll_reaches_clipped_options() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("app.preferences", json!({})).unwrap(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(450.0, 280.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let body = visible_label(&labels, screen, "When Scaling").center(); + let option = "Absolute Numbering (instead of Section Numbering)"; + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == option).unwrap(); + assert!(rect.right() > clip.right(), "rightmost option starts horizontally clipped: {rect:?}, {clip:?}"); + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(-600.0, 0.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in [option, "Preferences", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, option).center()); + assert!(app.ui.dialog.as_ref().unwrap().b("absolutePageNumbers")); + } + + #[test] + fn new_document_vertical_scroll_reaches_last_field() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let ctx = dialog_context("en"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(392.0, 271.0)); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let body = visible_label(&labels, screen, "Width").center(); + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Primary Text Frame").unwrap(); + assert!(rect.bottom() > clip.bottom(), "last field starts vertically clipped"); + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in ["Primary Text Frame", "New Document", "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Primary Text Frame").center()); + assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame")); + } + + #[test] + fn arabic_new_document_leftmost_fields_remain_reachable() { + for size in [egui::vec2(392.0, 271.0), egui::vec2(450.0, 280.0)] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.language = "ar".into(); + app.run("app.newDocumentDialog", json!({})).unwrap(); + let fields = [("height", "66p1"), ("gutter", "1p2"), ("marginBottom", "3p3"), ("marginOutside", "3p4")]; + for (field, value) in fields { + app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(value)); + } + let ctx = dialog_context("ar"); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for (_, value) in fields { + visible_label(&labels, screen, value); + } + let body = visible_label(&labels, screen, "66p1").center(); + for x in [-600.0, 600.0] { + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(x, 0.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for (_, value) in fields { + visible_label(&labels, screen, value); + } + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "66p1").center()); + dialog_frame( + &mut app, + &ctx, + screen, + vec![ + egui::Event::Key { + key: egui::Key::A, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers { ctrl: true, command: true, ..Default::default() }, + }, + egui::Event::Text("88p8".into()), + ], + ); + assert_eq!(app.ui.dialog.as_ref().unwrap().s("height"), "88p8", "leftmost numeric field accepts edits"); + scroll_dialog(&mut app, &ctx, screen, body, egui::vec2(0.0, -600.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + let label = crate::i18n::tr("ar", "Primary Text Frame"); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, label).center()); + assert!(app.ui.dialog.as_ref().unwrap().b("primaryTextFrame"), "leftmost checkbox remains clickable after scrolling"); + } + } + + #[test] + fn resized_dialogs_cancel_and_reopen_without_applying_edits() { + for (command, title, field) in [("app.preferences", "Preferences", "recoveryMinutes"), ("app.newDocumentDialog", "New Document", "pages")] { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run(command, json!({})).unwrap(); + let original = app.ui.dialog.as_ref().unwrap().fields[field].clone(); + app.ui.dialog.as_mut().unwrap().fields.insert(field.into(), json!(42)); + let ctx = dialog_context("en"); + for size in [egui::vec2(1178.0, 814.0), egui::vec2(392.0, 271.0), egui::vec2(589.0, 407.0)] { + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, size); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + for expected in [title, "OK", "Cancel"] { + visible_label(&labels, screen, expected); + } + } + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(589.0, 407.0)); + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + click_dialog(&mut app, &ctx, screen, visible_label(&labels, screen, "Cancel").center()); + assert!(app.ui.dialog.is_none()); + assert!(app.session.active().is_none(), "cancelling must not create a document"); + app.run(command, json!({})).unwrap(); + assert_eq!(app.ui.dialog.as_ref().unwrap().fields[field], original, "cancelled edits must not survive reopening"); + for _ in 0..3 { + dialog_frame(&mut app, &ctx, screen, vec![]); + } + let labels = dialog_frame(&mut app, &ctx, screen, vec![]); + visible_label(&labels, screen, title); + dialog_frame( + &mut app, + &ctx, + screen, + vec![egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }], + ); + assert!(app.ui.dialog.is_none(), "Escape must also close the reopened dialog"); + } + } + #[test] fn parses_command_params() { let f = command_fields("{name, type: custom|lastPageNumber|chapterNumber, text?, rule?: {on, weight}, flag?: bool} — creates"); diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index abff58c6..9f200fbd 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1751,24 +1751,62 @@ 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| { - 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; - } + 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; + } + } + }); + }); }); - }); } } +/// 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 +} + +fn clipped_submenu(ui: &mut egui::Ui, label: egui::WidgetText, contents: impl FnOnce(&mut egui::Ui) -> R) -> Option { + use egui::containers::menu::{MenuState, SubMenu, SubMenuButton}; + + let id = SubMenu::id_from_widget_id(ui.next_auto_id()); + let open = MenuState::from_ui(ui, |state, _| state.open_item == Some(id)); + let inactive = ui.style().visuals.widgets.inactive; + 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); + 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; + } + // egui expands an unclipped hover rectangle by half the item spacing. + 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) +} + /// The key a menu item is hidden by (Edit › Menus). pub fn menu_key(menu: &str, label: &str) -> String { format!("{menu}/{label}") @@ -1785,9 +1823,8 @@ 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| { - hidden += menu_items(app, ui, children, &sub); - }); + hidden += + clipped_submenu(ui, crate::rtl::widget(ui, shown), |ui| scroll_menu(ui, |ui| menu_items(app, ui, children, &sub))).unwrap_or(0); } 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: _ } => { @@ -2018,6 +2055,97 @@ mod tests { } } + /// Exercise the real dropdown, including clipping and wheel input, on a short viewport. + #[test] + fn oversized_edit_menu_scrolls_to_preferences() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(700.0, 260.0)); + let mut tick = 0; + let mut frame = |app: &mut DesignApp, events: Vec| { + tick += 1; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + egui::MenuBar::new().ui(ui, |ui| menu_bar(app, ui)); + }); + output.textures_delta.clear(); + let mut labels = Vec::new(); + fn collect(shape: &egui::Shape, clip: egui::Rect, labels: &mut Vec<(String, egui::Rect, egui::Rect)>) { + match shape { + egui::Shape::Text(t) => labels.push((t.galley.job.text.clone(), t.visual_bounding_rect(), clip)), + egui::Shape::Vec(shapes) => { + for shape in shapes { + collect(shape, clip, labels); + } + } + _ => {} + } + } + for shape in output.shapes { + collect(&shape.shape, shape.clip_rect, &mut labels); + } + labels + }; + frame(&mut app, vec![]); + let labels = frame(&mut app, vec![]); + let edit = labels.iter().find(|(text, _, _)| text == "Edit").unwrap().1.center(); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(edit), + egui::Event::PointerButton { pos: edit, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + for _ in 0..3 { + frame(&mut app, vec![]); + } + let layer = ctx.layer_id_at(egui::pos2(edit.x + 80.0, 100.0)).expect("menu layer"); + let bounds = ctx.memory(|m| m.area_rect(layer.id)).expect("menu bounds"); + let track = egui::pos2(bounds.right() - 9.0, bounds.bottom() - 25.0); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(track), + egui::Event::PointerButton { pos: track, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert!(egui::Popup::is_any_open(&ctx), "clicking the scroll track must not dismiss the menu"); + frame( + &mut app, + vec![ + egui::Event::PointerMoved(egui::pos2(edit.x + 80.0, 100.0)), + egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: egui::vec2(0.0, -3000.0), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }, + ], + ); + let mut labels = Vec::new(); + for _ in 0..60 { + labels = frame(&mut app, vec![]); + } + let (_, rect, clip) = labels.iter().find(|(text, _, _)| text == "Preferences…").expect("Preferences is rendered"); + assert!(screen.contains_rect(*rect) && clip.contains_rect(*rect), "Preferences must be visible after scrolling: {rect:?}, {clip:?}"); + let pos = rect.center(); + for pressed in [true, false] { + frame( + &mut app, + vec![ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + assert_eq!(app.ui.dialog.as_ref().map(|d| d.id.as_str()), Some("preferences")); + assert!(!egui::Popup::is_any_open(&ctx), "choosing a command dismisses the menu"); + } + #[test] fn transform_menu_items_open_their_dialogs() { for id in ["transform.move", "transform.scale", "transform.rotate", "transform.shear"] { @@ -2501,4 +2629,69 @@ mod tests { assert!(app.ui.hidden_menu_items.is_empty()); assert!(items.len() > 100, "every menu's items are listed: {}", items.len()); } + + #[test] + fn clipped_submenu_does_not_open_when_hovering_outside_menu() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let ctx = egui::Context::default(); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 900.0)); + let entries = vec![Item::Sub("Hidden submenu".into(), vec![cmd_item("app.preferences", Value::Null)])]; + let mut tick = 0; + let mut frame = |spacing: f32, events: Vec| { + tick += 1; + let mut button_rect = egui::Rect::NOTHING; + let mut hidden_submenu = None; + let mut output = + ctx.run_ui(egui::RawInput { screen_rect: Some(screen), time: Some(tick as f64 / 60.0), events, ..Default::default() }, |ui| { + egui::MenuBar::new().ui(ui, |ui| { + let response = ui.menu_button("Menu", |ui| { + egui::ScrollArea::vertical().max_height(400.0).show(ui, |ui| { + ui.add_space(spacing); + let button_id = ui.next_auto_id(); + menu_items(&mut app, ui, &entries, "Test"); + let response = ui.ctx().read_response(button_id).expect("submenu button response"); + let open = egui::containers::menu::MenuState::from_ui(ui, |state, _| state.open_item.is_some()); + hidden_submenu = Some((response.rect, ui.clip_rect(), open)); + }); + }); + button_rect = response.response.rect; + }); + }); + output.textures_delta.clear(); + (button_rect, hidden_submenu) + }; + frame(500.0, vec![]); + let button = frame(500.0, vec![]).0.center(); + for pressed in [true, false] { + frame( + 500.0, + vec![ + egui::Event::PointerMoved(button), + egui::Event::PointerButton { pos: button, button: egui::PointerButton::Primary, pressed, modifiers: egui::Modifiers::NONE }, + ], + ); + } + for _ in 0..3 { + frame(500.0, vec![]); + } + let (_, Some((hidden, clip, open))) = frame(500.0, vec![]) else { panic!("test menu is open") }; + assert!(!open, "submenu starts closed"); + assert!(screen.contains_rect(hidden), "hidden row remains inside the screen: {hidden:?}"); + assert!(!clip.intersects(hidden), "submenu must be fully clipped: {hidden:?}, {clip:?}"); + let (_, Some((_, _, open))) = frame(500.0, vec![egui::Event::PointerMoved(hidden.center())]) else { panic!("test menu remains open") }; + assert!(!open, "hovering the clipped row's position outside the menu must not open its submenu"); + + let partial_spacing = 500.0 - (hidden.top() - clip.bottom()) - 6.0; + let (_, Some((partial, clip, open))) = frame(partial_spacing, vec![]) else { panic!("test menu remains open") }; + assert!(!open); + assert!(partial.top() < clip.bottom() && partial.bottom() > clip.bottom(), "row must be partially clipped: {partial:?}, {clip:?}"); + let outside = egui::pos2(partial.center().x, clip.bottom() + 1.0); + let (_, Some((_, _, open))) = frame(partial_spacing, vec![egui::Event::PointerMoved(outside)]) else { panic!("test menu remains open") }; + assert!(!open, "hovering the clipped portion of a partially visible row must not open its submenu"); + let visible = partial.intersect(clip).center(); + let (_, Some((_, _, open))) = frame(partial_spacing, vec![egui::Event::PointerMoved(visible)]) else { panic!("test menu remains open") }; + assert!(open, "hovering the visible portion still opens the submenu"); + let (_, Some((_, _, open))) = frame(500.0, vec![]) else { panic!("test menu remains open") }; + assert!(!open, "an open submenu closes when its row becomes fully clipped"); + } }