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 <noreply@anthropic.com>
This commit is contained in:
parent
52ab38fcff
commit
6d2ef5a830
|
|
@ -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::*;
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<String> {
|
||||
if !ui.data(|d| d.get_temp::<bool>(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<Vec<Vec<[f32; 2]>>> = 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<egui::Pos2> = 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<Vec<Vec<[f32; 2]>>> = 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<egui::Pos2> = 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
|
||||
|
|
|
|||
|
|
@ -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"}));
|
||||
|
|
|
|||
|
|
@ -962,12 +962,14 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let hits = ui.data(|d| d.get_temp::<Vec<(u64, String)>>(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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -455,34 +455,27 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
|
|||
/// `viewer.<auto>.<n>` automation ids.
|
||||
fn popup(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: &str, anchor: Rect, items: &[(String, bool)], auto: &str) -> Option<usize> {
|
||||
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::<f32>() + 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) {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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<usize>) -> Option<usize> {
|
||||
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<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, rows: usize, seps: usize, body: impl FnOnce(&mut Ui) -> Option<R>) -> Option<R> {
|
||||
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<R>(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))
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
Loading…
Reference in New Issue