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::{ use gpui::{
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement, 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::{ 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>) { fn settle(menu: &Menu, levels: &mut Vec<Level>) {
let mut here = menu; let mut here = menu;
for depth in 0..levels.len() { for depth in 0..levels.len() {
let column = levels[depth].column; let (column, name) = (levels[depth].column, levels[depth].name.clone());
let at = levels[depth].at.filter(|&ix| here.runs(ix, column)); // 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; levels[depth].at = at;
match at match at
.and_then(|ix| here.item_at(ix, column)) .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. /// An open level: marked row, button, its label, and boxes.
#[derive(Clone, Copy, Debug, Default, PartialEq)] #[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Level { pub(super) struct Level {
pub(super) at: Option<usize>, pub(super) at: Option<usize>,
pub(super) column: usize, pub(super) column: usize,
pub(super) name: SharedString,
pub(super) row: Bounds<Pixels>, pub(super) row: Bounds<Pixels>,
pub(super) panel: Bounds<Pixels>, pub(super) panel: Bounds<Pixels>,
} }
@@ -126,12 +132,18 @@ impl Open {
/// A level marking row `at` at its first enabled button. /// A level marking row `at` at its first enabled button.
fn first_of(menu: &Menu, at: Option<usize>) -> Level { 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 { Level {
at, at,
column: match at { column,
Some(ix) => menu.first(ix).expect("a stepped row runs"), name: menu
None => 0, .item_at(ix, column)
}, .expect("its first spot")
.label
.clone(),
..Level::default() ..Level::default()
} }
} }
@@ -146,19 +158,21 @@ pub(super) fn mark(
cx: &mut App, cx: &mut App,
) { ) {
state.update(cx, |open, cx| { 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; return;
}; };
let sub = matches!( let (sub, name) = (matches!(item.kind, Kind::Sub(_)), item.label.clone());
here.item_at(at, column).map(|item| &item.kind),
Some(Kind::Sub(_))
);
open.levels.truncate(depth + 1); open.levels.truncate(depth + 1);
let level = &mut open.levels[depth]; let level = &mut open.levels[depth];
if level.at != Some(at) { if level.at != Some(at) {
level.row = Bounds::default(); 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 { if open_sub && sub {
open.levels.push(Level::default()); 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. /// Row `ix`'s first spot that runs.
pub(super) fn first(&self, ix: usize) -> Option<usize> { pub(super) fn first(&self, ix: usize) -> Option<usize> {
match self.entries.get(ix) { match self.entries.get(ix) {
+34 -5
View File
@@ -15,7 +15,7 @@ struct Clip {
root: FocusHandle, root: FocusHandle,
ran: Vec<SharedString>, ran: Vec<SharedString>,
paste_off: bool, paste_off: bool,
shown: usize, gone: Option<&'static str>,
} }
impl Clip { impl Clip {
@@ -41,9 +41,12 @@ impl Clip {
.keys("secondary-v") .keys("secondary-v")
.disabled(self.paste_off) .disabled(self.paste_off)
.on_click(run("paste")), .on_click(run("paste")),
MenuItem::new("Delete")
.icon(IconName::Trash2)
.on_click(run("delete")),
] ]
.into_iter() .into_iter()
.take(self.shown), .filter(|item| Some(item.label.as_ref()) != self.gone),
) )
.item(MenuItem::new("Select all").on_click(run("select all"))) .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(), root: cx.focus_handle(),
ran: Vec::new(), ran: Vec::new(),
paste_off: false, paste_off: false,
shown: 3, gone: None,
}); });
settle(cx); settle(cx);
(view, cx) (view, cx)
@@ -109,7 +112,7 @@ fn arrows_walk_a_button_row_past_its_disabled_button(cx: &mut TestAppContext) {
press("down", cx); press("down", cx);
press("left", cx); press("left", cx);
press("enter", 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); right_click(cx);
press("down", cx); press("down", 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] #[gpui::test]
fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) { fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) {
let (view, cx) = clip(cx); 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) { fn a_marked_button_turned_off_or_gone_runs_nothing(cx: &mut TestAppContext) {
for change in [ for change in [
|clip: &mut Clip| clip.paste_off = true, |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); let (view, cx) = clip(cx);
right_click(cx); right_click(cx);