diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index bb290bd86..8bb9446e8 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -140,6 +140,8 @@ mod tests_preview_limit; #[cfg(test)] mod tests_print; #[cfg(test)] +mod tests_profile_menu; +#[cfg(test)] mod tests_profiles_layout; #[cfg(test)] mod tests_quit_unsaved; diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index ded1c0bd0..89e134aed 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -169,7 +169,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // content, and the sliders below would run past its edge let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); - egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); + let below = r.rect.bottom(); + egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d, below)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( ui, @@ -429,8 +430,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } /// The profile dropdown: Favorites, Recent, one submenu per group, then favourite toggle and -/// Browse…. -fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { +/// Browse…. Like the menu bar's menus, the dropdown and each submenu stay inside the window and +/// scroll when their rows don't fit (issue #2059: a folder of many imported profiles ran past the +/// window's bottom edge). `below` is the dropdown's bottom edge, which the menu hangs from. +fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { + crate::menu_level::level(ui, 1, Some(below), |ui| profile_menu_rows(app, ui, d, below)); +} + +fn profile_menu_rows(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings, below: f32) { use lightcraft_engine::presets::{PROFILES, profile_groups}; let t = Tokens::get(ui.ctx()); ui.set_min_width(200.0); @@ -463,12 +470,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for g in profile_groups() { let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { - ui.set_min_width(170.0); - for p in PROFILES.iter().filter(|p| p.group == g) { - item(ui, "group", p.id, p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in PROFILES.iter().filter(|p| p.group == g) { + item(ui, "group", p.id, p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:groupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } // imported LUT profile groups let mut lut_groups: Vec = app.session.lut_profiles.iter().map(|p| p.group.clone()).collect(); @@ -477,12 +489,17 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { - ui.set_min_width(170.0); - for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { - item(ui, "group", &p.id, &p.name, &mut pick); - } + crate::menu_level::level(ui, 2, Some(below), |ui| { + ui.set_min_width(170.0); + for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { + item(ui, "group", &p.id, &p.name, &mut pick); + } + }); }); register(ui.ctx(), format!("profileMenu:lutGroupMenu:{g}"), r.response.rect); + if r.inner.is_some() { + crate::menu_level::set_anchor(ui.ctx(), 2, r.response.rect); + } } ui.separator(); if let Some((name, _)) = app.session.profile_info(cur) { diff --git a/crates/ui-egui/src/tests_profile_menu.rs b/crates/ui-egui/src/tests_profile_menu.rs new file mode 100644 index 000000000..3bd644467 --- /dev/null +++ b/crates/ui-egui/src/tests_profile_menu.rs @@ -0,0 +1,52 @@ +//! #2059: the profile dropdown and its submenus stay inside the window. A folder of many imported +//! profiles used to make its submenu run past the window's bottom edge, and the profiles below it +//! could not be reached. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(5); +const SETTLE: Duration = Duration::from_secs(10); +const FOLDER: &str = "Imported"; + +fn widget(h: &Headless, id: &str) -> Option { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn go(h: &mut Headless, method: &str, id: &str) { + let r = h.request(method, json!({"id": id}), T); + assert_eq!(r["ok"], true, "{method} {id}: {r}"); + h.settle(SETTLE); +} + +#[test] +fn a_folder_of_many_imported_profiles_scrolls_inside_the_window() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + for i in 0..80 { + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: format!("lut:imported-{i}"), + name: format!("Imported {i}"), + group: FOLDER.into(), + file: String::new(), + }); + } + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.settle(SETTLE); + go(&mut h, "ui.clickWidget", "dropdown:profile"); + let menu = widget(&h, "menu-level:1").expect("the profile menu is open"); + assert!(menu.bottom() <= h.size.y + 0.5, "the profile menu {menu:?} runs past the window's bottom {}", h.size.y); + go(&mut h, "ui.hoverWidget", &format!("profileMenu:lutGroupMenu:{FOLDER}")); + let sub = widget(&h, "menu-level:2").expect("the imported folder's submenu is open"); + assert!(sub.bottom() <= h.size.y + 0.5, "the submenu {sub:?} runs past the window's bottom {}", h.size.y); + // Its 80 rows don't all fit: some are scrolled out of view rather than drawn off screen. + let rows: Vec = (0..80).filter_map(|i| widget(&h, &format!("profileMenu:group:lut:imported-{i}"))).collect(); + assert!(!rows.is_empty(), "no imported profile rows drawn"); + assert!(rows.iter().any(|r| !sub.contains_rect(*r)), "every row fits a 900 pt window"); +}