Menus: a submenu stays open while the pointer heads for it (#1218) (#3251)

In the in-window menu bar (Windows, Linux, --in-window-menus), moving from a
submenu's row toward one of its lower items crosses the rows below. egui keeps
the submenu open only while the pointer moves in a straight line at it: a
bowed path or a pause on the way closed it, and the crossed rows lit up as if
chosen.

As in macOS and Photoshop menus, the submenu now stays open while the pointer
is inside the triangle from where it left the row to the submenu's near edge,
until it rests on another row for 0.3 s or moves away. Its row stays lit and
the crossed rows don't light up; when the submenu lets go, the row under the
pointer takes the highlight. Clicks, keys and leaving the triangle behave as
before.

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Brennan Stehling 2026-10-11 09:34:12 -07:00 committed by GitHub
parent da5abd10f9
commit 2b19987bf1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 282 additions and 7 deletions

View File

@ -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<usize>,
/// Per level: the pointer heading for the open submenu of one of its rows.
aims: Vec<Option<Aim>>,
/// A held submenu of this level let go: the row under the pointer takes the highlight even
/// if the pointer rests.
follow: Option<usize>,
}
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<R>(&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<Id>, 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<Id>, 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()))

View File

@ -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));
}