diff --git a/examples/gallery/pages/menus/rows.rs b/examples/gallery/pages/menus/rows.rs index 567e271..a4b0d58 100644 --- a/examples/gallery/pages/menus/rows.rs +++ b/examples/gallery/pages/menus/rows.rs @@ -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( diff --git a/src/menus/draw.rs b/src/menus/draw.rs index 479114a..8f59a47 100644 --- a/src/menus/draw.rs +++ b/src/menus/draw.rs @@ -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
{ }) } +/// Button `column` of row `ix`, named by its label. +fn button(item: &MenuItem, ix: usize, column: usize, current: bool, cx: &App) -> Stateful
{ + 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() diff --git a/src/menus/menu.rs b/src/menus/menu.rs index 7667013..8fabdde 100644 --- a/src/menus/menu.rs +++ b/src/menus/menu.rs @@ -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) { 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) { } } -/// 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, + pub(super) column: usize, pub(super) row: Bounds, pub(super) panel: Bounds, } @@ -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, 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) -> 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, 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, 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, @@ -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(); diff --git a/src/menus/model.rs b/src/menus/model.rs index f658885..9cafe2d 100644 --- a/src/menus/model.rs +++ b/src/menus/model.rs @@ -82,6 +82,8 @@ impl MenuItem { #[derive(Clone)] pub(super) enum Entry { Item(MenuItem), + /// Icon buttons side by side; labels name them. + Buttons(Vec), 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) -> Self { + let buttons: Vec = 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 { + 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 { + 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)); diff --git a/src/menus/tests/buttons.rs b/src/menus/tests/buttons.rs new file mode 100644 index 0000000..ddfbc2c --- /dev/null +++ b/src/menus/tests/buttons.rs @@ -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, +} + +impl Clip { + fn menu(&self, cx: &mut Context) -> 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) -> 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, &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, cx: &mut VisualTestContext) -> Vec { + 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"]); +} diff --git a/src/menus/tests/mod.rs b/src/menus/tests/mod.rs index 3450b9c..f5a35ad 100644 --- a/src/menus/tests/mod.rs +++ b/src/menus/tests/mod.rs @@ -4,6 +4,7 @@ use gpui::{ Window, div, point, prelude::*, px, }; +mod buttons; mod more; use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};