diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index dbdd32e3..5be1e80a 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -2048,7 +2048,8 @@ pub fn menu_bar(app: &mut EffectcraftApp, ui: &mut egui::Ui) { .open_memory(cmd) .show(|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)); + 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. @@ -2155,7 +2156,7 @@ fn menu_nodes( 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)); }); let response = browse_hover(nav, response); nav.entry(ui, depth, &response, true); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index 2de97a4b..d9c5b207 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -2605,7 +2605,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); @@ -3926,7 +3926,7 @@ pub(crate) fn layer_menu(ui: &mut egui::Ui, layers: &[u64], caps: LayerMenuCaps, item(ui, "Add Marker", "layer.addMarker", json!({"layers": layers})); }); 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() { @@ -3960,11 +3960,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(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})); } diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 1064854f..2eee93dd 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -1251,7 +1251,7 @@ 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(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]}))); diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index c005224d..7aee3d0b 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -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(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(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(ui: &Ui, out: &egui::containers::scroll_area::ScrollAreaOutput, 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 diff --git a/crates/ui-egui/tests/ui_scrolling.rs b/crates/ui-egui/tests/ui_scrolling.rs index 5f681623..a0bc1fe6 100644 --- a/crates/ui-egui/tests/ui_scrolling.rs +++ b/crates/ui-egui/tests/ui_scrolling.rs @@ -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() });