UI: keep menus and dialogs reachable at larger scales

This commit is contained in:
skylar taylor-barrick
2026-10-09 20:29:01 -06:00
parent 392f414322
commit 07b33edbef
2 changed files with 493 additions and 29 deletions
+284 -13
View File
@@ -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<egui::Event>) -> 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");
+209 -16
View File
@@ -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<R>(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<R>(ui: &mut egui::Ui, label: egui::WidgetText, contents: impl FnOnce(&mut egui::Ui) -> R) -> Option<R> {
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<egui::Event>| {
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<egui::Event>| {
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");
}
}