diff --git a/crates/ui-egui/src/menu_nav.rs b/crates/ui-egui/src/menu_nav.rs index b7b980d1..51842c35 100644 --- a/crates/ui-egui/src/menu_nav.rs +++ b/crates/ui-egui/src/menu_nav.rs @@ -7,9 +7,14 @@ //! //! The highlight is view state only (egui memory), per top-level menu; it follows the pointer too, //! so the mouse and the keyboard share one highlighted row like a native menu. +//! +//! An open submenu stays open while the pointer heads for it (#1218), as in macOS and Photoshop +//! menus: crossing the rows between its row and the submenu, on a diagonal, a curved path or with +//! a pause on the way, neither closes it nor opens the submenu of a row crossed. The pointer +//! resting on another row, or moving away from the submenu, switches as before. use egui::containers::menu::{MenuState, SubMenu, find_menu_root}; -use egui::{Id, Key, Modifiers, Rect, Sense, Ui, pos2, vec2}; +use egui::{Id, Key, Modifiers, Pos2, Rect, Sense, Ui, pos2, vec2}; /// Height of a scroll-arrow strip. pub const ARROW: f32 = 16.0; @@ -17,6 +22,23 @@ pub const ARROW: f32 = 16.0; const EDGE: f32 = 6.0; /// How fast resting on a scroll arrow scrolls, in points per second. const SPEED: f32 = 600.0; +/// How far above and below an open submenu the pointer may aim and still be heading for it. +const AIM_SLACK: f32 = 24.0; +/// How long the pointer may rest on another row, heading for an open submenu, before that row +/// takes over (macOS waits about as long). +const AIM_REST: f32 = 0.3; + +/// The pointer heading for the open submenu of a row (#1218). +#[derive(Clone, Debug)] +struct Aim { + /// The row whose submenu is open, and its index in its level. + row: Id, + index: usize, + /// Where the pointer last was on that row. + from: Pos2, + /// The pointer was off the row with its submenu still open in the last frame. + held: bool, +} /// One actionable row of a menu level: a command or a submenu. #[derive(Clone, Debug)] @@ -59,6 +81,11 @@ pub struct Nav { /// Highlight the first enabled row of this level once it has been drawn (a keyboard-opened /// submenu). first_at: Option, + /// Per level: the pointer heading for the open submenu of one of its rows. + aims: Vec>, + /// A held submenu of this level let go: the row under the pointer takes the highlight even + /// if the pointer rests. + follow: Option, } fn nav_id() -> Id { @@ -117,28 +144,100 @@ impl Nav { } /// Draw a row of `level` with `add`, highlighted like a hovered row when the keyboard (or the - /// pointer) is on it, and register it for the keys. `add` returns the row's response. + /// pointer) is on it, and register it for the keys. `add` returns the row's response. A row + /// without a `command` opens a submenu. pub fn row(&mut self, ui: &mut Ui, level: usize, enabled: bool, command: Option<&str>, add: impl FnOnce(&mut Ui, &mut Nav) -> (egui::Response, R)) -> R { let i = self.rows.get(level).map_or(usize::MAX, Vec::len); - let lit = enabled && self.highlighted(level) == Some(i); - let saved = ui.visuals().widgets.inactive; + // While a submenu is kept open for the pointer heading to it, its row stays lit and the + // rows crossed on the way don't light up. + let held = self.aims.get(level).and_then(Option::as_ref).filter(|a| a.held).map(|a| a.index); + let lit = enabled && (held == Some(i) || held.is_none() && self.highlighted(level) == Some(i)); + let saved = (ui.visuals().widgets.inactive, ui.visuals().widgets.hovered); if lit { ui.visuals_mut().widgets.inactive = ui.visuals().widgets.hovered; + } else if held.is_some() { + ui.visuals_mut().widgets.hovered = ui.visuals().widgets.inactive; } + let root = self.roots.get(level).copied().filter(|_| command.is_none()); + let open = root.and_then(|root| MenuState::from_id(ui.ctx(), root, |s| s.open_item)); let (r, out) = add(ui, self); - ui.visuals_mut().widgets.inactive = saved; + (ui.visuals_mut().widgets.inactive, ui.visuals_mut().widgets.hovered) = saved; + if let Some(root) = root { + self.aim(level, i, root, open, &r); + } if let Some(rows) = self.rows.get_mut(level) { rows.push(Row { widget: r.id, enabled, command: command.map(str::to_string), rect: r.rect }); - self.after_row(level, i, lit, &r); + if let Some(index) = held { + self.set_hi(level, Some(index)); + } else { + self.after_row(level, i, lit, &r); + } } out } + /// After a submenu row is drawn: if egui just closed its submenu because the pointer is over + /// another row of this menu, keep it open while the pointer heads for it (#1218). `open` was + /// the menu's open submenu before the row was drawn. + fn aim(&mut self, level: usize, index: usize, root: Id, open: Option, r: &egui::Response) { + if self.aims.len() <= level { + self.aims.resize(level + 1, None); + } + let Some(p) = r.ctx.pointer_hover_pos() else { return }; + let was_held = self.aims.get(level).and_then(Option::as_ref).is_some_and(|a| a.row == r.id && a.held); + let held = self.hold(level, index, root, open, p, r); + if was_held && !held { + // Let go: the row under the pointer takes the highlight, moving or not. + self.follow = Some(level); + } + } + + /// [`Self::aim`]: is this row's submenu open with the pointer off the row (held) now? + fn hold(&mut self, level: usize, index: usize, root: Id, open: Option, p: Pos2, r: &egui::Response) -> bool { + let ctx = &r.ctx; + let sub = SubMenu::id_from_widget_id(r.id); + let target = self.views.get(level + 1).copied(); + let Some(slot) = self.aims.get_mut(level) else { return false }; + if open != Some(sub) { + if slot.as_ref().is_some_and(|a| a.row == r.id) { + *slot = None; + } + return false; + } + if r.interact_rect.contains(p) { + *slot = Some(Aim { row: r.id, index, from: p, held: false }); + return false; + } + let Some(aim) = slot.as_mut().filter(|a| a.row == r.id) else { return false }; + // Off its row with the submenu still open (egui keeps it while the pointer moves straight + // at it): the row stays lit. + aim.held = true; + if MenuState::from_id(ctx, root, |s| s.open_item) == Some(sub) { + return true; + } + aim.held = false; + // A click or a key closes menus as usual; so does leaving the submenu's direction, or + // resting on another row. + let (acted, rest) = ctx.input(|i| (i.pointer.any_pressed() || i.pointer.any_click() || !i.keys_down.is_empty(), i.pointer.time_since_last_movement())); + let Some(target) = target else { return false }; + if acted || rest >= AIM_REST || !heading(aim.from, p, target) { + return false; + } + MenuState::from_id(ctx, root, |s| s.open_item = Some(sub)); + aim.held = true; + ctx.request_repaint_after_secs(AIM_REST - rest); + true + } + /// After a row is drawn: the pointer moving onto it highlights it; a keyboard move scrolls /// the highlighted row into view. fn after_row(&mut self, level: usize, i: usize, lit: bool, r: &egui::Response) { let moved = r.ctx.input(|inp| inp.pointer.delta() != egui::Vec2::ZERO); - if moved && r.hovered() && !lit { + let follow = self.follow == Some(level); + if (moved || follow) && r.hovered() && !lit { + if follow { + self.follow = None; + } self.set_hi(level, Some(i)); self.level = level; r.ctx.request_repaint(); @@ -172,6 +271,10 @@ impl Nav { self.hi.truncate(level + 1); self.level = level; self.reveal = true; + // The keys move the highlight now, not the pointer heading for a submenu. + if let Some(aim) = self.aims.get_mut(level) { + *aim = None; + } } /// Open the highlighted submenu of `level` and move the keyboard into it. @@ -260,6 +363,22 @@ impl Nav { } } +/// Is the pointer at `p`, having left a submenu's row at `from`, heading for the submenu at +/// `target`? It is while it stays inside the triangle from `from` to the submenu's near edge +/// (with [`AIM_SLACK`] above and below), as macOS menus judge it. +fn heading(from: Pos2, p: Pos2, target: Rect) -> bool { + if !target.is_positive() { + return false; + } + let x = if target.center().x >= from.x { target.left() } else { target.right() }; + let (a, b) = (pos2(x, target.top() - AIM_SLACK), pos2(x, target.bottom() + AIM_SLACK)); + let side = |u: Pos2, v: Pos2| (v.x - u.x) * (p.y - u.y) - (v.y - u.y) * (p.x - u.x); + let (d1, d2, d3) = (side(from, a), side(a, b), side(b, from)); + let neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(neg && pos) +} + /// Is the pointer over `rect` on this ui's layer (not under a submenu drawn on top)? fn pointer_on(ui: &Ui, rect: Rect) -> bool { ui.ctx().pointer_hover_pos().is_some_and(|p| rect.contains(p) && ui.ctx().layer_id_at(p) == Some(ui.layer_id())) diff --git a/crates/ui-egui/src/menu_nav_tests.rs b/crates/ui-egui/src/menu_nav_tests.rs index bfa504ff..fa610c3c 100644 --- a/crates/ui-egui/src/menu_nav_tests.rs +++ b/crates/ui-egui/src/menu_nav_tests.rs @@ -590,3 +590,159 @@ fn press_drag_release_reaches_other_menus_and_submenus() { assert_eq!(proofing, Some(true), "releasing on Working CMYK ran it"); assert!(Nav::current(&h.ctx).rows.first().is_none_or(|r| r.is_empty()), "and closed the menus"); } + +/// A menu bar whose frames are `dt` seconds apart, as with a real pointer sending moves. +fn harness_dt(dt: f32) -> Harness<'static, PhotocraftApp> { + let mut h = Harness::builder().with_size(egui::vec2(1280.0, 720.0)).with_step_dt(dt).build_ui_state( + |ui, app| { + crate::menus::menu_bar(app, ui); + }, + app(), + ); + PhotocraftApp::setup_context(&h.ctx, crate::theme::ThemeKind::ALL[0]); + h.ctx.all_styles_mut(|s| s.scroll_animation = egui::style::ScrollAnimation::none()); + h.run_steps(3); + h +} + +/// Which of Image › Mode and Image › Adjustments has its submenu open. +fn image_submenu(h: &Harness<'static, PhotocraftApp>) -> &'static str { + match (h.query_by_label_contains("16 Bits/Channel").is_some(), h.query_by_label_contains("Brightness/Contrast").is_some()) { + (true, false) => "Mode", + (false, true) => "Adjustments", + (false, false) => "none", + (true, true) => "both", + } +} + +/// Open Image › Mode with the pointer resting on it, at 120 frames a second; returns where the +/// pointer is, the Adjustments row below it, and Mode's 16 Bits/Channel row. +fn rest_on_mode(h: &mut Harness<'static, PhotocraftApp>) -> (egui::Pos2, egui::Rect, egui::Rect) { + open(h, "Image"); + let mode = h.get_by_label_contains("Mode").rect(); + let from = egui::pos2(mode.right() - 40.0, mode.center().y); + for k in 0..=12 { + h.event(egui::Event::PointerMoved(from - egui::vec2(30.0 * (1.0 - k as f32 / 12.0), 0.0))); + h.run_steps(1); + } + h.run_steps(30); + assert_eq!(image_submenu(h), "Mode", "resting on Mode opens it"); + (from, h.get_by_label_contains("Adjustments").rect(), h.get_by_label_contains("16 Bits/Channel").rect()) +} + +/// Move the pointer along `path` (0…1) in `frames` frames; `pause` holds it still for that many +/// frames at that fraction of the way. +fn glide(h: &mut Harness<'static, PhotocraftApp>, frames: usize, pause: Option<(f32, usize)>, mut path: impl FnMut(f32) -> egui::Pos2) { + let mut paused = false; + for k in 0..=frames { + let t = k as f32 / frames as f32; + h.event(egui::Event::PointerMoved(path(t))); + h.run_steps(1); + if let Some((_, n)) = pause.filter(|(at, _)| !paused && t >= *at) { + paused = true; + h.run_steps(n); + } + } +} + +/// A person's path from `a` to `b`: it bows, down first and then across, by `bow`. +fn bowed(a: egui::Pos2, b: egui::Pos2, bow: f32) -> impl Fn(f32) -> egui::Pos2 { + let c = egui::pos2(a.x + (b.x - a.x) * (0.5 - bow), a.y + (b.y - a.y) * (0.5 + bow)); + move |t| { + let u = 1.0 - t; + egui::pos2(u * u * a.x + 2.0 * u * t * c.x + t * t * b.x, u * u * a.y + 2.0 * u * t * c.y + t * t * b.y) + } +} + +/// #1218: heading from Image › Mode for a row low in its submenu, the pointer crosses the +/// Adjustments row. On a diagonal, a bowed path or with a short pause on the way, Mode's submenu +/// stays open and Adjustments' doesn't open; the crossed row isn't highlighted. +#[test] +fn a_submenu_stays_open_while_the_pointer_heads_for_it() { + struct Path { + name: &'static str, + /// Frames at 120 a second. + frames: usize, + /// Hold still this many frames at this fraction of the way. + pause: Option<(f32, usize)>, + bow: f32, + } + let paths = [ + Path { name: "straight", frames: 36, pause: None, bow: 0.0 }, + Path { name: "hesitate", frames: 36, pause: Some((0.3, 18)), bow: 0.0 }, + Path { name: "bowed", frames: 42, pause: None, bow: 0.2 }, + Path { name: "slow and bowed", frames: 96, pause: None, bow: 0.2 }, + ]; + let mut wrong = Vec::new(); + for Path { name, frames, pause, bow } in paths { + let mut h = harness_dt(1.0 / 120.0); + let (from, adjustments, target) = rest_on_mode(&mut h); + let to = egui::pos2(target.left() + 40.0, target.center().y); + let path = bowed(from, to, bow); + let mode_row = rows(&h, 0).iter().position(|r| r.command.is_none() && r.rect.contains(from)).expect("the Mode row"); + let mut crossed = false; + glide(&mut h, frames, pause, |t| { + let p = path(t); + crossed |= adjustments.contains(p); + p + }); + assert!(crossed, "{name}: the path crosses the Adjustments row"); + // Once Mode's submenu closes on the way, the pointer arrives over Adjustments' instead. + let open = image_submenu(&h); + if open != "Mode" { + wrong.push(format!("{name}: arrived with {open} open, not Mode")); + } + if let Some(row) = Nav::current(&h.ctx).highlighted(0).filter(|&row| row != mode_row) { + wrong.push(format!("{name}: row {row} is highlighted, not Mode")); + } + } + assert!(wrong.is_empty(), "{wrong:#?}"); +} + +/// Resting on the row crossed, or moving away from the submenu onto it, still opens its submenu, +/// and the highlight moves to it (only it is lit). +#[test] +fn resting_on_another_row_or_moving_away_switches_submenus() { + let only_adjustments_lit = |h: &Harness<'static, PhotocraftApp>, adjustments: egui::Rect, what: &str| { + let row = rows(h, 0).iter().position(|r| r.rect.contains(adjustments.center())); + assert_eq!(Nav::current(&h.ctx).highlighted(0), row, "{what}: Adjustments is the highlighted row, not Mode"); + }; + let mut h = harness_dt(1.0 / 120.0); + let (from, adjustments, _) = rest_on_mode(&mut h); + // Head for the submenu, but stop on Adjustments on the way. + let stop = egui::pos2(from.x + 15.0, adjustments.center().y); + glide(&mut h, 12, None, |t| from + (stop - from) * t); + assert_eq!(image_submenu(&h), "Mode", "just arrived on Adjustments, still heading for Mode's submenu"); + h.run_steps((super::AIM_REST * 120.0) as usize + 12); + assert_eq!(image_submenu(&h), "Adjustments", "resting on Adjustments opens it"); + only_adjustments_lit(&h, adjustments, "resting"); + + // Moving away from Mode's submenu switches (as soon as egui does): straight down, or down and + // to the left. + for dx in [0.0, -60.0] { + let mut h = harness_dt(1.0 / 120.0); + let (from, adjustments, _) = rest_on_mode(&mut h); + let to = egui::pos2(from.x + dx, adjustments.center().y); + glide(&mut h, 10, None, |t| from + (to - from) * t); + h.run_steps(30); + assert_eq!(image_submenu(&h), "Adjustments", "moving away ({dx}) from Mode's submenu opens Adjustments'"); + only_adjustments_lit(&h, adjustments, "moving away"); + } +} + +#[test] +fn heading_is_the_triangle_to_the_submenus_near_edge() { + let sub = egui::Rect::from_min_max(egui::pos2(200.0, 100.0), egui::pos2(400.0, 400.0)); + let from = egui::pos2(150.0, 110.0); + assert!(heading(from, from, sub)); + assert!(heading(from, egui::pos2(170.0, 160.0), sub), "down and right, toward the submenu"); + assert!(heading(from, egui::pos2(190.0, 105.0), sub), "straight across"); + assert!(!heading(from, egui::pos2(150.0, 160.0), sub), "straight down"); + assert!(!heading(from, egui::pos2(120.0, 140.0), sub), "away to the left"); + assert!(!heading(from, egui::pos2(170.0, 60.0), sub), "up past the submenu's top"); + // A submenu that opened to the left. + let left = egui::Rect::from_min_max(egui::pos2(0.0, 100.0), egui::pos2(100.0, 400.0)); + assert!(heading(egui::pos2(150.0, 110.0), egui::pos2(130.0, 160.0), left)); + assert!(!heading(egui::pos2(150.0, 110.0), egui::pos2(170.0, 160.0), left)); + assert!(!heading(from, from, egui::Rect::NOTHING)); +}