Menus · button rows: Menu::buttons lays icon buttons in one row, arrows cross it, a press inside a panel stays the menu's

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:20:18 -04:00
1 parent 8fd3e431db
commit 3c35daa4d8
6 files changed
+356 -48

No files matched your search

+19 -1
View File
@@ -211,6 +211,24 @@ pub fn hosts(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let (last, caption) = ran("context-ran", window, cx);
let menu = Menu::new()
.buttons([
MenuItem::new("Cut")
.icon(IconName::Scissors)
.keys("secondary-x")
.on_click(run(&last, "cut")),
MenuItem::new("Copy")
.icon(IconName::Copy)
.keys("secondary-c")
.on_click(run(&last, "copy")),
MenuItem::new("Paste")
.icon(IconName::Clipboard)
.keys("secondary-v")
.on_click(run(&last, "paste")),
MenuItem::new("Delete")
.icon(IconName::Trash2)
.disabled(true),
])
.separator()
.item(
MenuItem::new("Back")
.icon(IconName::ArrowLeft)
@@ -242,7 +260,7 @@ pub fn context(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> {
let theme = cx.theme();
section(
"ContextMenu",
"A right click opens the menu where it lands; it stays inside the window.",
"A right click opens the menu where it lands; it stays inside the window. A row of icon buttons heads it; arrows cross the row.",
cx,
)
.child(specimen(
+79 -4
View File
@@ -1,6 +1,6 @@
use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Stateful, Styled, div, prelude::*,
MouseButton, ParentElement, Role, SharedString, Stateful, Styled, div, prelude::*,
};
use super::{
@@ -10,7 +10,7 @@ use super::{
use crate::{
forms::{Run, surface},
navigation::marked,
primitives::{Icon, IconName},
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::KbdCombo,
};
@@ -82,6 +82,50 @@ fn row(item: &MenuItem, ix: usize, current: bool, cx: &App) -> Stateful<Div> {
})
}
/// Button `column` of row `ix`, named by its label.
fn button(item: &MenuItem, ix: usize, column: usize, current: bool, cx: &App) -> Stateful<Div> {
let theme = cx.theme();
let colors = &theme.colors;
let fg = match (item.disabled, current) {
(true, _) => colors.fg_disabled,
(false, true) => colors.fg,
(false, false) => colors.fg_muted,
};
let (label, keys) = (item.label.clone(), item.keys.clone());
let icon = item.icon.expect("a menu button has its icon");
div()
.id((SharedString::from(format!("button-{ix}")), column))
.debug_selector(move || format!("menu-button-{ix}-{column}"))
.role(Role::Button)
.aria_label(item.label.clone())
.flex_1()
.flex()
.items_center()
.justify_center()
.h(theme.control_height(ControlSize::Md))
.rounded(theme.radius(Radius::Md))
.border_1()
.border_color(colors.border)
.when(current && !item.disabled, |button| button.bg(colors.hover))
.when(!item.disabled, |button| button.cursor_pointer())
.child(Icon::new(icon).size(IconSize::Sm).color(fg))
.tooltip(Tooltip::rich(move |_, cx| {
div()
.flex()
.items_center()
.gap_2()
.child(label.clone())
.when_some(keys.clone(), |tip, keys| {
tip.child(
div()
.text_color(cx.theme().colors.tooltip_fg.opacity(0.6))
.child(KbdCombo::new(&keys)),
)
})
.into_any_element()
}))
}
pub(super) fn panel(
id: &ElementId,
menu: &Menu,
@@ -111,6 +155,32 @@ pub(super) fn panel(
.text_color(colors.fg_subtle)
.child(title.clone())
.into_any_element(),
Entry::Buttons(buttons) => div()
.flex()
.gap_1()
.py_0p5()
.children(buttons.iter().enumerate().map(|(column, item)| {
let current = at == Some(ix) && open.levels[depth].column == column;
let (hover, click, close, menu) =
(state.clone(), state.clone(), close.clone(), menu.clone());
let hover_menu = menu.clone();
button(item, ix, column, current, cx).when(!item.disabled, |button| {
button
.on_mouse_move(move |_, _, cx| {
let open = hover.read(cx);
let marked = open.levels.get(depth).map(|l| (l.at, l.column));
if marked != Some((Some(ix), column)) {
mark(&hover, &hover_menu, depth, (ix, column), false, cx);
}
})
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
choose(&click, &menu, depth, (ix, column), &close, window, cx);
})
})
}))
.into_any_element(),
Entry::Item(item) => {
let marked = at == Some(ix);
let sub = matches!(item.kind, Kind::Sub(_));
@@ -126,7 +196,7 @@ pub(super) fn panel(
};
let shown = open.levels.len() > depth + 1;
if level.at != Some(ix) || (sub && !shown) {
mark(&hover, &hover_menu, depth, ix, true, cx);
mark(&hover, &hover_menu, depth, (ix, 0), true, cx);
}
})
.on_mouse_down(
@@ -134,7 +204,7 @@ pub(super) fn panel(
move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
choose(&click, &menu, depth, ix, &close, window, cx);
choose(&click, &menu, depth, (ix, 0), &close, window, cx);
},
)
})
@@ -151,6 +221,11 @@ pub(super) fn panel(
});
surface((id.clone(), format!("panel-{depth}")), cx)
.relative()
// Presses here are the menu's, not its host's.
.on_mouse_down(MouseButton::Left, |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
})
.min_w(theme.menu_width())
.p_1()
.flex()
+59 -36
View File
@@ -21,7 +21,7 @@ pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Op
levels.get(..depth)?.iter().try_fold(menu, |menu, level| {
match level
.at
.and_then(|ix| menu.item_at(ix))
.and_then(|ix| menu.item_at(ix, level.column))
.map(|item| &item.kind)
{
Some(Kind::Sub(sub)) => Some(sub),
@@ -34,11 +34,16 @@ 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 at = levels[depth]
let column = levels[depth].column;
let spot = levels[depth]
.at
.filter(|&ix| here.item_at(ix).is_some_and(|item| !item.disabled));
levels[depth].at = at;
match at.and_then(|ix| here.item_at(ix)).map(|item| &item.kind) {
.and_then(|ix| here.settled(ix, column).map(|column| (ix, column)));
levels[depth].at = spot.map(|(ix, _)| ix);
levels[depth].column = spot.map_or(0, |(_, column)| column);
match spot
.and_then(|(ix, column)| here.item_at(ix, column))
.map(|item| &item.kind)
{
Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub,
_ => {
levels.truncate(depth + 1);
@@ -48,10 +53,11 @@ fn settle(menu: &Menu, levels: &mut Vec<Level>) {
}
}
/// One open level: its marked row, that row's box and its panel's box.
/// An open level: marked row, button, and their boxes.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(super) struct Level {
pub(super) at: Option<usize>,
pub(super) column: usize,
pub(super) row: Bounds<Pixels>,
pub(super) panel: Bounds<Pixels>,
}
@@ -93,10 +99,7 @@ impl Open {
let first = menu.step(None, 1).filter(|_| marked);
state.update(cx, |open, cx| {
open.spot = Some(spot);
open.levels = vec![Level {
at: first,
..Level::default()
}];
open.levels = vec![first_of(menu, first)];
cx.notify();
});
}
@@ -105,10 +108,7 @@ impl Open {
pub(crate) fn restart(state: &Entity<Open>, menu: &Menu, cx: &mut App) {
let first = menu.step(None, 1);
state.update(cx, |open, cx| {
open.levels = vec![Level {
at: first,
..Level::default()
}];
open.levels = vec![first_of(menu, first)];
cx.notify();
});
}
@@ -127,12 +127,24 @@ impl Open {
}
}
/// Marks row `at` of level `depth` and drops deeper levels; a submenu row opens when `open_sub`.
/// A level marking row `at` at its first enabled button.
fn first_of(menu: &Menu, at: Option<usize>) -> Level {
Level {
at,
column: match at {
Some(ix) => menu.settled(ix, 0).expect("a stepped row runs"),
None => 0,
},
..Level::default()
}
}
/// Marks a spot at `depth`; `open_sub` opens its submenu.
pub(super) fn mark(
state: &Entity<Open>,
menu: &Menu,
depth: usize,
at: usize,
(at, column): (usize, usize),
open_sub: bool,
cx: &mut App,
) {
@@ -140,12 +152,16 @@ pub(super) fn mark(
let Some(here) = at_depth(menu, &open.levels, depth) else {
return;
};
let sub = matches!(here.item_at(at).map(|item| &item.kind), Some(Kind::Sub(_)));
let sub = matches!(
here.item_at(at, column).map(|item| &item.kind),
Some(Kind::Sub(_))
);
open.levels.truncate(depth + 1);
if open.levels[depth].at != Some(at) {
open.levels[depth].at = Some(at);
open.levels[depth].row = Bounds::default();
let level = &mut open.levels[depth];
if level.at != Some(at) {
level.row = Bounds::default();
}
(level.at, level.column) = (Some(at), column);
if open_sub && sub {
open.levels.push(Level::default());
}
@@ -153,12 +169,12 @@ pub(super) fn mark(
});
}
/// Runs a row. A submenu opens with its first row marked; anything else closes the menu first.
/// Runs a spot; a submenu opens, others close first.
pub(super) fn choose(
state: &Entity<Open>,
menu: &Menu,
depth: usize,
at: usize,
(at, column): (usize, usize),
close: &Run,
window: &mut Window,
cx: &mut App,
@@ -166,23 +182,22 @@ pub(super) fn choose(
let levels = &state.read(cx).levels;
let Some(item) = at_depth(menu, levels, depth)
.filter(|_| levels.len() > depth)
.and_then(|here| here.item_at(at))
.and_then(|here| here.item_at(at, column))
.cloned()
else {
log::debug!("menu: a press from an older frame found row {at} of level {depth} gone");
log::debug!(
"menu: a press from an older frame found row {at}:{column} of level {depth} gone"
);
return;
};
if item.disabled {
return;
}
if let Kind::Sub(sub) = &item.kind {
let first = sub.step(None, 1);
mark(state, menu, depth, at, false, cx);
let first = first_of(sub, sub.step(None, 1));
mark(state, menu, depth, (at, column), false, cx);
state.update(cx, |open, cx| {
open.levels.push(Level {
at: first,
..Level::default()
});
open.levels.push(first);
cx.notify();
});
return;
@@ -194,7 +209,7 @@ pub(super) fn choose(
}
}
/// Arrows walk the deepest level; Right opens a submenu; Enter and Space choose; Left and Escape back out.
/// Arrows, Enter, Space and Escape drive the deepest level.
fn keys(
menu: &Menu,
state: &Entity<Open>,
@@ -210,19 +225,27 @@ fn keys(
let Some(here) = at_depth(menu, &levels, depth) else {
return false;
};
let at = levels[depth].at;
let (at, column) = (levels[depth].at, levels[depth].column);
let sub = at
.and_then(|at| here.item_at(at))
.and_then(|at| here.item_at(at, column))
.is_some_and(|item| matches!(item.kind, Kind::Sub(_)));
let across = |by| at.and_then(|at| here.across(at, column, by).map(|to| (at, to)));
match (key, at) {
("down" | "up", _) => {
let by = if key == "down" { 1 } else { -1 };
if let Some(to) = here.step(at, by) {
mark(state, menu, depth, to, false, cx);
let column = here.settled(to, 0).expect("a stepped row runs");
mark(state, menu, depth, (to, column), false, cx);
}
}
("right", Some(at)) if sub => choose(state, menu, depth, at, close, window, cx),
("enter" | "space", Some(at)) => choose(state, menu, depth, at, close, window, cx),
("left" | "right", Some(_)) if across(1).is_some() => {
let to = across(if key == "right" { 1 } else { -1 }).expect("a row with a button");
mark(state, menu, depth, to, false, cx);
}
("right", Some(at)) if sub => choose(state, menu, depth, (at, column), close, window, cx),
("enter" | "space", Some(at)) => {
choose(state, menu, depth, (at, column), close, window, cx)
}
("left" | "escape", _) if depth > 0 => state.update(cx, |open, cx| {
open.levels.pop();
cx.notify();
+61 -7
View File
@@ -82,6 +82,8 @@ impl MenuItem {
#[derive(Clone)]
pub(super) enum Entry {
Item(MenuItem),
/// Icon buttons side by side; labels name them.
Buttons(Vec<MenuItem>),
Separator,
Heading(SharedString),
}
@@ -121,9 +123,41 @@ impl Menu {
self
}
pub(super) fn item_at(&self, ix: usize) -> Option<&MenuItem> {
/// A row of icon buttons; labels name them.
pub fn buttons(mut self, buttons: impl IntoIterator<Item = MenuItem>) -> Self {
let buttons: Vec<MenuItem> = buttons.into_iter().collect();
assert!(!buttons.is_empty(), "a button row holds buttons");
for button in &buttons {
assert!(
button.icon.is_some() && matches!(button.kind, Kind::Run),
"menu button {:?} needs an icon and runs",
button.label
);
}
self.entries.push(Entry::Buttons(buttons));
self
}
/// Row `ix`, or button `column` of its button row.
pub(super) fn item_at(&self, ix: usize, column: usize) -> Option<&MenuItem> {
match self.entries.get(ix) {
Some(Entry::Item(item)) => Some(item),
Some(Entry::Item(item)) if column == 0 => Some(item),
Some(Entry::Buttons(buttons)) => buttons.get(column),
_ => None,
}
}
/// Where row `ix` runs nearest `column`; none if disabled.
pub(super) fn settled(&self, ix: usize, column: usize) -> Option<usize> {
match self.entries.get(ix) {
Some(Entry::Item(item)) if !item.disabled => Some(0),
Some(Entry::Buttons(buttons)) => {
let near = column.min(buttons.len() - 1);
match buttons[near].disabled {
false => Some(near),
true => buttons.iter().position(|button| !button.disabled),
}
}
_ => None,
}
}
@@ -134,7 +168,23 @@ impl Menu {
let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize);
for _ in 0..count {
at = (at + by).rem_euclid(count);
if self.item_at(at as usize).is_some_and(|item| !item.disabled) {
if self.settled(at as usize, 0).is_some() {
return Some(at as usize);
}
}
None
}
/// Row `ix`'s next enabled button after `column`, wrapping.
pub(super) fn across(&self, ix: usize, column: usize, by: isize) -> Option<usize> {
let Some(Entry::Buttons(buttons)) = self.entries.get(ix) else {
return None;
};
let count = buttons.len() as isize;
let mut at = column as isize;
for _ in 0..count {
at = (at + by).rem_euclid(count);
if !buttons[at as usize].disabled {
return Some(at as usize);
}
}
@@ -149,15 +199,19 @@ impl Menu {
let mut found: Vec<(i32, MenuItem)> = self
.entries
.iter()
.filter_map(|entry| match entry {
Entry::Item(item) => fuzzy(query, &item.label).map(|fit| {
.flat_map(|entry| match entry {
Entry::Item(item) => std::slice::from_ref(item),
Entry::Buttons(buttons) => buttons.as_slice(),
_ => &[],
})
.filter_map(|item| {
fuzzy(query, &item.label).map(|fit| {
let item = MenuItem {
hits: fit.hits,
..item.clone()
};
(fit.score, item)
}),
_ => None,
})
})
.collect();
found.sort_by(|(a, _), (b, _)| b.cmp(a));
+137
View File
@@ -0,0 +1,137 @@
use gpui::{
App, Context, FocusHandle, IntoElement, Modifiers, MouseButton, ParentElement, Render,
SharedString, Styled, TestAppContext, VisualTestContext, Window, div, point, px,
};
use super::{press, settle};
use crate::{
menus::{ContextMenu, Menu, MenuItem, SearchableMenu},
primitives::{FocusScope, IconName},
theme::Theme,
};
/// A context menu headed by clipboard buttons.
struct Clip {
root: FocusHandle,
ran: Vec<SharedString>,
}
impl Clip {
fn menu(&self, cx: &mut Context<Self>) -> Menu {
let run = |name: &'static str| {
let view = cx.entity();
move |_: &mut Window, cx: &mut App| {
view.update(cx, |clip, _| clip.ran.push(name.into()))
}
};
Menu::new()
.buttons([
MenuItem::new("Cut")
.icon(IconName::Scissors)
.on_click(run("cut")),
MenuItem::new("Copy")
.icon(IconName::Copy)
.disabled(true)
.on_click(run("copy")),
MenuItem::new("Paste")
.icon(IconName::Clipboard)
.keys("secondary-v")
.on_click(run("paste")),
])
.item(MenuItem::new("Select all").on_click(run("select all")))
}
}
impl Render for Clip {
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let menu = self.menu(cx);
FocusScope::new(&self.root)
.size_full()
.child(SearchableMenu::new("find", "Find", menu.clone()))
.child(ContextMenu::new("area", menu).child(div().w(px(300.0)).h(px(200.0))))
}
}
fn clip(cx: &mut TestAppContext) -> (gpui::Entity<Clip>, &mut VisualTestContext) {
cx.update(|cx| {
Theme::init(cx);
crate::forms::bind_keys(cx);
});
let (view, cx) = cx.add_window_view(|_, cx| Clip {
root: cx.focus_handle(),
ran: Vec::new(),
});
settle(cx);
(view, cx)
}
fn ran(view: &gpui::Entity<Clip>, cx: &mut VisualTestContext) -> Vec<SharedString> {
view.read_with(cx, |clip, _| clip.ran.clone())
}
fn right_click(cx: &mut VisualTestContext) {
let at = point(px(60.0), px(120.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);
}
fn press_button(selector: &'static str, cx: &mut VisualTestContext) {
let at = cx.debug_bounds(selector).expect("the button").center();
cx.simulate_mouse_move(at, None, Modifiers::none());
cx.simulate_click(at, Modifiers::none());
settle(cx);
}
#[gpui::test]
fn arrows_walk_a_button_row_past_its_disabled_button(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
right_click(cx);
press("down", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut"], "down marks the row's first button");
right_click(cx);
press("down", cx);
press("right", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut", "paste"], "right skips copy");
right_click(cx);
press("down", cx);
press("left", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["cut", "paste", "paste"], "left wraps");
right_click(cx);
press("down", cx);
press("down", cx);
press("enter", cx);
assert_eq!(
ran(&view, cx).last().map(|name| name.as_ref()),
Some("select all")
);
}
#[gpui::test]
fn a_pressed_button_runs_and_a_disabled_one_does_not(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
right_click(cx);
press_button("menu-button-0-1", cx);
assert!(ran(&view, cx).is_empty(), "copy is off");
press_button("menu-button-0-2", cx);
assert_eq!(ran(&view, cx), ["paste"]);
assert!(
cx.debug_bounds("menu-button-0-0").is_none(),
"the menu closed after paste"
);
}
#[gpui::test]
fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
cx.update(|window, cx| window.focus_next(cx));
press("enter", cx);
cx.simulate_input("pas");
settle(cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["paste"]);
}
+1
View File
@@ -4,6 +4,7 @@ use gpui::{
Window, div, point, prelude::*, px,
};
mod buttons;
mod more;
use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};