Merge #1653: UI: native press-and-browse menus, with edge auto-scroll (@elZanco)

This commit is contained in:
aiongg
2026-10-23 23:33:23 +00:00
7 changed files with 443 additions and 23 deletions
+38 -1
View File
@@ -10,7 +10,7 @@
//! shortcuts and the panels don't see them (Space must not start a preview).
use egui::containers::menu::{MenuState, SubMenu, find_menu_root};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Response, Ui};
use egui::{Context, Event, Id, Key, Modifiers, Popup, Pos2, Rect, Response, Ui};
/// An entry of an open menu as drawn this frame.
#[derive(Clone, Copy)]
@@ -39,6 +39,12 @@ pub struct Nav {
tops: Vec<(Id, Id)>,
/// Open menus by depth: the top-level menu, then each open submenu.
levels: Vec<Level>,
/// The titles and open menus drawn this frame: a press outside them is an outside press,
/// which closes the menu (native behaviour; the press itself still reaches what's under it).
rects: Vec<Rect>,
/// A title press opened (or kept) the menu this frame: releasing the button over an entry
/// chooses it (After Effects' press-and-browse).
browsing: bool,
}
/// What to highlight once a menu opened from the keyboard has been drawn: the first entry of the
@@ -73,6 +79,37 @@ impl Nav {
self.tops.push((button.id, Popup::default_response_id(button)));
}
/// The rect `rect` (a menu title or an open menu) holds, so a press there doesn't close.
pub fn cover(&mut self, rect: Rect) {
self.rects.push(rect);
}
/// Whether `pos` is inside a title or menu drawn this frame.
pub fn covers(&self, pos: Pos2) -> bool {
self.rects.iter().any(|r| r.contains(pos))
}
/// A title press is in progress (its button still held): see [`Nav::browsing`].
pub fn set_browsing(&mut self) {
self.browsing = true;
}
/// Whether the menu bar is mid press-and-browse (a title was pressed this pointer-down and
/// hasn't been released yet): releasing over an entry then chooses it.
pub fn browsing(&self) -> bool {
self.browsing
}
/// The popup of the open top-level menu (only one can be open), if any.
pub fn open_popup(&self, ctx: &Context) -> Option<Id> {
self.tops.iter().find(|(_, p)| Popup::is_id_open(ctx, *p)).map(|(_, p)| *p)
}
/// The position in the bar of the open top-level menu, if any.
pub fn open_index(&self, ctx: &Context) -> Option<usize> {
self.tops.iter().position(|(_, p)| Popup::is_id_open(ctx, *p))
}
/// An entry drawn at `depth` (0: a top-level menu) in the menu `ui`. Hovering an entry moves
/// the keyboard highlight to it, and a highlighted entry scrolls into view.
pub fn entry(&mut self, ui: &Ui, depth: usize, r: &Response, submenu: bool) {
+128 -15
View File
@@ -2037,32 +2037,119 @@ fn handle_shortcuts_impl(app: &mut EffectcraftApp, ctx: &egui::Context, tab_only
}
}
/// The id under which [`menu_bar`] remembers which of its menus was open at the end of the last
/// frame (its title's open look, and the hover that switches between the titles).
fn open_menu_id() -> egui::Id {
egui::Id::new("menu-bar-open")
}
/// The id under which [`menu_bar`] remembers a title press still in progress (press-and-browse).
fn browse_menu_id() -> egui::Id {
egui::Id::new("menu-bar-browse")
}
/// Draw the in-window menu bar (the engine's AE menu tree).
///
/// The titles open on the press, not the release (native menu bars): while one is held the menus
/// can be browsed by moving onto them, and releasing over an entry chooses it. Hovering another
/// title switches the open menu to it, and the open one closes on a press outside it or when the
/// window loses focus. The pointer leaving the window — onto the OS title bar, directly above the
/// bar — closes nothing, however long: the platform forwards none of the title bar's clicks to
/// egui (only the cursor leaving), so there is no click to close on and a graze must not drop the
/// menu. The closing press itself still reaches the widget under it, as a click does in After
/// Effects.
pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) {
let ctx = ui.ctx().clone();
let mut clicked: Option<(String, Value)> = None;
let mut nav = crate::menu_keys::Nav::default();
// The open menu as of the last frame: the titles are drawn before this frame can open or
// close anything, and what happened last frame is what they must react to.
let open_prev: Option<usize> = ctx.data(|d| d.get_temp(open_menu_id())).flatten();
if ctx.data(|d| d.get_temp(browse_menu_id())).unwrap_or(false) {
nav.set_browsing();
}
let mut drawn = 0;
ui.horizontal_centered(|ui| {
ui.add_space(6.0);
egui::MenuBar::new().config(egui::containers::menu::MenuConfig::new().style(crate::widgets::menu_style)).ui(ui, |ui| {
for node in effectcraft_engine::menus::menu_bar() {
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 });
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
});
nav.top(&r.response);
app.auto.add(&format!("menu.{label}"), r.response.rect, label);
let MenuNode::Submenu { label, children } = node else { continue };
// The open title takes the open button's look while its menu is up (as in AE).
let inactive = ui.style().visuals.widgets.inactive;
if open_prev == Some(drawn) {
ui.style_mut().visuals.widgets.inactive = ui.style().visuals.widgets.open;
}
let mut response = ui.add(egui::Button::new(crate::i18n::label(app, "", label)));
ui.style_mut().visuals.widgets.inactive = inactive;
// A press on a title opens its menu at once (a second press on the open one closes
// it), and while it is held the menus below are browsable. The keyboard and the
// accessibility activation report `clicked` without the primary button (the press
// then happened somewhere else), and toggle the menu. Hovering another title
// switches to it once the pointer has moved: merely resting there must not take a
// keyboard-driven menu over.
let moved = ctx.input(|i| i.pointer.delta() != egui::Vec2::ZERO);
// With the button down elsewhere nothing is hovered, so the title under the
// pointer takes the highlight by hand: the hover-switch to it shows, one frame
// later like egui's own hover (the open title keeps its open look).
if moved && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
response = response.highlight();
}
let pressed = ctx.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)) && response.hovered();
let cmd = if pressed {
nav.set_browsing();
Some(egui::SetOpenCommand::Bool(open_prev != Some(drawn)))
} else if response.clicked() && !response.clicked_by(egui::PointerButton::Primary) {
Some(egui::SetOpenCommand::Toggle)
} else if moved && response.enabled() && response.contains_pointer() && open_prev.is_some_and(|p| p != drawn) {
Some(egui::SetOpenCommand::Bool(true))
} else {
None
};
let inner = egui::Popup::menu(&response)
.close_behavior(egui::PopupCloseBehavior::IgnoreClicks)
.style(crate::widgets::menu_style)
.open_memory(cmd)
.show(|ui| {
ui.set_min_width(if label == "Effect" { 200.0 } else { 280.0 });
let browsing = nav.browsing();
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, &mut clicked, &mut nav, 0));
});
nav.top(&response);
// The titles and the open menus: a press inside them is not an outside press.
nav.cover(response.rect);
if let Some(inner) = &inner {
nav.cover(inner.response.rect);
}
app.auto.add(&format!("menu.{label}"), response.rect, label);
drawn += 1;
}
});
});
crate::menu_keys::handle(&ctx, &nav);
// egui leaves the closing to us (`IgnoreClicks`), so the menus close here: at once on a press
// outside them (the press itself still reaches the widget below) and when the window loses
// focus. The pointer leaving the window closes nothing: moving onto the OS title bar fires
// the cursor-left event and the platform forwards none of its clicks to egui, so there is no
// click to close on — and a graze or a stay must not drop the menu, as in native apps.
if let Some(top) = nav.open_popup(&ctx) {
let focus_lost = ctx.input(|i| i.events.iter().any(|e| matches!(e, egui::Event::WindowFocused(false))));
let outside_press = ctx.input(|i| i.pointer.any_pressed() && i.pointer.interact_pos().is_some_and(|p| !nav.covers(p)));
if focus_lost || outside_press {
egui::Popup::close_id(&ctx, top);
}
}
if let Some((id, params)) = clicked.map(|(id, params)| menu_choice(app, id, params))
&& let Err(e) = invoke(app, &ctx, &id, if params.is_null() { json!({}) } else { params })
{
app.ui.status = e;
}
// What the next frame starts from: the open title, and a title press still in progress.
let open = nav.open_index(&ctx);
let browsing = open.is_some() && nav.browsing() && ctx.input(|i| i.pointer.primary_down());
ctx.data_mut(|d| {
d.insert_temp(open_menu_id(), open);
d.insert_temp(browse_menu_id(), browsing);
});
}
/// The entries of top-level menu `name` (the Effect menu is the Effect Controls panel's context
@@ -2114,9 +2201,10 @@ fn menu_nodes(
Some(t) => r.on_hover_text(t),
None => r,
};
let r = browse_hover(nav, r);
app.auto.add(&format!("menu.{name}.{i}"), r.rect, &e.label);
nav.entry(ui, depth, &r, false);
if r.clicked() {
if r.clicked() || browse_pick(nav, ui, &r) {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
@@ -2128,19 +2216,27 @@ fn menu_nodes(
// every other submenu goes through the menu catalog.
let shown = crate::i18n::effect_category(children, label)
.unwrap_or_else(|| crate::i18n::submenu(app, label, effectcraft_engine::menus::submenu_label(&app.session, label, &dyn_ctx(&ws, &[]))));
let r = ui.menu_button((gutter(false), shown.as_str()), |ui| {
let browsing = nav.browsing();
let egui::InnerResponse { inner, response } = ui.menu_button((gutter(false), shown.as_str()), |ui| {
ui.set_min_width(if children.len() > 30 { 200.0 } else { 240.0 });
crate::widgets::menu_scroll(ui, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
crate::widgets::menu_scroll(ui, browsing, |ui| menu_nodes(app, ui, children, clicked, nav, depth + 1));
});
nav.entry(ui, depth, &r.response, true);
if r.inner.is_some() {
nav.opened(depth, &r.response);
let response = browse_hover(nav, response);
nav.entry(ui, depth, &response, true);
if inner.is_some() {
// `ui.menu_button` drops the popup's own response on the floor: read it back,
// so that a press inside this submenu doesn't count as an outside press.
let sub = egui::containers::menu::SubMenu::id_from_widget_id(response.id);
if let Some(resp) = ui.ctx().read_response(sub) {
nav.cover(resp.rect);
}
nav.opened(depth, &response);
}
}
MenuNode::Item(e) => {
let r = menu_entry(app, ui, e);
let r = browse_hover(nav, menu_entry(app, ui, e));
nav.entry(ui, depth, &r, false);
if r.clicked() {
if r.clicked() || browse_pick(nav, ui, &r) {
*clicked = Some((e.command.clone(), e.params.clone()));
ui.close();
}
@@ -2149,6 +2245,23 @@ fn menu_nodes(
}
}
/// The row `r` highlighted while the bar is mid press-and-browse: with a button down egui reports
/// nothing as hovered — which would leave browsing with no hover feedback at all — so the row
/// under the pointer takes the highlight by hand. It reads as the hover look one frame later,
/// exactly egui's own hover latency; disabled rows ignore it (`Noninteractive`), as in native
/// menus. Context menus use their own [`crate::menu_keys::Nav`], where the bar never browses.
fn browse_hover(nav: &crate::menu_keys::Nav, r: egui::Response) -> egui::Response {
if nav.browsing() && r.contains_pointer() { r.highlight() } else { r }
}
/// Whether a press-and-browse (a menu-bar title still held down) releases over entry `r`, which
/// then chooses it like a click does. egui reports neither for it: the release is far from the
/// press, so there is no click, and nothing but the pressed widget is hovered while a button is
/// down, so the row has to be tested by the pointer being over it.
fn browse_pick(nav: &crate::menu_keys::Nav, ui: &egui::Ui, r: &egui::Response) -> bool {
nav.browsing() && r.enabled() && r.contains_pointer() && ui.input(|i| i.pointer.button_released(egui::PointerButton::Primary))
}
/// The menu bar's submenu at `path` (`["File", "Interpret Footage"]`) in a panel's context menu,
/// with the menu bar's labels, shortcuts and enabled state; a chosen entry goes to `clicked`.
pub(crate) fn submenu_at(app: &mut EffectcraftApp, ui: &mut egui::Ui, path: &[&str], clicked: &mut Option<(String, Value)>) {
+4 -4
View File
@@ -2752,7 +2752,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
egui::Popup::menu(&lang).style(crate::widgets::menu_style).show(|ui| {
for (cat, items) in effectcraft_engine::commands::expr_tools::language_menu() {
ui.menu_button(cat, |ui| {
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for (label, text) in items {
if ui.button(label).clicked() {
picked = Some(text);
@@ -4156,7 +4156,7 @@ pub(crate) fn layer_menu(
}
}
ui.menu_button(crate::i18n::tr("Blending Mode"), |ui| {
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for m in BlendMode::ALL {
item(ui, m.label(), "layer.setBlendMode", json!({"layers": layers, "mode": m.label()}));
if m.ends_group() {
@@ -4217,11 +4217,11 @@ fn effects_submenu(ui: &mut egui::Ui, layers: &[u64], item: &mut dyn FnMut(&mut
}
}
cats.sort_by_key(|(c, _)| c.to_ascii_lowercase());
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for (cat, mut fx) in cats {
fx.sort_by_key(|e| e.name.to_ascii_lowercase());
ui.menu_button(crate::i18n::effects::category(cat), |ui| {
widgets::menu_scroll(ui, |ui| {
widgets::menu_scroll(ui, false, |ui| {
for e in fx {
item(ui, e.name, "effect.apply", json!({"effect": e.id, "layers": layers}));
}
+1 -1
View File
@@ -1291,7 +1291,7 @@ 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(crate::i18n::tr("Select"), |ui| {
crate::widgets::menu_scroll(ui, |ui| {
crate::widgets::menu_scroll(ui, false, |ui| {
for (id, name) in &hits {
if ui.button(name).clicked() {
context_actions.push(("layer.select".to_owned(), serde_json::json!({"layers":[id]})));
+71 -2
View File
@@ -607,9 +607,78 @@ pub fn any_menu_open(ctx: &egui::Context) -> bool {
/// 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 {
///
/// While the bar is mid press-and-browse (`browsing`), resting the pointer against the menu's
/// bottom or top edge scrolls it from there — a crawl at first, then a run, as native menus do —
/// so a long list can be walked with no wheel. Without the hold the edges do nothing: the wheel
/// and the scroll bar are how a normally opened menu scrolls.
pub fn menu_scroll<R>(ui: &mut Ui, browsing: bool, 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
let out = egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height(max_h).show(ui, add);
edge_scroll(ui, &out, browsing);
out.inner
}
/// The edge scroll [`menu_scroll`] adds while browsing: with the pointer resting in the bottom
/// (top) band of an overflowing menu, its content crawls up (down) after a short pause, ramps to
/// a run and stops at the end — and stops at once when the pointer leaves the band or the hold.
///
/// This runs after `ScrollArea::show`, which laid the menu out for the offset it had, so the new
/// offset goes into the area's state and shows on the next frame: a frame of lag, invisible at
/// these speeds. egui's own scrolling (wheel, drag, scroll bar) is untouched.
fn edge_scroll<R>(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput<R>, browsing: bool) {
/// How far from the top / bottom edge the pointer starts the crawl (points).
const BAND: f32 = 18.0;
/// The pause before the crawl starts: a pointer merely grazing the edge must not jump.
const DELAY: f32 = 0.12;
/// Points per second: a crawl at first, reaching the run about three quarters of a second in.
const CRAWL: f32 = 45.0;
const RAMP: f32 = 700.0;
const RUN: f32 = 560.0;
let ctx = ui.ctx();
let over = out.content_size.y - out.inner_rect.height();
let dir = match (browsing, over > 0.0, ctx.input(|i| i.pointer.latest_pos())) {
(true, true, Some(p)) if out.inner_rect.contains(p) => {
if p.y >= out.inner_rect.max.y - BAND {
1
} else if p.y <= out.inner_rect.min.y + BAND {
-1
} else {
0
}
}
_ => 0,
};
// The clock the pause counts from, kept per menu: entering or leaving an edge band restarts
// it, so a later stay there crawls off again instead of jumping straight to the run.
let now = ctx.input(|i| i.time);
let key = out.id.with("menu-edge-scroll");
let (since, prev) = ctx.data(|d| d.get_temp::<(f64, i32)>(key)).unwrap_or((now, 0));
let since = if dir == prev {
since
} else {
ctx.data_mut(|d| d.insert_temp(key, (now, dir)));
now
};
if dir == 0 {
return;
}
if now - since < f64::from(DELAY) {
// A pointer resting still fires no events: keep the frames coming until the crawl runs.
ctx.request_repaint();
return;
}
let speed = (CRAWL + RAMP * ((now - since) as f32 - DELAY)).min(RUN);
let crawl = ctx.input(|i| i.unstable_dt).min(0.05) * speed * dir as f32;
let new = (out.state.offset.y + crawl).clamp(0.0, over);
if new != out.state.offset.y {
let mut state = out.state;
state.offset.y = new;
state.store(ctx, out.id);
// At the end of the list the crawl stands still and asks for no more frames.
ctx.request_repaint();
}
}
/// egui's menu style with no gap between the rows: the gap the app's item spacing left between
+128
View File
@@ -29,6 +29,25 @@ fn click_at(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.run_steps(2);
}
/// Press the primary button at `p` and keep it down.
fn press(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.run_steps(2);
}
/// Move the pointer to `p` (press or no press, whichever is held).
fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(2);
}
/// Release the primary button at `p`.
fn release(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Press and release `key` (one frame each), then let the UI settle.
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE });
@@ -244,3 +263,112 @@ fn space_previews_after_opening_the_demo_from_home() {
key(&mut h, Key::Space);
assert!(h.state().playback.playing, "Space previews the demo (#1059)");
}
/// The titles open on the press, not the release, so the menus can be browsed with the button
/// still held down: moving onto an entry and releasing there chooses it (as in After Effects).
#[test]
fn a_menu_opens_on_press_and_browsing_releases_choose_an_entry() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
press(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu opens on the press, before any release");
let item = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
move_to(&mut h, item.center());
release(&mut h, item.center());
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the release over the entry chose it");
assert!(!shown(&h, "Quick Apply"), "and closed the menu");
}
/// While a menu is open, hovering another title switches to that menu (native menu bars).
#[test]
fn hovering_another_title_while_a_menu_is_open_switches_to_it() {
let mut h = harness();
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
assert!(shown(&h, "Keyboard Shortcuts"), "the Edit menu is open");
let file = rect(&h, "menu.File");
move_to(&mut h, file.center());
assert!(shown(&h, "Open Project"), "hovering File opened the File menu");
assert!(!shown(&h, "Keyboard Shortcuts"), "and closed Edit");
}
/// The press on the title of the open menu closes it again, and the click's release — a plain
/// click on the title — leaves it closed.
#[test]
fn pressing_the_open_title_again_closes_its_menu() {
let mut h = harness();
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"), "the File menu is open");
click_at(&mut h, file.center());
assert!(!shown(&h, "Open Project"), "the second press closed it");
}
/// Press-and-hold browse (AE's gesture) reaches into a submenu: while the button is still held,
/// moving onto the submenu row opens it, and the release inside the submenu picks the entry.
#[test]
fn hold_browse_opens_a_submenu_and_the_release_inside_picks_an_entry() {
let mut h = harness();
// Applying an effect needs a selected layer, as in the app after clicking one in the timeline.
let first = {
let cid = h.state().session.active_comp_id().expect("the demo opens a comp");
let comp = h.state().session.project.comp(cid).expect("the demo comp exists");
comp.layers.iter().find(|l| l.effects().is_some()).expect("the demo comp has a layer that takes effects").id
};
h.state_mut().session.state.selected_layers = vec![first];
let effect = rect(&h, "menu.Effect");
press(&mut h, effect.center());
assert!(shown(&h, "Effect Controls"), "the Effect menu opens on the press");
// The categories come from the effect registry; the first alphabetically is "3D Channel".
let cat = h.query_by_label_contains("3D Channel").expect("the category row is shown").rect();
move_to(&mut h, cat.center());
assert!(shown(&h, "Cryptomatte"), "moving onto a category while still holding opened its submenu");
let entry = h.query_by_label_contains("Cryptomatte").expect("the submenu is open").rect();
move_to(&mut h, entry.center());
release(&mut h, entry.center());
assert!(!shown(&h, "Effect Controls"), "the release closed the menus");
assert!(h.state().session.state.last_effect.is_some(), "and the release chose the entry: it applied its effect");
}
/// The open menus close on a press outside them and when the window loses focus. The pointer
/// leaving the window — onto the OS title bar, whose clicks are never forwarded to egui — closes
/// nothing, however long it stays away: there is no click to close on, and a graze or a stay
/// must not drop the menu. The closing press itself is not swallowed: it reaches the widget under
/// it, as a click does in After Effects.
#[test]
fn outside_presses_and_focus_loss_close_the_menus() {
let mut h = harness();
let file = rect(&h, "menu.File");
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
// A press in the empty part of the bar, outside every title and menu.
click_at(&mut h, pos2(1100.0, 12.0));
assert!(!shown(&h, "Open Project"), "a press outside the menus closes them");
// The press reaches the header's workspace tab behind the open menu: the menu closes and the
// tab still acts (the menus don't swallow input).
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
let tab = rect(&h, "header.workspace.Review");
click_at(&mut h, tab.center());
assert_eq!(h.state().ui.workspace, "Review", "the press reached the tab behind the open menu");
assert!(!shown(&h, "Open Project"), "and closed the menu");
// Moving onto the OS title bar fires the cursor-left event — its clicks are never forwarded
// to egui — which egui reports as `PointerGone`. It must not close the menus, however long
// the pointer stays away.
click_at(&mut h, file.center());
assert!(shown(&h, "Open Project"));
h.input_mut().events.push(Event::PointerGone);
h.run_steps(1);
assert!(shown(&h, "Open Project"), "leaving the window alone doesn't drop the menus at once");
let now = h.ctx.input(|i| i.time);
h.input_mut().time = Some(now + 60.0);
h.run_steps(2);
assert!(shown(&h, "Open Project"), "staying away doesn't close them either");
h.input_mut().events.push(Event::PointerMoved(file.center()));
h.run_steps(2);
assert!(shown(&h, "Open Project"), "the pointer coming back keeps them open too");
// Losing focus (alt-tabbing away, or clicking another window).
h.input_mut().events.push(Event::WindowFocused(false));
h.run_steps(2);
assert!(!shown(&h, "Open Project"), "losing focus closes the menus");
}
+73
View File
@@ -40,6 +40,33 @@ fn wheel(h: &mut Harness<'_, EffectcraftApp>, at: Pos2, dy: f32) {
h.run_steps(20);
}
/// Press the primary button at `p` and keep it down (press-and-browse, as on a menu title).
fn hold(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.run_steps(2);
}
/// Move the pointer to `p`, keeping whatever button is held.
fn move_to(h: &mut Harness<'_, EffectcraftApp>, p: Pos2) {
h.input_mut().events.push(Event::PointerMoved(p));
h.run_steps(2);
}
/// The open menu popup's rect (frame included), from egui's area memory: the only tall area up.
fn open_popup(h: &Harness<'_, EffectcraftApp>) -> Rect {
h.ctx
.memory(|m| {
m.areas()
.visible_layer_ids()
.into_iter()
.filter(|l| l.order != egui::Order::Background)
.filter_map(|l| m.area_rect(l.id))
.find(|r| r.height() > 100.0)
})
.unwrap_or_else(|| panic!("no menu popup is open"))
}
/// 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).
@@ -59,6 +86,52 @@ fn a_menu_taller_than_the_window_scrolls() {
assert_eq!(h.state().dialog, Some(effectcraft_ui_egui::Dialog::Shortcuts), "the entry takes the click");
}
/// With the title still held down (press-and-browse), resting the pointer against the bottom
/// edge of a menu too tall for the window scrolls it from there — a crawl, then a run, as native
/// menus do — so the long list can be walked with no wheel.
#[test]
fn holding_the_title_scrolls_the_menu_from_its_bottom_edge() {
let mut h = harness(1200.0, 520.0);
let edit = rect(&h, "menu.Edit");
hold(&mut h, edit.center());
let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu opens on the press").rect();
assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})");
let menu = open_popup(&h);
// Creeping down the edge: the 18 pt band above the menu's inner bottom lies at one of these
// positions, whatever the frame's margin (they are 6 pt apart).
let mut walked = false;
for step in 0..5 {
move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32));
h.run_steps(8);
let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
if now.min.y < before.min.y {
walked = true;
break;
}
}
assert!(walked, "resting on the bottom edge of the held-down menu scrolled its entries up");
}
/// Without the title held down — the menu opened by a plain click — the same edge rest scrolls
/// nothing: the wheel (above) is how a click-opened menu scrolls, the pointer alone must not
/// move it (a hover may align the row under it by a few points; a crawl there would be a bug).
#[test]
fn without_the_hold_the_edge_does_not_scroll_the_menu() {
let mut h = harness(1200.0, 520.0);
let edit = rect(&h, "menu.Edit");
click_at(&mut h, edit.center());
let before = h.query_by_label_contains("Keyboard Shortcuts").expect("the Edit menu is open").rect();
assert!(before.max.y > 520.0, "Edit is taller than the window ({before:?})");
let menu = open_popup(&h);
for step in 0..5 {
move_to(&mut h, pos2(menu.center().x, menu.max.y - 6.0 - 6.0 * step as f32));
h.run_steps(8);
let now = h.query_by_label_contains("Keyboard Shortcuts").expect("still open").rect();
let crawled = before.min.y - now.min.y;
assert!(crawled < 60.0, "step {step}: the edge scrolled the menu with no title held down ({before:?} -> {now:?})");
}
}
fn drag(h: &mut Harness<'_, EffectcraftApp>, from: Pos2, to: Pos2) {
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });