Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b8d67839a
|
||
|
|
ed75621a6d
|
||
|
|
a1718b6f7c
|
||
|
|
3c35daa4d8
|
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
}
|
||||
@@ -4,6 +4,7 @@ use gpui::{
|
||||
Window, div, point, prelude::*, px,
|
||||
};
|
||||
|
||||
mod buttons;
|
||||
mod more;
|
||||
|
||||
use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};
|
||||
|
||||
Reference in new issue
Block a user