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>
This commit is contained in:
1 parent
3c35daa4d8
commit
a1718b6f7c
6 files changed
+138
-39
No files matched your search
+2
-2
@@ -116,8 +116,8 @@ pub use switch::Switch;
|
|||||||
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
|
pub(crate) use syntax::{Syntax, syntax_fenced, syntax_of};
|
||||||
pub use tags::TagInput;
|
pub use tags::TagInput;
|
||||||
pub(crate) use text::{
|
pub(crate) use text::{
|
||||||
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up,
|
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Left, Paste, Redo, Right, Submit,
|
||||||
bind_keys, from_utf16, to_utf16,
|
Undo, Up, bind_keys, from_utf16, to_utf16,
|
||||||
};
|
};
|
||||||
pub use text::{Highlight, History, InputEvent, TextInput};
|
pub use text::{Highlight, History, InputEvent, TextInput};
|
||||||
pub use transfer::TransferList;
|
pub use transfer::TransferList;
|
||||||
|
|||||||
@@ -13,8 +13,8 @@ use gpui::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
pub(crate) use actions::{
|
pub(crate) use actions::{
|
||||||
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Paste, Redo, Submit, Undo, Up,
|
Backspace, Copy, Cut, Delete, DeleteWordLeft, Down, Enter, Left, Paste, Redo, Right, Submit,
|
||||||
bind_keys,
|
Undo, Up, bind_keys,
|
||||||
};
|
};
|
||||||
pub use edit::History;
|
pub use edit::History;
|
||||||
use edit::Snapshot;
|
use edit::Snapshot;
|
||||||
|
|||||||
+11
-1
@@ -9,6 +9,7 @@ use super::{
|
|||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{Run, surface},
|
forms::{Run, surface},
|
||||||
|
i18n,
|
||||||
navigation::marked,
|
navigation::marked,
|
||||||
primitives::{Icon, IconName, Tooltip},
|
primitives::{Icon, IconName, Tooltip},
|
||||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||||
@@ -83,7 +84,13 @@ fn row(item: &MenuItem, ix: usize, current: bool, cx: &App) -> Stateful<Div> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Button `column` of row `ix`, named by its label.
|
/// Button `column` of row `ix`, named by its label.
|
||||||
fn button(item: &MenuItem, ix: usize, column: usize, current: bool, cx: &App) -> Stateful<Div> {
|
pub(super) fn button(
|
||||||
|
item: &MenuItem,
|
||||||
|
ix: usize,
|
||||||
|
column: usize,
|
||||||
|
current: bool,
|
||||||
|
cx: &App,
|
||||||
|
) -> Stateful<Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let colors = &theme.colors;
|
let colors = &theme.colors;
|
||||||
let fg = match (item.disabled, current) {
|
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)
|
.border_color(colors.border)
|
||||||
.when(current && !item.disabled, |button| button.bg(colors.hover))
|
.when(current && !item.disabled, |button| button.bg(colors.hover))
|
||||||
.when(!item.disabled, |button| button.cursor_pointer())
|
.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))
|
.child(Icon::new(icon).size(IconSize::Sm).color(fg))
|
||||||
.tooltip(Tooltip::rich(move |_, cx| {
|
.tooltip(Tooltip::rich(move |_, cx| {
|
||||||
div()
|
div()
|
||||||
|
|||||||
+33
-10
@@ -10,7 +10,7 @@ use super::{
|
|||||||
model::{Kind, Menu},
|
model::{Kind, Menu},
|
||||||
};
|
};
|
||||||
use crate::{
|
use crate::{
|
||||||
forms::{Down, Enter, Run, TextInput, Up, float},
|
forms::{Down, Enter, Left, Right, Run, TextInput, Up, float},
|
||||||
motion,
|
motion,
|
||||||
primitives::{Icon, IconName, Takeover, give_back, raise, take_focus},
|
primitives::{Icon, IconName, Takeover, give_back, raise, take_focus},
|
||||||
theme::{ActiveTheme, IconSize},
|
theme::{ActiveTheme, IconSize},
|
||||||
@@ -35,13 +35,10 @@ 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 = levels[depth].column;
|
||||||
let spot = levels[depth]
|
let at = levels[depth].at.filter(|&ix| here.runs(ix, column));
|
||||||
.at
|
levels[depth].at = at;
|
||||||
.and_then(|ix| here.settled(ix, column).map(|column| (ix, column)));
|
match at
|
||||||
levels[depth].at = spot.map(|(ix, _)| ix);
|
.and_then(|ix| here.item_at(ix, column))
|
||||||
levels[depth].column = spot.map_or(0, |(_, column)| column);
|
|
||||||
match spot
|
|
||||||
.and_then(|(ix, column)| here.item_at(ix, column))
|
|
||||||
.map(|item| &item.kind)
|
.map(|item| &item.kind)
|
||||||
{
|
{
|
||||||
Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub,
|
Some(Kind::Sub(sub)) if depth + 1 < levels.len() => here = sub,
|
||||||
@@ -132,7 +129,7 @@ fn first_of(menu: &Menu, at: Option<usize>) -> Level {
|
|||||||
Level {
|
Level {
|
||||||
at,
|
at,
|
||||||
column: match 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,
|
None => 0,
|
||||||
},
|
},
|
||||||
..Level::default()
|
..Level::default()
|
||||||
@@ -234,7 +231,7 @@ fn keys(
|
|||||||
("down" | "up", _) => {
|
("down" | "up", _) => {
|
||||||
let by = if key == "down" { 1 } else { -1 };
|
let by = if key == "down" { 1 } else { -1 };
|
||||||
if let Some(to) = here.step(at, by) {
|
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);
|
mark(state, menu, depth, (to, column), false, cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -256,6 +253,17 @@ fn keys(
|
|||||||
true
|
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.
|
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
|
||||||
pub(super) fn measure(
|
pub(super) fn measure(
|
||||||
state: Entity<Open>,
|
state: Entity<Open>,
|
||||||
@@ -356,6 +364,8 @@ pub(crate) fn hang(
|
|||||||
});
|
});
|
||||||
let (up_menu, up_state, up_close) = (menu.clone(), state.clone(), close.clone());
|
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 (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)
|
let root = draw::panel(id, menu, state, 0, &close, head, cx)
|
||||||
.track_focus(&focus)
|
.track_focus(&focus)
|
||||||
.when(!spaced, |root| {
|
.when(!spaced, |root| {
|
||||||
@@ -368,6 +378,19 @@ pub(crate) fn hang(
|
|||||||
keys(&down_menu, &down_state, "down", &down_close, window, cx);
|
keys(&down_menu, &down_state, "down", &down_close, window, cx);
|
||||||
})
|
})
|
||||||
.capture_action(|_: &Enter, _, cx| cx.stop_propagation())
|
.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| {
|
.on_key_down(move |event, window, cx| {
|
||||||
let stroke = &event.keystroke;
|
let stroke = &event.keystroke;
|
||||||
|
|||||||
+14
-11
@@ -147,28 +147,31 @@ impl Menu {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Where row `ix` runs nearest `column`; none if disabled.
|
/// Whether the spot at row `ix`, button `column`, runs.
|
||||||
pub(super) fn settled(&self, ix: usize, column: usize) -> Option<usize> {
|
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<usize> {
|
||||||
match self.entries.get(ix) {
|
match self.entries.get(ix) {
|
||||||
Some(Entry::Item(item)) if !item.disabled => Some(0),
|
Some(Entry::Item(item)) => (!item.disabled).then_some(0),
|
||||||
Some(Entry::Buttons(buttons)) => {
|
Some(Entry::Buttons(buttons)) => buttons.iter().position(|button| !button.disabled),
|
||||||
let near = column.min(buttons.len() - 1);
|
|
||||||
match buttons[near].disabled {
|
|
||||||
false => Some(near),
|
|
||||||
true => buttons.iter().position(|button| !button.disabled),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => None,
|
_ => 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.
|
/// The next enabled row from `from`, stepping by `by` and wrapping.
|
||||||
pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> {
|
pub(super) fn step(&self, from: Option<usize>, by: isize) -> Option<usize> {
|
||||||
let count = self.entries.len() as isize;
|
let count = self.entries.len() as isize;
|
||||||
let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize);
|
let mut at = from.map_or(if by > 0 { -1 } else { count }, |at| at as isize);
|
||||||
for _ in 0..count {
|
for _ in 0..count {
|
||||||
at = (at + by).rem_euclid(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);
|
return Some(at as usize);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+76
-13
@@ -14,6 +14,8 @@ use crate::{
|
|||||||
struct Clip {
|
struct Clip {
|
||||||
root: FocusHandle,
|
root: FocusHandle,
|
||||||
ran: Vec<SharedString>,
|
ran: Vec<SharedString>,
|
||||||
|
paste_off: bool,
|
||||||
|
shown: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Clip {
|
impl Clip {
|
||||||
@@ -25,19 +27,24 @@ impl Clip {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
Menu::new()
|
Menu::new()
|
||||||
.buttons([
|
.buttons(
|
||||||
MenuItem::new("Cut")
|
[
|
||||||
.icon(IconName::Scissors)
|
MenuItem::new("Cut")
|
||||||
.on_click(run("cut")),
|
.icon(IconName::Scissors)
|
||||||
MenuItem::new("Copy")
|
.on_click(run("cut")),
|
||||||
.icon(IconName::Copy)
|
MenuItem::new("Copy")
|
||||||
.disabled(true)
|
.icon(IconName::Copy)
|
||||||
.on_click(run("copy")),
|
.disabled(true)
|
||||||
MenuItem::new("Paste")
|
.on_click(run("copy")),
|
||||||
.icon(IconName::Clipboard)
|
MenuItem::new("Paste")
|
||||||
.keys("secondary-v")
|
.icon(IconName::Clipboard)
|
||||||
.on_click(run("paste")),
|
.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")))
|
.item(MenuItem::new("Select all").on_click(run("select all")))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -60,6 +67,8 @@ fn clip(cx: &mut TestAppContext) -> (gpui::Entity<Clip>, &mut VisualTestContext)
|
|||||||
let (view, cx) = cx.add_window_view(|_, cx| Clip {
|
let (view, cx) = cx.add_window_view(|_, cx| Clip {
|
||||||
root: cx.focus_handle(),
|
root: cx.focus_handle(),
|
||||||
ran: Vec::new(),
|
ran: Vec::new(),
|
||||||
|
paste_off: false,
|
||||||
|
shown: 3,
|
||||||
});
|
});
|
||||||
settle(cx);
|
settle(cx);
|
||||||
(view, cx)
|
(view, cx)
|
||||||
@@ -135,3 +144,57 @@ fn the_filter_finds_a_button_by_its_name(cx: &mut TestAppContext) {
|
|||||||
press("enter", cx);
|
press("enter", cx);
|
||||||
assert_eq!(ran(&view, cx), ["paste"]);
|
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);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in new issue
Block a user