Compare commits

..
4 Commits
Author SHA1 Message Date
ZacharyZhang-NYandClaude Opus 5.5 1b8d67839a Menus · button rows: a reader presses a button through Click and hears the marked one; the panel is a menu
CI / platforms (windows-latest) (push) Failing after 10s
CI / crate (push) Failing after 10m45s
CI / site (push) Successful in 27s
CI / platforms (ubuntu-24.04) (push) Canceled after 0s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 21:25:18 -04:00
ZacharyZhang-NYandClaude Opus 5.5 ed75621a6d 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>
2026-10-13 20:47:02 -04:00
ZacharyZhang-NYandClaude Opus 5.5 a1718b6f7c Menus · button rows: a marked button that turns off or leaves clears the mark, a filter field gives its arrows to a marked row, a disabled button says it is unavailable
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-13 20:31:44 -04:00
ZacharyZhang-NYandClaude Opus 5.5 3c35daa4d8 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>
2026-10-13 20:20:18 -04:00
9 changed files with 534 additions and 57 deletions

No files matched your search

+1 -1
View File
@@ -88,7 +88,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark.
- Floating lists go below their anchor, or above when only above has room (`forms::options::float`). gpui's own switch keeps the anchor point, so a flipped list would cover its trigger. An option list is at least its trigger's width and at most the window's less `window_margin` each side, so gpui can snap it inside and its rows end in an ellipsis; wider, gpui pins it left and the rest runs off.
- A trigger that opens a list goes through `forms::select::listing`: toggle, keys, blur and popup live there, for Select, TabOverflowMenu and Breadcrumb alike. A field that suggests as you type floats its rows through `forms::select::Picker` and `forms::options::Popup`, as Combobox and TagInput do.
- A palette goes through `navigation::palette::Palette`: query field, grouped rows, cursor, keys and focus live there. CommandPalette, QuickOpen, QuickSwitcher, SearchPalette and QuickLauncher only build rows.
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event.
- A menu goes through `menus::menu`: rows, submenus, keys, focus and presses outside live there, for DropdownMenu, OverflowMenu, SplitButton, ContextMenu, SearchableMenu and MenuBar. A press on the host's own box is the host's to handle, so a trigger toggles and a right click moves a context menu without a close and reopen in one event. A row of icon buttons (`Menu::buttons`) is one entry: Up and Down reach it, Left and Right cross it, a reader presses a button through Click and hears the marked one, and a mark holds its item by label, so a row that changes under it runs nothing unasked.
- gpui focuses the nearest focusable ancestor on mouse down, and apps wrap everything in a `FocusScope`. A press that must leave focus alone calls `prevent_default`, as buttons and context regions do.
- gpui runs the click of every pressed element under the pointer, a row's with its button's. A pressable row that holds buttons stops their press at their box on mouse down (`files::transfer`), as `overlays::media_button` stops its own.
- gpui draws a window's first frame inside `open_window`. State the first frame reads, such as `cx.set_menus`, is set before it opens.
+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(
+2 -2
View File
@@ -116,8 +116,8 @@ pub use switch::Switch;
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
pub use tags::TagInput;
pub(crate) use text::{
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up,
bind_keys, from_utf16, to_utf16,
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Left, Paste, Redo, Right, Submit,
Undo, Up, bind_keys, from_utf16, to_utf16,
};
pub use text::{Highlight, History, InputEvent, TextInput};
pub use transfer::TransferList;
+2 -2
View File
@@ -13,8 +13,8 @@ use gpui::{
};
pub(crate) use actions::{
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up,
bind_keys,
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Left, Paste, Redo, Right, Submit,
Undo, Up, bind_keys,
};
pub use edit::History;
use edit::Snapshot;
+103 -4
View File
@@ -1,6 +1,9 @@
use std::rc::Rc;
use gpui::{
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
MouseButton, ParentElement, Stateful, Styled, div, prelude::*,
MouseButton, ParentElement, Role, SharedString, Stateful, Styled, accesskit::Action, div,
prelude::*,
};
use super::{
@@ -9,8 +12,9 @@ use super::{
};
use crate::{
forms::{Run, surface},
i18n,
navigation::marked,
primitives::{Icon, IconName},
primitives::{Icon, IconName, Tooltip},
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
typography::KbdCombo,
};
@@ -82,6 +86,71 @@ fn row(item: &MenuItem, ix: usize, current: bool, cx: &App) -> Stateful<Div> {
})
}
/// Button `column` of row `ix`, named by its label.
pub(super) fn button(
item: &MenuItem,
(ix, column): (usize, usize),
current: bool,
press: Run,
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)
.aria_selected(current)
.when(current, |button| button.aria_active_descendant())
.when(current && !item.disabled, |button| button.bg(colors.hover))
.when(item.disabled, |button| {
button.aria_description(i18n::text(cx, "state.unavailable", &[]))
})
.when(!item.disabled, |button| {
let read = press.clone();
button
.cursor_pointer()
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
window.prevent_default();
cx.stop_propagation();
press(window, cx);
})
.on_a11y_action(Action::Click, move |_, window, cx| read(window, cx))
})
.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 +180,30 @@ 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();
let press: Run = Rc::new(move |window, cx| {
choose(&click, &menu, depth, (ix, column), &close, window, cx)
});
let drawn = button(item, (ix, column), current, press, cx);
drawn.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);
}
})
})
}))
.into_any_element(),
Entry::Item(item) => {
let marked = at == Some(ix);
let sub = matches!(item.kind, Kind::Sub(_));
@@ -126,7 +219,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 +227,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);
},
)
})
@@ -150,7 +243,13 @@ pub(super) fn panel(
}
});
surface((id.clone(), format!("panel-{depth}")), cx)
.role(Role::Menu)
.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()
+100 -40
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::{
@@ -10,7 +11,7 @@ use super::{
model::{Kind, Menu},
};
use crate::{
forms::{Down, Enter, Run, TextInput, Up, float},
forms::{Down, Enter, Left, Right, Run, TextInput, Up, float},
motion,
primitives::{Icon, IconName, Takeover, give_back, raise, take_focus},
theme::{ActiveTheme, IconSize},
@@ -21,7 +22,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 +35,17 @@ 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]
.at
.filter(|&ix| here.item_at(ix).is_some_and(|item| !item.disabled));
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)).map(|item| &item.kind) {
match at
.and_then(|ix| 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 +55,12 @@ fn settle(menu: &Menu, levels: &mut Vec<Level>) {
}
}
/// One open level: its marked row, that row's box and its panel's box.
#[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>,
}
@@ -93,10 +102,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 +111,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,25 +130,49 @@ 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 {
let Some(ix) = at else {
return Level::default();
};
let column = menu.first(ix).expect("a stepped row runs");
Level {
at,
column,
name: menu
.item_at(ix, column)
.expect("its first spot")
.label
.clone(),
..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,
) {
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).map(|item| &item.kind), Some(Kind::Sub(_)));
let (sub, name) = (matches!(item.kind, Kind::Sub(_)), item.label.clone());
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, level.name) = (Some(at), column, name);
if open_sub && sub {
open.levels.push(Level::default());
}
@@ -153,12 +180,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 +193,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 +220,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 +236,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.first(to).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();
@@ -233,6 +267,17 @@ fn keys(
true
}
/// Whether the deepest level marks a button row.
fn on_buttons(menu: &Menu, state: &Entity<Open>, cx: &App) -> bool {
let levels = &state.read(cx).levels;
let Some(depth) = levels.len().checked_sub(1) else {
return false;
};
let here = at_depth(menu, levels, depth);
here.zip(levels[depth].at)
.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>,
@@ -333,6 +378,8 @@ pub(crate) fn hang(
});
let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone());
let (down_menu, down_state, down_close) = (menu.clone(), state.clone(), close.clone());
let (left_menu, left_state, left_close) = (menu.clone(), state.clone(), close.clone());
let (right_menu, right_state, right_close) = (menu.clone(), state.clone(), close.clone());
let root = draw::panel(id, menu, state, 0, &close, head, cx)
.track_focus(&focus)
.when(!spaced, |root| {
@@ -345,6 +392,19 @@ pub(crate) fn hang(
keys(&down_menu, &down_state, "down", &down_close, window, cx);
})
.capture_action(|_: &Enter, _, cx| cx.stop_propagation())
// A button row takes the arrows from the filter.
.capture_action(move |_: &Left, window, cx| {
if on_buttons(&left_menu, &left_state, cx) {
cx.stop_propagation();
keys(&left_menu, &left_state, "left", &left_close, window, cx);
}
})
.capture_action(move |_: &Right, window, cx| {
if on_buttons(&right_menu, &right_state, cx) {
cx.stop_propagation();
keys(&right_menu, &right_state, "right", &right_close, window, cx);
}
})
})
.on_key_down(move |event, window, cx| {
let stroke = &event.keystroke;
+59 -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,20 +123,66 @@ 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,
}
}
/// Row `ix`'s first spot that runs.
pub(super) fn first(&self, ix: usize) -> Option<usize> {
match self.entries.get(ix) {
Some(Entry::Item(item)) => (!item.disabled).then_some(0),
Some(Entry::Buttons(buttons)) => buttons.iter().position(|button| !button.disabled),
_ => None,
}
}
pub(super) fn is_buttons(&self, ix: usize) -> bool {
matches!(self.entries.get(ix), Some(Entry::Buttons(_)))
}
/// The next enabled row from `from`, stepping by `by` and wrapping.
pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> {
let count = self.entries.len() as isize;
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.first(at as usize).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 +197,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));
+247
View File
@@ -0,0 +1,247 @@
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>,
paste_off: bool,
gone: Option<&'static str>,
}
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")
.disabled(self.paste_off)
.on_click(run("paste")),
MenuItem::new("Delete")
.icon(IconName::Trash2)
.on_click(run("delete")),
]
.into_iter()
.filter(|item| Some(item.label.as_ref()) != self.gone),
)
.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(),
paste_off: false,
gone: None,
});
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", "delete"], "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 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);
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"]);
}
#[gpui::test]
fn arrows_cross_buttons_while_the_filter_holds_focus(cx: &mut TestAppContext) {
let (view, cx) = clip(cx);
cx.update(|window, cx| window.focus_next(cx));
press("enter", cx);
press("right", cx);
press("enter", cx);
assert_eq!(ran(&view, cx), ["paste"], "right skips copy");
}
#[gpui::test]
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.gone = Some("Paste"),
] {
let (view, cx) = clip(cx);
right_click(cx);
press("down", cx);
press("right", cx);
view.update(cx, |clip, cx| {
change(clip);
cx.notify();
});
settle(cx);
press("enter", cx);
assert!(
ran(&view, cx).is_empty(),
"another button ran: {:?}",
ran(&view, cx)
);
}
}
#[gpui::test]
fn a_disabled_button_says_it_is_unavailable(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
let off = MenuItem::new("Copy").icon(IconName::Copy).disabled(true);
let on = MenuItem::new("Cut").icon(IconName::Scissors);
let read =
|item: &MenuItem, cx: &mut App| node(item, false, cx).description().map(str::to_owned);
let expected = crate::i18n::text(cx, "state.unavailable", &[]).to_string();
assert_eq!(read(&off, cx), Some(expected));
assert_eq!(read(&on, cx), None);
});
}
/// The accessibility node `item` draws as a menu button.
fn node(item: &MenuItem, current: bool, cx: &mut App) -> gpui::accesskit::Node {
let mut node = gpui::accesskit::Node::new(gpui::Role::Button);
let press: crate::forms::Run = std::rc::Rc::new(|_, _| {});
let drawn = super::super::draw::button(item, (0, 0), current, press, cx);
gpui::Element::write_a11y_info(&drawn, &mut node);
node
}
#[gpui::test]
fn a_button_offers_its_press_and_says_it_is_marked(cx: &mut TestAppContext) {
cx.update(|cx| {
Theme::init(cx);
let cut = MenuItem::new("Cut").icon(IconName::Scissors);
let off = MenuItem::new("Copy").icon(IconName::Copy).disabled(true);
let click = gpui::accesskit::Action::Click;
let marked = node(&cut, true, cx);
assert!(marked.supports_action(click), "a reader can press it");
assert_eq!(marked.is_selected(), Some(true));
assert_eq!(node(&cut, false, cx).is_selected(), Some(false));
assert!(!node(&off, false, cx).supports_action(click), "off");
});
}
+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};