Menus: a mark holds its item, so a neighbor shifted into its place is not chosen; a move after the menu closed marks nothing

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-13 20:47:02 -04:00
1 parent a1718b6f7c
commit ed75621a6d
3 files changed
+63 -25

No files matched your search

+29 -15
View File
@@ -2,7 +2,8 @@ use std::rc::Rc;
use gpui::{
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement,
IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, div, prelude::*,
IntoElement, ParentElement, Pixels, Point, SharedString, Styled, Window, anchored, canvas, div,
prelude::*,
};
use super::{
@@ -34,8 +35,12 @@ pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Op
fn settle(menu: &Menu, levels: &mut Vec<Level>) {
let mut here = menu;
for depth in 0..levels.len() {
let column = levels[depth].column;
let at = levels[depth].at.filter(|&ix| here.runs(ix, column));
let (column, name) = (levels[depth].column, levels[depth].name.clone());
// A mark holds its item, not just its place.
let at = levels[depth].at.filter(|&ix| {
here.item_at(ix, column)
.is_some_and(|item| !item.disabled && item.label == name)
});
levels[depth].at = at;
match at
.and_then(|ix| here.item_at(ix, column))
@@ -50,11 +55,12 @@ fn settle(menu: &Menu, levels: &mut Vec<Level>) {
}
}
/// An open level: marked row, button, and their boxes.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
/// An open level: marked row, button, its label, and boxes.
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Level {
pub(super) at: Option<usize>,
pub(super) column: usize,
pub(super) name: SharedString,
pub(super) row: Bounds<Pixels>,
pub(super) panel: Bounds<Pixels>,
}
@@ -126,12 +132,18 @@ impl Open {
/// A level marking row `at` at its first enabled button.
fn first_of(menu: &Menu, at: Option<usize>) -> Level {
let Some(ix) = at else {
return Level::default();
};
let column = menu.first(ix).expect("a stepped row runs");
Level {
at,
column: match at {
Some(ix) => menu.first(ix).expect("a stepped row runs"),
None => 0,
},
column,
name: menu
.item_at(ix, column)
.expect("its first spot")
.label
.clone(),
..Level::default()
}
}
@@ -146,19 +158,21 @@ pub(super) fn mark(
cx: &mut App,
) {
state.update(cx, |open, cx| {
let Some(here) = at_depth(menu, &open.levels, depth) else {
if open.levels.len() <= depth {
return log::debug!("menu: level {depth} closed before its mark");
}
let Some(item) =
at_depth(menu, &open.levels, depth).and_then(|here| here.item_at(at, column))
else {
return;
};
let sub = matches!(
here.item_at(at, column).map(|item| &item.kind),
Some(Kind::Sub(_))
);
let (sub, name) = (matches!(item.kind, Kind::Sub(_)), item.label.clone());
open.levels.truncate(depth + 1);
let level = &mut open.levels[depth];
if level.at != Some(at) {
level.row = Bounds::default();
}
(level.at, level.column) = (Some(at), column);
(level.at, level.column, level.name) = (Some(at), column, name);
if open_sub && sub {
open.levels.push(Level::default());
}
-5
View File
@@ -147,11 +147,6 @@ impl Menu {
}
}
/// Whether the spot at row `ix`, button `column`, runs.
pub(super) fn runs(&self, ix: usize, column: usize) -> bool {
self.item_at(ix, column).is_some_and(|item| !item.disabled)
}
/// Row `ix`'s first spot that runs.
pub(super) fn first(&self, ix: usize) -> Option<usize> {
match self.entries.get(ix) {
+34 -5
View File
@@ -15,7 +15,7 @@ struct Clip {
root: FocusHandle,
ran: Vec<SharedString>,
paste_off: bool,
shown: usize,
gone: Option<&'static str>,
}
impl Clip {
@@ -41,9 +41,12 @@ impl Clip {
.keys("secondary-v")
.disabled(self.paste_off)
.on_click(run("paste")),
MenuItem::new("Delete")
.icon(IconName::Trash2)
.on_click(run("delete")),
]
.into_iter()
.take(self.shown),
.filter(|item| Some(item.label.as_ref()) != self.gone),
)
.item(MenuItem::new("Select all").on_click(run("select all")))
}
@@ -68,7 +71,7 @@ fn clip(cx: &mut TestAppContext) -> (gpui::Entity<Clip>, &mut VisualTestContext)
root: cx.focus_handle(),
ran: Vec::new(),
paste_off: false,
shown: 3,
gone: None,
});
settle(cx);
(view, cx)
@@ -109,7 +112,7 @@ fn arrows_walk_a_button_row_past_its_disabled_button(cx: &mut TestAppContext) {
press("down", cx);
press("left", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut", "paste", "paste"], "left wraps");
assert_eq!(ran(&view, cx), ["cut", "paste", "delete"], "left wraps");
right_click(cx);
press("down", cx);
press("down", cx);
@@ -134,6 +137,32 @@ fn a_pressed_button_runs_and_a_disabled_one_does_not(cx: &mut TestAppContext) {
);
}
#[gpui::test]
fn a_move_after_a_button_closed_the_menu_does_nothing(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
right_click(cx);
let at = cx.debug_bounds("menu-button-0-2").expect("paste").center();
// Both events land before any repaint.
cx.update(|window, cx| {
let down = gpui::MouseDownEvent {
position: at,
button: MouseButton::Left,
modifiers: Modifiers::none(),
click_count: 1,
first_mouse: false,
};
let moved = gpui::MouseMoveEvent {
position: at + point(px(1.0), px(0.0)),
pressed_button: Some(MouseButton::Left),
modifiers: Modifiers::none(),
};
window.dispatch_event(gpui::PlatformInput::MouseDown(down), cx);
window.dispatch_event(gpui::PlatformInput::MouseMove(moved), cx);
});
settle(cx);
assert_eq!(ran(&view, cx), ["paste"]);
}
#[gpui::test]
fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
@@ -159,7 +188,7 @@ fn arrows_cross_buttons_while_the_filter_holds_focus(cx: &mut TestAppContext) {
fn a_marked_button_turned_off_or_gone_runs_nothing(cx: &mut TestAppContext) {
for change in [
|clip: &mut Clip| clip.paste_off = true,
|clip: &mut Clip| clip.shown = 1,
|clip: &mut Clip| clip.gone = Some("Paste"),
] {
let (view, cx) = clip(cx);
right_click(cx);