diff --git a/src/forms/mod.rs b/src/forms/mod.rs index 83ca20b..ecbe1df 100644 --- a/src/forms/mod.rs +++ b/src/forms/mod.rs @@ -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; diff --git a/src/forms/text/mod.rs b/src/forms/text/mod.rs index 6f09452..e08f077 100644 --- a/src/forms/text/mod.rs +++ b/src/forms/text/mod.rs @@ -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; diff --git a/src/menus/draw.rs b/src/menus/draw.rs index 8f59a47..cdf3918 100644 --- a/src/menus/draw.rs +++ b/src/menus/draw.rs @@ -9,6 +9,7 @@ use super::{ }; use crate::{ forms::{Run, surface}, + i18n, navigation::marked, primitives::{Icon, IconName, Tooltip}, theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, @@ -83,7 +84,13 @@ 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
{ +pub(super) 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) { @@ -108,6 +115,9 @@ fn button(item: &MenuItem, ix: usize, column: usize, current: bool, cx: &App) -> .border_color(colors.border) .when(current && !item.disabled, |button| button.bg(colors.hover)) .when(!item.disabled, |button| button.cursor_pointer()) + .when(item.disabled, |button| { + button.aria_description(i18n::text(cx, "state.unavailable", &[])) + }) .child(Icon::new(icon).size(IconSize::Sm).color(fg)) .tooltip(Tooltip::rich(move |_, cx| { div() diff --git a/src/menus/menu.rs b/src/menus/menu.rs index 8fabdde..10bbcf0 100644 --- a/src/menus/menu.rs +++ b/src/menus/menu.rs @@ -10,7 +10,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}, @@ -35,13 +35,10 @@ fn settle(menu: &Menu, levels: &mut Vec) { let mut here = menu; for depth in 0..levels.len() { let column = levels[depth].column; - let spot = levels[depth] - .at - .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)) + let at = levels[depth].at.filter(|&ix| here.runs(ix, column)); + levels[depth].at = at; + 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, @@ -132,7 +129,7 @@ fn first_of(menu: &Menu, at: Option) -> Level { Level { at, column: match at { - Some(ix) => menu.settled(ix, 0).expect("a stepped row runs"), + Some(ix) => menu.first(ix).expect("a stepped row runs"), None => 0, }, ..Level::default() @@ -234,7 +231,7 @@ fn keys( ("down" | "up", _) => { let by = if key == "down" { 1 } else { -1 }; if let Some(to) = here.step(at, by) { - let column = here.settled(to, 0).expect("a stepped row runs"); + let column = here.first(to).expect("a stepped row runs"); mark(state, menu, depth, (to, column), false, cx); } } @@ -256,6 +253,17 @@ fn keys( true } +/// Whether the deepest level marks a button row. +fn on_buttons(menu: &Menu, state: &Entity, 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, @@ -356,6 +364,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| { @@ -368,6 +378,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; diff --git a/src/menus/model.rs b/src/menus/model.rs index 9cafe2d..aa8cd2e 100644 --- a/src/menus/model.rs +++ b/src/menus/model.rs @@ -147,28 +147,31 @@ impl Menu { } } - /// Where row `ix` runs nearest `column`; none if disabled. - pub(super) fn settled(&self, ix: usize, column: usize) -> Option { + /// 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 { 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), - } - } + 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, by: isize) -> Option { 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.settled(at as usize, 0).is_some() { + if self.first(at as usize).is_some() { return Some(at as usize); } } diff --git a/src/menus/tests/buttons.rs b/src/menus/tests/buttons.rs index ddfbc2c..f41cf09 100644 --- a/src/menus/tests/buttons.rs +++ b/src/menus/tests/buttons.rs @@ -14,6 +14,8 @@ use crate::{ struct Clip { root: FocusHandle, ran: Vec, + paste_off: bool, + shown: usize, } impl Clip { @@ -25,19 +27,24 @@ impl Clip { } }; 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")), - ]) + .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")), + ] + .into_iter() + .take(self.shown), + ) .item(MenuItem::new("Select all").on_click(run("select all"))) } } @@ -60,6 +67,8 @@ fn clip(cx: &mut TestAppContext) -> (gpui::Entity, &mut VisualTestContext) let (view, cx) = cx.add_window_view(|_, cx| Clip { root: cx.focus_handle(), ran: Vec::new(), + paste_off: false, + shown: 3, }); settle(cx); (view, cx) @@ -135,3 +144,57 @@ fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) { 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.shown = 1, + ] { + 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| { + let mut node = gpui::accesskit::Node::new(gpui::Role::Button); + gpui::Element::write_a11y_info( + &super::super::draw::button(item, 0, 0, false, cx), + &mut node, + ); + node.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); + }); +}