Menus: hover intent for submenus; panels occlude; flip left
CI / platforms (windows-latest) (push) Failing after 9s
CI / site (push) Successful in 22s
CI / crate (push) Failing after 11m21s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s

Panels occlude the rows under them. A submenu without room on the
right opens on the left of its row. A submenu opens after the pointer
rests 150 ms; a row crossed toward an open submenu waits 300 ms.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-15 02:25:43 -04:00
1 parent f1771b3880
commit d4fdb694ef
7 files changed
+375 -49

No files matched your search

+120
View File
@@ -0,0 +1,120 @@
//! Hover intent: open after a pause, hold while aiming at a submenu.
use std::time::Duration;
use gpui::{App, Bounds, Entity, Pixels, Point};
use super::{
menu::{Open, mark},
model::Menu,
};
/// A submenu row opens after the pointer rests this long.
pub(super) const OPEN: Duration = Duration::from_millis(150);
/// A row crossed toward an open submenu waits this long.
pub(super) const HOLD: Duration = Duration::from_millis(300);
/// The pointer moved over row `ix` at `depth`, a submenu row when `sub`.
pub(super) fn hover(
state: &Entity<Open>,
menu: &Menu,
(depth, ix): (usize, usize),
sub: bool,
at: Point<Pixels>,
cx: &mut App,
) {
let seen = state.update(cx, |open, _| {
let from = open.pointer.replace(at);
open.over = Some((depth, ix));
let level = open.levels.get(depth)?;
let shown = (open.levels.get(depth + 1)).map(|level| level.panel);
Some((from, level.at, shown))
});
let Some((from, marked, shown)) = seen else {
return;
};
if marked == Some(ix) {
if !sub || shown.is_some() {
state.update(cx, |open, _| open.pending = None);
} else if state.read(cx).pending.is_none() {
later(state, menu, (depth, ix), OPEN, cx);
}
return;
}
let aimed = from
.zip(shown.filter(|panel| *panel != Bounds::default()))
.is_some_and(|(from, panel)| toward(from, at, panel));
if aimed {
log::debug!("menu: row {ix} of level {depth} waits on the aim");
return later(state, menu, (depth, ix), HOLD, cx);
}
state.update(cx, |open, _| open.pending = None);
mark(state, menu, depth, (ix, 0), false, cx);
if sub {
later(state, menu, (depth, ix), OPEN, cx);
}
}
/// Marks row `ix` after `wait`, its submenu open, if still under the pointer.
fn later(
state: &Entity<Open>,
menu: &Menu,
(depth, ix): (usize, usize),
wait: Duration,
cx: &mut App,
) {
let (state_, menu) = (state.clone(), menu.clone());
let task = cx.spawn(async move |cx| {
cx.background_executor().timer(wait).await;
cx.update(|cx| {
if state_.read(cx).over != Some((depth, ix)) {
return log::debug!("menu: the pointer left row {ix} before it opened");
}
state_.update(cx, |open, _| open.pending = None);
mark(&state_, &menu, depth, (ix, 0), true, cx);
});
});
state.update(cx, |open, _| open.pending = Some(task));
}
/// Whether `to` lies in the triangle from `from` to `panel`'s near edge.
pub(super) fn toward(from: Point<Pixels>, to: Point<Pixels>, panel: Bounds<Pixels>) -> bool {
let near = if panel.left() >= from.x {
panel.left()
} else {
panel.right()
};
let (top, bottom) = (
Point::new(near, panel.top()),
Point::new(near, panel.bottom()),
);
let side = |a: Point<Pixels>, b: Point<Pixels>| {
f32::from(b.x - a.x) * f32::from(to.y - a.y) - f32::from(b.y - a.y) * f32::from(to.x - a.x)
};
let sides = [side(from, top), side(top, bottom), side(bottom, from)];
sides.iter().all(|side| *side >= 0.0) || sides.iter().all(|side| *side <= 0.0)
}
#[cfg(test)]
mod tests {
use gpui::{Bounds, point, px, size};
use super::toward;
#[test]
fn the_aim_holds_toward_the_panel_on_either_side() {
let right = Bounds::new(point(px(200.), px(0.)), size(px(100.), px(200.)));
let from = point(px(100.), px(100.));
assert!(toward(from, point(px(110.), px(98.)), right));
assert!(
!toward(from, point(px(100.), px(130.)), right),
"straight down"
);
let left = Bounds::new(point(px(0.), px(0.)), size(px(50.), px(200.)));
assert!(toward(from, point(px(90.), px(102.)), left));
assert!(
!toward(from, point(px(110.), px(100.)), left),
"away from it"
);
}
}
+43 -13
View File
@@ -1,13 +1,14 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Role, SharedString, Stateful, Styled, accesskit::Action, div,
prelude::*,
AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Pixels, Role, SharedString, Stateful, Styled, accesskit::Action,
canvas, div, prelude::*,
};
use super::{
menu::{Open, at_depth, choose, mark, measure},
aim,
menu::{Open, at_depth, choose, mark},
model::{Entry, Kind, Menu, MenuItem},
};
use crate::{
@@ -195,6 +196,9 @@ pub(super) fn panel(
let drawn = button(item, (ix, column), current, press, cx);
drawn.when(!item.disabled, |button| {
button.on_mouse_move(move |_, _, cx| {
hover.update(cx, |open, _| {
(open.over, open.pending) = (Some((depth, ix)), None);
});
let open = hover.read(cx);
let marked = open.levels.get(depth).map(|l| (l.at, l.column));
if marked != Some((Some(ix), column)) {
@@ -210,17 +214,12 @@ pub(super) fn panel(
let (hover, click, close, menu) =
(state.clone(), state.clone(), close.clone(), menu.clone());
let hover_menu = menu.clone();
let label = item.label.clone();
row(item, ix, marked, cx)
.debug_selector(move || format!("menu-row-{label}"))
.when(!item.disabled, |row| {
row.on_mouse_move(move |_, _, cx| {
let open = hover.read(cx);
let Some(level) = open.levels.get(depth) else {
return;
};
let shown = open.levels.len() > depth + 1;
if level.at != Some(ix) || (sub && !shown) {
mark(&hover, &hover_menu, depth, (ix, 0), true, cx);
}
row.on_mouse_move(move |event, _, cx| {
aim::hover(&hover, &hover_menu, (depth, ix), sub, event.position, cx)
})
.on_mouse_down(
MouseButton::Left,
@@ -242,9 +241,20 @@ pub(super) fn panel(
.into_any_element()
}
});
let left = state.clone();
surface((id.clone(), format!("panel-{depth}")), cx)
.role(Role::Menu)
.debug_selector(move || format!("menu-panel-{depth}"))
.relative()
// Rows under this panel take no hover.
.occlude()
.on_mouse_move(move |_, _, cx| {
left.update(cx, |open, _| {
if open.over.is_some_and(|(over, _)| over != depth) {
open.over = None;
}
})
})
// Presses here are the menu's, not its host's.
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
@@ -263,3 +273,23 @@ pub(super) fn panel(
changed
}))
}
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
pub(super) fn measure(
state: Entity<Open>,
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
) -> impl IntoElement {
canvas(
move |bounds, window, cx| {
if state.update(cx, |open, _| write(open, bounds)) {
state.update(cx, |_, cx| cx.notify());
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
+41 -34
View File
@@ -1,13 +1,13 @@
use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement,
IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Window, anchored, canvas, div,
prelude::*,
Anchor, Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity,
InteractiveElement, IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Task,
Window, anchored, div, prelude::*,
};
use super::{
draw,
draw::{self, measure},
model::{Kind, Menu},
};
use crate::{
@@ -80,6 +80,12 @@ pub(crate) struct Open {
host: Bounds<Pixels>,
anchor: Bounds<Pixels>,
takeover: Option<Entity<Takeover>>,
/// The pointer's last place over a row.
pub(super) pointer: Option<Point<Pixels>>,
/// The row under the pointer: depth and index.
pub(super) over: Option<(usize, usize)>,
/// A delayed mark; dropping it cancels.
pub(super) pending: Option<Task<()>>,
}
impl Open {
@@ -121,6 +127,7 @@ impl Open {
let takeover = state.update(cx, |open, cx| {
open.spot = None;
open.levels.clear();
(open.pointer, open.over, open.pending) = (None, None, None);
cx.notify();
open.takeover.take()
});
@@ -204,6 +211,7 @@ pub(super) fn choose(
if item.disabled {
return;
}
state.update(cx, |open, _| open.pending = None);
if let Kind::Sub(sub) = &item.kind {
let first = first_of(sub, sub.step(None, 1));
mark(state, menu, depth, (at, column), false, cx);
@@ -236,6 +244,7 @@ fn keys(
let Some(here) = at_depth(menu, &levels, depth) else {
return false;
};
state.update(cx, |open, _| open.pending = None);
let (at, column) = (levels[depth].at, levels[depth].column);
let sub = at
.and_then(|at| here.item_at(at, column))
@@ -278,26 +287,6 @@ fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
.is_some_and(|(here, ix)| here.is_buttons(ix))
}
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
pub(super) fn measure(
state: Entity<Open>,
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
) -> impl IntoElement {
canvas(
move |bounds, window, cx| {
if state.update(cx, |open, _| write(open, bounds)) {
state.update(cx, |_, cx| cx.notify());
window.request_animation_frame();
}
},
|_, _, _, _| {},
)
.absolute()
.top_0()
.left_0()
.size_full()
}
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
pub(crate) fn measure_host(state: Entity<Open>, hangs_under: bool) -> impl IntoElement {
measure(state, move |open, bounds| {
@@ -464,20 +453,38 @@ pub(crate) fn hang(
.with_priority(1)
.into_any_element(),
};
let room = window.viewport_size().width;
let subs = (1..depth).filter_map(|level| {
let row = state.read(cx).levels[level - 1].row;
let open = state.read(cx);
let row = open.levels[level - 1].row;
let width = match open.levels[level].panel.size.width {
width if width > Pixels::ZERO => width,
_ => cx.theme().menu_width().to_pixels(window.rem_size()),
};
// No room on the right: open on the left, over nothing.
let left = row.right() + width > room && row.left() >= width;
let (corner, from, slide) = match left {
true => (Anchor::TopRight, row.origin, -1.0),
false => (Anchor::TopLeft, row.top_right(), 1.0),
};
(row != Bounds::default()).then(|| {
raise(
(id.clone(), format!("level-{level}")),
anchored().position(row.top_right()).snap_to_window().child(
draw::panel(id, menu, state, level, &close, None, cx)
.mt_neg_1()
.with_animation(
(id.clone(), format!("sub-{level}")),
enter.clone(),
|panel, t| panel.opacity(t).ml(motion::NUDGE * (t - 1.0)),
),
),
anchored()
.anchor(corner)
.position(from)
.snap_to_window()
.child(
draw::panel(id, menu, state, level, &close, None, cx)
.mt_neg_1()
.with_animation(
(id.clone(), format!("sub-{level}")),
enter.clone(),
move |panel, t| {
panel.opacity(t).left(motion::NUDGE * (t - 1.0) * slide)
},
),
),
)
.with_priority(2)
.into_any_element()
+1
View File
@@ -1,3 +1,4 @@
mod aim;
mod bar;
mod draw;
mod hosts;
+167
View File
@@ -0,0 +1,167 @@
//! Hover intent: delayed submenus, the aim triangle, flips, occlusion.
use gpui::{
Bounds, Context, FocusHandle, IntoElement, Modifiers, MouseButton, ParentElement, Pixels,
Render, Styled, TestAppContext, VisualTestContext, Window, div, point, px, size,
};
use super::settle;
use crate::{
menus::{ContextMenu, Menu, MenuItem, aim},
primitives::FocusScope,
theme::Theme,
};
/// A region whose context menu has two submenus and a plain row.
struct Region {
root: FocusHandle,
}
impl Render for Region {
fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
let insert = Menu::new()
.item(MenuItem::new("Row above"))
.item(MenuItem::new("Row below"))
.item(MenuItem::new("Column left"));
let delete = Menu::new()
.item(MenuItem::new("Delete row"))
.item(MenuItem::new("Delete column"));
let menu = Menu::new()
.item(MenuItem::submenu("Insert", insert))
.item(MenuItem::submenu("Delete", delete))
.item(MenuItem::new("Plain"));
FocusScope::new(&self.root).size_full().child(
ContextMenu::new("region", menu)
.size_full()
.child(div().size_full()),
)
}
}
/// A region in a window `width` wide, its menu opened at `x`.
fn region(width: f32, x: f32, cx: &mut TestAppContext) -> &mut VisualTestContext {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
let (_, cx) = cx.add_window_view(|_, cx| Region {
root: cx.focus_handle(),
});
cx.simulate_resize(size(px(width), px(600.0)));
settle(cx);
let at = point(px(x), px(20.0));
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_mouse_down(at, MouseButton::Right, Modifiers::none());
cx.simulate_mouse_up(at, MouseButton::Right, Modifiers::none());
settle(cx);
cx
}
fn bounds(name: &str, cx: &mut VisualTestContext) -> Option<Bounds<Pixels>> {
cx.debug_bounds(Box::leak(name.to_string().into_boxed_str()))
}
fn row(label: &str, cx: &mut VisualTestContext) -> Bounds<Pixels> {
bounds(&format!("menu-row-{label}"), cx).expect("the row is drawn")
}
fn shown(label: &str, cx: &mut VisualTestContext) -> bool {
bounds(&format!("menu-row-{label}"), cx).is_some()
}
fn move_to(at: gpui::Point<Pixels>, cx: &mut VisualTestContext) {
cx.simulate_mouse_move(at, None, Modifiers::none());
settle(cx);
}
fn wait(time: std::time::Duration, cx: &mut VisualTestContext) {
cx.executor().advance_clock(time);
settle(cx);
}
/// Opens Insert's submenu by resting on it.
fn rest_on_insert(cx: &mut VisualTestContext) {
move_to(row("Insert", cx).center(), cx);
wait(aim::OPEN, cx);
assert!(shown("Row above", cx), "Insert's submenu opened");
}
#[gpui::test]
fn a_submenu_opens_after_the_pointer_rests(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
move_to(row("Insert", cx).center(), cx);
assert!(!shown("Row above", cx), "not yet");
wait(aim::OPEN, cx);
assert!(shown("Row above", cx));
}
#[gpui::test]
fn aiming_at_the_submenu_holds_it_across_rows(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let delete = row("Delete", cx);
move_to(point(delete.right() - px(16.0), delete.center().y), cx);
assert!(
shown("Row above", cx),
"crossing Delete keeps Insert's submenu"
);
wait(aim::HOLD / 2, cx);
assert!(shown("Row above", cx));
move_to(row("Row below", cx).center(), cx);
wait(aim::HOLD, cx);
assert!(shown("Row above", cx), "the submenu was reached");
assert!(!shown("Delete row", cx));
}
#[gpui::test]
fn resting_on_a_crossed_row_switches_after_the_hold(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let delete = row("Delete", cx);
move_to(point(delete.right() - px(16.0), delete.center().y), cx);
wait(aim::HOLD, cx);
assert!(shown("Delete row", cx), "Delete's submenu replaced it");
assert!(!shown("Row above", cx));
}
#[gpui::test]
fn moving_away_from_the_submenu_switches_at_once(cx: &mut TestAppContext) {
let cx = region(1000.0, 40.0, cx);
rest_on_insert(cx);
let insert = row("Insert", cx).center();
move_to(point(insert.x, row("Delete", cx).center().y), cx);
assert!(!shown("Row above", cx), "straight down left Insert");
wait(aim::OPEN, cx);
assert!(shown("Delete row", cx));
}
#[gpui::test]
fn a_submenu_without_room_on_the_right_opens_on_the_left(cx: &mut TestAppContext) {
let cx = region(800.0, 790.0, cx);
rest_on_insert(cx);
let parent = bounds("menu-panel-0", cx).expect("the menu");
let sub = bounds("menu-panel-1", cx).expect("the submenu");
// It meets the row's edge, as on the right, sliding in.
let edge = row("Insert", cx).left() + crate::motion::NUDGE;
assert!(sub.left() < parent.left(), "{sub:?} left of {parent:?}");
assert!(sub.right() <= edge, "{sub:?} leaves the rows of {parent:?}");
}
#[gpui::test]
fn rows_under_a_submenu_take_no_hover(cx: &mut TestAppContext) {
let cx = region(320.0, 10.0, cx);
rest_on_insert(cx);
let (plain, sub) = (
row("Plain", cx),
bounds("menu-panel-1", cx).expect("submenu"),
);
assert!(sub.left() < plain.right(), "the submenu covers the menu");
let under = point((sub.left() + plain.right()) / 2.0, plain.center().y);
assert!(sub.contains(&under), "the point is on the submenu");
move_to(under, cx);
wait(aim::HOLD + aim::OPEN, cx);
assert!(
shown("Row above", cx),
"Plain under the submenu stayed unmarked"
);
}
+1
View File
@@ -4,6 +4,7 @@ use gpui::{
Window, div, point, prelude::*, px,
};
mod aim;
mod buttons;
mod more;