From 6d2ef5a83096e328bbfda24765490cf1cb879cb9 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Thu, 8 Oct 2026 18:04:32 +0700 Subject: [PATCH] Menus: a long dropdown scrolls instead of running off the window (#269) The menu bar's own menus (Edit has 30 entries) had no scroll area, only their submenus did, so on a short screen Edit ran past the bottom of the window. The font menu was capped at 420 points and never moved up, the Preview panel's and the viewer's popup lists neither scrolled nor moved, and the Timeline's Blending Mode and Expression Language submenus didn't scroll. egui's scroll bars also only appeared while the pointer was over the list, so a list could only be scrolled with a wheel or touchpad. - widgets::popup_list: the one popup list behind popup_menu, the font menu, the Preview panel's dropdowns and the viewer's popups: as tall as the window allows, scrolling beyond, moved up to fit the window. - widgets::menu_scroll: the same for menus and submenus (the menu bar's, Blending Mode, the Expression Language menu, the viewer's Select menu). - Scroll bars always show (solid scroll style), so they can be dragged. - A first window that doesn't fit the screen is maximized: the 1680 x 1020 default 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 even when they fitted the window. Test: ui_scrolling a_menu_taller_than_the_window_scrolls (Edit in a 520-point window scrolls its last entry into view and it takes the click); overflows_screen unit test. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/lib.rs | 34 +++++++++++ crates/ui-egui/src/menus.rs | 9 +-- crates/ui-egui/src/panels/misc.rs | 31 ++++------ crates/ui-egui/src/panels/text_panels.rs | 69 ++++++++++------------- crates/ui-egui/src/panels/timeline.rs | 24 ++++---- crates/ui-egui/src/panels/viewer.rs | 12 ++-- crates/ui-egui/src/panels/viewer_tools.rs | 41 ++++++-------- crates/ui-egui/src/theme.rs | 3 + crates/ui-egui/src/widgets.rs | 64 +++++++++++++-------- crates/ui-egui/tests/ui_scrolling.rs | 60 ++++++++++++++++++++ 10 files changed, 219 insertions(+), 128 deletions(-) create mode 100644 crates/ui-egui/tests/ui_scrolling.rs 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"); +}