diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index eba6ba2e..53bf34ac 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -1543,6 +1543,7 @@ impl eframe::App for EffectcraftApp { self.apply_gpu_failure(ctx); if !self.styled { theme::install(ctx, &self.tokens); + fit_window(ctx); self.styled = true; ctx.request_repaint(); } else { @@ -1598,6 +1599,39 @@ pub(crate) fn tick_playback(app: &mut EffectcraftApp, ctx: &egui::Context, scale app.advance_playback(ctx, scale); } +/// Maximize a first window that doesn't fit the screen, which fits it to the space beside the +/// taskbar or dock. The 1680 × 1020 window on a smaller screen is only shrunk to the monitor's +/// size, so with its title bar it ran under the taskbar and cut menus off at the bottom of the +/// screen (#269). +fn fit_window(ctx: &egui::Context) { + let (monitor, window) = ctx.input(|i| (i.viewport().monitor_size, i.viewport().outer_rect.or(i.viewport().inner_rect))); + if let (Some(monitor), Some(window)) = (monitor, window) + && overflows_screen(window.size(), monitor) + { + ctx.send_viewport_cmd(egui::ViewportCommand::Maximized(true)); + } +} + +/// Whether a window `size` leaves no room on a `monitor` for a taskbar or dock (points). +fn overflows_screen(size: egui::Vec2, monitor: egui::Vec2) -> bool { + const TASKBAR: f32 = 64.0; + size.x > monitor.x || size.y > monitor.y - TASKBAR +} + +#[cfg(test)] +mod fit_window_tests { + use super::overflows_screen; + use egui::vec2; + + #[test] + fn a_window_taller_than_the_screen_beside_its_taskbar_overflows() { + // The default window on a 1366 × 768 laptop (shrunk to the monitor) and on 1080p. + assert!(overflows_screen(vec2(1366.0, 799.0), vec2(1366.0, 768.0))); + assert!(overflows_screen(vec2(1680.0, 1051.0), vec2(1920.0, 1080.0))); + assert!(!overflows_screen(vec2(1680.0, 1051.0), vec2(2560.0, 1440.0))); + } +} + #[cfg(test)] mod gpu_failure_tests { use super::*; diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index e181398e..1cef8b57 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1537,7 +1537,7 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { if let MenuNode::Submenu { label, children } = node { let r = ui.menu_button(crate::i18n::label(app, "", label), |ui| { ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 }); - menu_nodes(app, ui, children, &mut clicked); + crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked)); }); app.auto.add(&format!("menu.{label}"), r.response.rect, label); } @@ -1604,12 +1604,7 @@ fn menu_nodes(app: &mut EffectcraftApp, ui: &mut egui::Ui, nodes: &[MenuNode], c let shown = crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))); ui.menu_button((gutter(false), shown.as_str()), |ui| { ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 }); - // Long submenus (Blending Mode, effect categories) scroll instead of running - // off the screen. Others show whole: egui sizes a new submenu from a default - // 400 pt area, so without a minimum a longer one (Window ▸ Workspace) got - // stuck at that height with its last entries scrolled out of view (#191). - let max_h = ui.ctx().content_rect().height() - 40.0; - egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, |ui| menu_nodes(app, ui, children, clicked)); + crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked)); }); } MenuNode::Item(e) => { diff --git a/crates/ui-egui/src/panels/misc.rs b/crates/ui-egui/src/panels/misc.rs index dfe0458b..ab2591a9 100644 --- a/crates/ui-egui/src/panels/misc.rs +++ b/crates/ui-egui/src/panels/misc.rs @@ -268,28 +268,21 @@ fn dropdown_row(app: &mut EffectcraftApp, ui: &mut egui::Ui, r: Rect, key: &str, ui.data_mut(|d| d.insert_temp(pid.with("open"), !open)); } app.auto.add(&format!("preview.{key}"), r, text); - let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); - if !open { + if !widgets::popup_is_open(ui, pid) { return None; } - let mut chosen = None; - let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(r.left_bottom() + vec2(0.0, 2.0)).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(r.width().max(140.0)); - for (i, label) in items.iter().enumerate() { - let resp = ui.selectable_label(current == Some(i), label.as_str()); - app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label); - if resp.clicked() { - chosen = Some(i); - } + widgets::popup_list(ui, pid, r.left_bottom() + vec2(0.0, 2.0), r, items.len(), 0, |ui| { + ui.set_min_width(r.width().max(140.0)); + let mut chosen = None; + for (i, label) in items.iter().enumerate() { + let resp = ui.selectable_label(current == Some(i), label.as_str()); + app.auto.add(&format!("preview.{key}.{i}"), resp.rect, label); + if resp.clicked() { + chosen = Some(i); } - }); - }); - let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !r.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); - if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); - } - chosen + } + chosen + }) } /// Audio panel: L/R VU meters (dBFS, 0 to -48) with peak hold and clip indicators, fed by the diff --git a/crates/ui-egui/src/panels/text_panels.rs b/crates/ui-egui/src/panels/text_panels.rs index de4f6c93..4f575fb8 100644 --- a/crates/ui-egui/src/panels/text_panels.rs +++ b/crates/ui-egui/src/panels/text_panels.rs @@ -544,51 +544,44 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { /// The font menu: recent fonts first, names in English or the fonts' own language, and a /// "Sample" preview in each font (Settings ▸ Type). fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option { - if !ui.data(|d| d.get_temp::(id.with("open")).unwrap_or(false)) { + if !widgets::popup_is_open(ui, id) { return None; } let rows = effectcraft_engine::font_menu(&app.session.prefs); let preview = app.session.prefs.type_.font_preview; let t = app.tokens; - let mut chosen = None; - let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(if preview { 300.0 } else { 180.0 }); - egui::ScrollArea::vertical().max_height(420.0).show(ui, |ui| { - for r in &rows { - if r.family.is_empty() { - ui.separator(); - continue; - } - let resp = ui.selectable_label(r.family == current, &r.display); - if preview && ui.is_rect_visible(resp.rect) { - let key = egui::Id::new(("font-preview", &r.family)); - let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { - Some(l) => l, - None => { - let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); - ui.data_mut(|d| d.insert_temp(key, l.clone())); - l - } - }; - let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); - for poly in lines.iter() { - let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); - ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); - } - } - if resp.clicked() { - chosen = Some(r.family.clone()); + let seps = rows.iter().filter(|r| r.family.is_empty()).count(); + widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| { + ui.set_min_width(if preview { 300.0 } else { 180.0 }); + let mut chosen = None; + for r in &rows { + if r.family.is_empty() { + ui.separator(); + continue; + } + let resp = ui.selectable_label(r.family == current, &r.display); + if preview && ui.is_rect_visible(resp.rect) { + let key = egui::Id::new(("font-preview", &r.family)); + let lines: std::sync::Arc>> = match ui.data(|d| d.get_temp(key)) { + Some(l) => l, + None => { + let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0)); + ui.data_mut(|d| d.insert_temp(key, l.clone())); + l } + }; + let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0); + for poly in lines.iter() { + let pts: Vec = poly.iter().map(|p| o + vec2(p[0], p[1])).collect(); + ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim))); } - }); - }); - }); - let outside = widgets::pressed_outside(ui.ctx(), &area.response); - if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(id.with("open"), false)); - } - chosen + } + if resp.clicked() { + chosen = Some(r.family.clone()); + } + } + chosen + }) } /// Align panel: Align Layers to Selection / Composition, the six align buttons and the six diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2730391b..d3dcf62e 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -1934,12 +1934,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { egui::Popup::menu(&lang).show(|ui| { for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() { ui.menu_button(cat, |ui| { - for (label, text) in items { - if ui.button(label).clicked() { - picked = Some(text); - ui.close(); + widgets::menu_scroll(ui, |ui| { + for (label, text) in items { + if ui.button(label).clicked() { + picked = Some(text); + ui.close(); + } } - } + }) }); } }); @@ -2958,12 +2960,14 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], actions: &mut Vec<(S item(ui, "Freeze on Last Frame", "layer.freezeOnLastFrame", json!({"layers": layers})); }); ui.menu_button("Blending Mode", |ui| { - for m in BlendMode::ALL { - item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); - if m.ends_group() { - ui.separator(); + widgets::menu_scroll(ui, |ui| { + for m in BlendMode::ALL { + item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()})); + if m.ends_group() { + ui.separator(); + } } - } + }) }); ui.menu_button("Arrange", |ui| { item(ui, "Bring Layer to Front", "layer.arrange", json!({"layers": layers, "to": "front"})); diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index b35c8f5d..4d75b8b1 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -962,12 +962,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let hits = ui.data(|d| d.get_temp::>(menu_hits_id)).unwrap_or_default(); if !hits.is_empty() { ui.menu_button("Select", |ui| { - for (id, name) in &hits { - if ui.button(name).clicked() { - context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); - ui.close(); + crate::widgets::menu_scroll(ui, |ui| { + for (id, name) in &hits { + if ui.button(name).clicked() { + context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]}))); + ui.close(); + } } - } + }) }); ui.separator(); } diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index d1ffc4db..63f23356 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -455,34 +455,27 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: /// `viewer..` automation ids. fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option { let pid = egui::Id::new(id); - let open: bool = ui.data(|d| d.get_temp(pid.with("open")).unwrap_or(false)); - if !open { + if !widgets::popup_is_open(ui, pid) { return None; } let h: f32 = items.iter().map(|(l, _)| if l == "-" { 9.0 } else { 21.0 }).sum::() + 14.0; - let pos = anchor.left_top() - vec2(0.0, h + 4.0); - let mut chosen = None; - let area = egui::Area::new(pid.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { - egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(170.0); - for (i, (label, on)) in items.iter().enumerate() { - if label == "-" { - ui.separator(); - continue; - } - let r = ui.selectable_label(*on, label.as_str()); - app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); - if r.clicked() { - chosen = Some(i); - } + let seps = items.iter().filter(|(l, _)| l == "-").count(); + widgets::popup_list(ui, pid, anchor.left_top() - vec2(0.0, h + 4.0), anchor, items.len().saturating_sub(seps), seps, |ui| { + ui.set_min_width(170.0); + let mut chosen = None; + for (i, (label, on)) in items.iter().enumerate() { + if label == "-" { + ui.separator(); + continue; } - }); - }); - let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !anchor.contains(ui.input(|i| i.pointer.interact_pos()).unwrap_or_default()); - if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(pid.with("open"), false)); - } - chosen + let r = ui.selectable_label(*on, label.as_str()); + app.auto.add(&format!("viewer.{auto}.{i}"), r.rect, label); + if r.clicked() { + chosen = Some(i); + } + } + chosen + }) } fn toggle_popup(ui: &egui::Ui, id: &str) { diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 0fcdb7eb..1a9e1a1a 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -345,6 +345,9 @@ pub fn apply_visuals(ctx: &egui::Context, t: &Tokens) { s.spacing.button_padding = egui::vec2(8.0, 3.0); s.spacing.interact_size.y = 22.0; s.spacing.menu_margin = egui::Margin::same(5); + // Scroll bars always show (egui's float in only over the list), so long menus and lists + // can be dragged without a mouse wheel or touchpad (#269). + s.spacing.scroll = egui::style::ScrollStyle::solid(); s.text_styles.insert(TextStyle::Body, FontId::new(12.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Button, FontId::new(12.0, FontFamily::Proportional)); s.text_styles.insert(TextStyle::Small, FontId::new(11.0, FontFamily::Proportional)); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index 30b7c60f..523c1ab5 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -293,46 +293,60 @@ pub fn check_label(on: bool, label: &str) -> String { /// Show a popup menu anchored at `pos` with string options; returns the chosen index. pub fn popup_menu(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, options: &[String], current: Option) -> Option { - let mut chosen = None; - let open_id = id.with("open"); - let open: bool = ui.data(|d| d.get_temp(open_id).unwrap_or(false)); - if !open { + if !popup_is_open(ui, id) { return None; } - // As tall as the window allows (a shape layer's Add menu has 20 entries), scrolling beyond, - // and moved up when it would run past the bottom of the window (the Timeline's menus). + let seps = options.iter().filter(|o| *o == "-").count(); + popup_list(ui, id, pos, Rect::NOTHING, options.len().saturating_sub(seps), seps, |ui| { + ui.set_min_width(160.0); + let mut chosen = None; + for (i, o) in options.iter().enumerate() { + if o == "-" { + ui.separator(); + } else if ui.selectable_label(current == Some(i), o).clicked() { + chosen = Some(i); + } + } + chosen + }) +} + +/// The open popup list `id` at `pos`, drawn by `body` (`rows` entries and `seps` separators, +/// for its height): as tall as the window allows, scrolling beyond with a scroll bar (a shape +/// layer's Add menu has 20 entries, the blend modes 40, the fonts hundreds), and moved up when +/// it would run past the bottom of the window (the Timeline's menus, #269). Closes when `body` +/// returns a choice, on Escape, or on a press outside it and outside `anchor` (a control that +/// toggles the list itself). Returns the choice. +pub fn popup_list(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, rows: usize, seps: usize, body: impl FnOnce(&mut Ui) -> Option) -> Option { let screen = ui.ctx().content_rect(); let max_h = (screen.height() - 24.0).max(120.0); let sp = ui.spacing(); - let seps = options.iter().filter(|o| *o == "-").count() as f32; - let rows = options.len() as f32 - seps; - let est = (rows * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0); + let est = (rows as f32 * (sp.interact_size.y + sp.item_spacing.y + 4.0) + seps as f32 * (2.0 * sp.item_spacing.y + 2.0) + 16.0).min(max_h + 12.0); let pos = egui::pos2(pos.x, pos.y.min(screen.bottom() - est).max(screen.top())); + let mut chosen = None; let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).fixed_pos(pos).show(ui.ctx(), |ui| { egui::Frame::popup(ui.style()).show(ui, |ui| { - ui.set_min_width(160.0); // (An area's content is laid out in last frame's size: ask for the estimate.) - egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| { - for (i, o) in options.iter().enumerate() { - if o == "-" { - ui.separator(); - continue; - } - let sel = current == Some(i); - if ui.selectable_label(sel, o).clicked() { - chosen = Some(i); - } - } - }); + egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui)); }); }); - let clicked_outside = pressed_outside(ui.ctx(), &area.response); - if chosen.is_some() || clicked_outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { - ui.data_mut(|d| d.insert_temp(open_id, false)); + let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p)); + if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) { + ui.data_mut(|d| d.insert_temp(id.with("open"), false)); } chosen } +/// A menu's (or submenu's) entries, scrolling with a scroll bar when they are taller than the +/// window instead of running off it (Edit on a short screen, #269; Blending Mode). Others show +/// whole: egui sizes a new menu from a default 400 pt area, so without a minimum a longer one +/// (Window ▸ Workspace) got stuck at that height with its last entries scrolled out of view +/// (#191). +pub fn menu_scroll(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { + let max_h = ui.ctx().content_rect().height() - 40.0; + egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add).inner +} + /// Whether a [`popup_menu`] is open (build its options only then: they can be long). pub fn popup_is_open(ui: &Ui, id: egui::Id) -> bool { ui.data(|d| d.get_temp(id.with("open")).unwrap_or(false)) diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs new file mode 100644 index 00000000..2b4cf331 --- /dev/null +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -0,0 +1,60 @@ +//! Long menus and lists scroll instead of running off the window, with a scroll bar to drag +//! (egui_kittest, UI logic only). + +use effectcraft_engine::Session; +use effectcraft_ui_egui::EffectcraftApp; +use egui::{Event, Pos2, Rect, pos2, vec2}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use serde_json::json; + +fn harness(w: f32, h: f32) -> Harness<'static, EffectcraftApp> { + let mut s = Session::default(); + s.execute("file.openDemoProject", json!({})).unwrap(); + let mut h = Harness::builder().with_size(vec2(w, h)).build_eframe(|_| EffectcraftApp::new(s)); + h.run_steps(3); + h +} + +fn rect(h: &Harness<'_, EffectcraftApp>, id: &str) -> Rect { + let e = h.state().auto.find(id).unwrap_or_else(|| panic!("no {id}")); + Rect::from_min_size(pos2(e.rect[0], e.rect[1]), vec2(e.rect[2], e.rect[3])) +} + +fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) { + h.input_mut().events.push(Event::PointerMoved(p)); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(2); +} + +fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) { + h.input_mut().events.push(Event::PointerMoved(at)); + h.input_mut().events.push(Event::MouseWheel { + unit: egui::MouseWheelUnit::Point, + delta: vec2(0.0, dy), + modifiers: Default::default(), + phase: egui::TouchPhase::Move, + }); + h.run_steps(20); +} + +/// Edit is taller than a short window: it scrolls (with the wheel, and a scroll bar that always +/// shows) so its last entries can be reached and clicked, instead of running off the bottom of +/// the window (#269). +#[test] +fn a_menu_taller_than_the_window_scrolls() { + let mut h = harness(1200.0, 520.0); + assert!(!h.ctx.global_style().spacing.scroll.floating, "scroll bars show without hovering the list"); + let edit = rect(&h, "menu.Edit"); + click_at(&mut h, edit.center()); + let last = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect(); + assert!(last.max.y > 520.0, "Edit is taller than the window ({last:?})"); + let first = h.query_by_label_contains("Quick Apply").expect("Quick Apply").rect(); + wheel(&mut h, first.center(), -2000.0); + let last = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect(); + assert!(last.min.y >= 0.0 && last.max.y <= 520.0, "scrolled into the window ({last:?})"); + click_at(&mut h, last.center()); + assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click"); +}