T08b menus: menu bar over the native menus, searchable menu, pie menu; megamenu points to the navigation menu; menus follow their hosts
This commit is contained in:
18 files changed
+1516
-261
No files matched your search
@@ -0,0 +1,206 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Action, App, Bounds, ElementId, Entity, InteractiveElement, IntoElement, MouseButton,
|
||||
OwnedMenu, OwnedMenuItem, ParentElement, Pixels, RenderOnce, StatefulInteractiveElement,
|
||||
Styled, Window, canvas, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
menu::{Open, Spot, hang, measure_host},
|
||||
model::{Menu, MenuItem},
|
||||
};
|
||||
use crate::{
|
||||
primitives::tab_stop,
|
||||
theme::{ActiveTheme, ControlSize, Radius, TextSize},
|
||||
};
|
||||
|
||||
/// The title under the keyboard and where each title sits.
|
||||
#[derive(Default)]
|
||||
struct Titles {
|
||||
at: usize,
|
||||
boxes: Vec<Bounds<Pixels>>,
|
||||
}
|
||||
|
||||
/// gpui's menu model as rows: actions run through dispatch and show the keys bound to them.
|
||||
fn rows(items: &[OwnedMenuItem], window: &Window) -> Menu {
|
||||
items.iter().fold(Menu::new(), |menu, item| match item {
|
||||
OwnedMenuItem::Separator => menu.separator(),
|
||||
OwnedMenuItem::Submenu(sub) => menu.item(MenuItem::submenu(
|
||||
sub.name.clone(),
|
||||
rows(&sub.items, window),
|
||||
)),
|
||||
OwnedMenuItem::SystemMenu(os) => menu.item(MenuItem::new(os.name.clone()).disabled(true)),
|
||||
OwnedMenuItem::Action { name, action, .. } => {
|
||||
let keys = window
|
||||
.highest_precedence_binding_for_action(action.as_ref())
|
||||
.map(|binding| {
|
||||
let strokes: Vec<String> = binding
|
||||
.keystrokes()
|
||||
.iter()
|
||||
.map(|stroke| stroke.unparse())
|
||||
.collect();
|
||||
strokes.join(" ")
|
||||
});
|
||||
let action: Rc<Box<dyn Action>> = Rc::new(action.boxed_clone());
|
||||
let row = MenuItem::new(name.clone())
|
||||
.on_click(move |window, cx| window.dispatch_action(action.boxed_clone(), cx));
|
||||
menu.item(match keys {
|
||||
Some(keys) => row.keys(keys),
|
||||
None => row,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/// Opens title `at` under its box, its first row marked when `marked`.
|
||||
fn open_title(
|
||||
state: &Entity<Open>,
|
||||
titles: &Entity<Titles>,
|
||||
menus: &[Menu],
|
||||
at: usize,
|
||||
marked: bool,
|
||||
cx: &mut App,
|
||||
) {
|
||||
let anchor = titles.read(cx).boxes[at];
|
||||
titles.update(cx, |titles, cx| {
|
||||
titles.at = at;
|
||||
cx.notify();
|
||||
});
|
||||
Open::set_anchor(state, anchor, cx);
|
||||
Open::show(state, &menus[at], Spot::Under, marked, cx);
|
||||
}
|
||||
|
||||
/// The app's menus drawn in the window, for platforms and layouts without a native bar.
|
||||
#[derive(IntoElement)]
|
||||
pub struct MenuBar {
|
||||
id: ElementId,
|
||||
menus: Vec<OwnedMenu>,
|
||||
}
|
||||
|
||||
impl MenuBar {
|
||||
/// Pass `cx.get_menus()`: the menus `cx.set_menus` set.
|
||||
pub fn new(id: impl Into<ElementId>, menus: Vec<OwnedMenu>) -> Self {
|
||||
assert!(!menus.is_empty(), "a menu bar needs menus");
|
||||
Self {
|
||||
id: id.into(),
|
||||
menus,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for MenuBar {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let count = self.menus.len();
|
||||
let focus = tab_stop((self.id.clone(), "focus").into(), true, window, cx);
|
||||
let bar_focused = focus.is_focused(window);
|
||||
let state = window.use_keyed_state((self.id.clone(), "menu"), cx, |_, _| Open::default());
|
||||
let titles =
|
||||
window.use_keyed_state((self.id.clone(), "titles"), cx, |_, _| Titles::default());
|
||||
if titles.read(cx).boxes.len() != count {
|
||||
titles.update(cx, |titles, _| {
|
||||
titles.boxes = vec![Bounds::default(); count]
|
||||
});
|
||||
}
|
||||
let menus: Rc<Vec<Menu>> = Rc::new(
|
||||
self.menus
|
||||
.iter()
|
||||
.map(|menu| rows(&menu.items, window))
|
||||
.collect(),
|
||||
);
|
||||
let open = state.read(cx).is_open();
|
||||
let at = titles.read(cx).at.min(count - 1);
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let heads = self.menus.iter().enumerate().map(|(ix, menu)| {
|
||||
let (press, hover, measure, follow) =
|
||||
(state.clone(), state.clone(), titles.clone(), state.clone());
|
||||
let (press_titles, hover_titles) = (titles.clone(), titles.clone());
|
||||
let (press_menus, hover_menus) = (menus.clone(), menus.clone());
|
||||
let here = ix == at;
|
||||
div()
|
||||
.id(("title", ix))
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.h(theme.control_height(ControlSize::Sm))
|
||||
.px_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.border_1()
|
||||
.border_color(if bar_focused && here && !open {
|
||||
colors.focus
|
||||
} else {
|
||||
gpui::transparent_black()
|
||||
})
|
||||
.text_size(theme.text_size(TextSize::Sm))
|
||||
.when(open && here, |head| head.bg(colors.hover))
|
||||
.hover(|style| style.bg(colors.hover))
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
if open && here {
|
||||
Open::close(&press, window, cx);
|
||||
} else {
|
||||
open_title(&press, &press_titles, &press_menus, ix, false, cx);
|
||||
}
|
||||
})
|
||||
.on_hover(move |hovered, _, cx| {
|
||||
if *hovered && hover.read(cx).is_open() && hover_titles.read(cx).at != ix {
|
||||
open_title(&hover, &hover_titles, &hover_menus, ix, false, cx);
|
||||
}
|
||||
})
|
||||
.child(menu.name.clone())
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds, _, cx| {
|
||||
if measure.read(cx).boxes.get(ix) != Some(&bounds) {
|
||||
measure.update(cx, |titles, _| titles.boxes[ix] = bounds);
|
||||
}
|
||||
if here {
|
||||
Open::set_anchor(&follow, bounds, cx);
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
)
|
||||
});
|
||||
let (keys, key_titles, key_menus) = (state.clone(), titles.clone(), menus.clone());
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.track_focus(&focus)
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_0p5()
|
||||
.on_key_down(move |event, _, cx| {
|
||||
let key = event.keystroke.key.as_str();
|
||||
let open = keys.read(cx).is_open();
|
||||
let at = key_titles.read(cx).at.min(count - 1);
|
||||
let next = |by: isize| (at as isize + by).rem_euclid(count as isize) as usize;
|
||||
match key {
|
||||
"left" | "right" => {
|
||||
let to = next(if key == "right" { 1 } else { -1 });
|
||||
if open {
|
||||
open_title(&keys, &key_titles, &key_menus, to, true, cx);
|
||||
} else {
|
||||
key_titles.update(cx, |titles, cx| {
|
||||
titles.at = to;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
"down" | "enter" | "space" if !open => {
|
||||
open_title(&keys, &key_titles, &key_menus, at, key == "down", cx)
|
||||
}
|
||||
_ => return,
|
||||
}
|
||||
cx.stop_propagation();
|
||||
})
|
||||
.relative()
|
||||
.children(heads)
|
||||
.child(measure_host(state.clone(), false))
|
||||
.children(hang(&self.id, &menus[at], &state, None, window, cx))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,166 @@
|
||||
use gpui::{
|
||||
AnyElement, App, Div, ElementId, Entity, FontWeight, InteractiveElement, IntoElement,
|
||||
MouseButton, ParentElement, Stateful, Styled, div, prelude::*,
|
||||
};
|
||||
|
||||
use super::{
|
||||
menu::{Open, at_depth, choose, mark, measure},
|
||||
model::{Entry, Kind, Menu, MenuItem},
|
||||
};
|
||||
use crate::{
|
||||
forms::{Run, surface},
|
||||
navigation::marked,
|
||||
primitives::{Icon, IconName},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::KbdCombo,
|
||||
};
|
||||
|
||||
fn row(item: &MenuItem, ix: usize, current: bool, cx: &App) -> Stateful<Div> {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let fg = if item.disabled {
|
||||
colors.fg_disabled
|
||||
} else {
|
||||
colors.fg
|
||||
};
|
||||
let (lead, lead_color) = match &item.kind {
|
||||
Kind::Check(true) => (Some(IconName::Check), fg),
|
||||
Kind::Radio(true) => (Some(IconName::Dot), fg),
|
||||
_ => (item.icon, if item.disabled { fg } else { colors.fg_muted }),
|
||||
};
|
||||
div()
|
||||
.id(("row", ix))
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.h(theme.control_height(ControlSize::Md))
|
||||
.px_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.text_color(fg)
|
||||
.when(current && !item.disabled, |row| row.bg(colors.hover))
|
||||
.when(!item.disabled, |row| row.cursor_pointer())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.when_some(lead, |slot, icon| {
|
||||
slot.child(Icon::new(icon).size(IconSize::Sm).color(lead_color))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(if item.hits.is_empty() {
|
||||
item.label.clone().into_any_element()
|
||||
} else {
|
||||
marked(item.label.clone(), item.hits.clone(), cx)
|
||||
}),
|
||||
)
|
||||
.when_some(item.keys.clone(), |row, keys| {
|
||||
row.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.pl_4()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(KbdCombo::new(&keys)),
|
||||
)
|
||||
})
|
||||
.when(matches!(item.kind, Kind::Sub(_)), |row| {
|
||||
row.child(
|
||||
Icon::new(IconName::ChevronRight)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn panel(
|
||||
id: &ElementId,
|
||||
menu: &Menu,
|
||||
state: &Entity<Open>,
|
||||
depth: usize,
|
||||
close: &Run,
|
||||
head: Option<AnyElement>,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let open = state.read(cx);
|
||||
let here = at_depth(menu, &open.levels, depth).expect("settled levels follow submenu rows");
|
||||
let at = open.levels[depth].at;
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rows = here
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, entry)| match entry {
|
||||
Entry::Separator => div().h_px().my_1().bg(colors.border).into_any_element(),
|
||||
Entry::Heading(title) => div()
|
||||
.px_2()
|
||||
.pt_1p5()
|
||||
.pb_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(title.clone())
|
||||
.into_any_element(),
|
||||
Entry::Item(item) => {
|
||||
let marked = at == Some(ix);
|
||||
let sub = matches!(item.kind, Kind::Sub(_));
|
||||
let (hover, click, close, menu) =
|
||||
(state.clone(), state.clone(), close.clone(), menu.clone());
|
||||
let hover_menu = menu.clone();
|
||||
row(item, ix, marked, cx)
|
||||
.when(!item.disabled, |row| {
|
||||
row.on_mouse_move(move |_, _, cx| {
|
||||
let open = hover.read(cx);
|
||||
let Some(level) = open.levels.get(depth) else {
|
||||
return;
|
||||
};
|
||||
let shown = open.levels.len() > depth + 1;
|
||||
if level.at != Some(ix) || (sub && !shown) {
|
||||
mark(&hover, &hover_menu, depth, ix, true, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
choose(&click, &menu, depth, ix, &close, window, cx);
|
||||
},
|
||||
)
|
||||
})
|
||||
.when(marked && sub, |row| {
|
||||
row.child(measure(state.clone(), move |open, bounds| {
|
||||
let level = &mut open.levels[depth];
|
||||
let changed = level.row != bounds;
|
||||
level.row = bounds;
|
||||
changed
|
||||
}))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
});
|
||||
surface((id.clone(), format!("panel-{depth}")), cx)
|
||||
.relative()
|
||||
.min_w(theme.menu_width())
|
||||
.p_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(head)
|
||||
.children(rows)
|
||||
.child(measure(state.clone(), move |open, bounds| {
|
||||
let level = &mut open.levels[depth];
|
||||
let changed = level.panel != bounds;
|
||||
level.panel = bounds;
|
||||
changed
|
||||
}))
|
||||
}
|
||||
+96
-9
@@ -13,6 +13,7 @@ use super::{
|
||||
};
|
||||
use crate::{
|
||||
buttons::{Button, ButtonGroup, ButtonVariant, IconButton},
|
||||
forms::TextInput,
|
||||
primitives::IconName,
|
||||
};
|
||||
|
||||
@@ -40,6 +41,7 @@ fn host(
|
||||
trigger: impl IntoElement,
|
||||
menu: &Menu,
|
||||
state: &Entity<Open>,
|
||||
field: Option<&Entity<TextInput>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Stateful<Div> {
|
||||
@@ -48,8 +50,8 @@ fn host(
|
||||
.relative()
|
||||
.flex_none()
|
||||
.child(trigger)
|
||||
.child(measure_host(state.clone()))
|
||||
.children(hang(&id, menu, state, window, cx))
|
||||
.child(measure_host(state.clone(), true))
|
||||
.children(hang(&id, menu, state, field, window, cx))
|
||||
}
|
||||
|
||||
/// A button that opens a menu under it.
|
||||
@@ -93,7 +95,7 @@ impl RenderOnce for DropdownMenu {
|
||||
.trailing_icon(IconName::ChevronDown)
|
||||
.when_some(self.icon, |button, icon| button.icon(icon))
|
||||
.on_click(move |event, window, cx| click(event, window, cx));
|
||||
host(self.id, button, &self.menu, &state, window, cx)
|
||||
host(self.id, button, &self.menu, &state, None, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,7 +123,7 @@ impl RenderOnce for OverflowMenu {
|
||||
.variant(ButtonVariant::Ghost)
|
||||
.tooltip("More")
|
||||
.on_click(move |event, window, cx| click(event, window, cx));
|
||||
host(self.id, button, &self.menu, &state, window, cx)
|
||||
host(self.id, button, &self.menu, &state, None, window, cx)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,10 +167,16 @@ impl RenderOnce for SplitButton {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = state(&self.id, window, cx);
|
||||
let click = toggle(&state, &self.menu, cx);
|
||||
let (shut, run) = (state.clone(), self.on_click);
|
||||
let main = Button::new((self.id.clone(), "main"), self.label)
|
||||
.variant(self.variant)
|
||||
.when_some(self.on_click, |button, run| {
|
||||
button.on_click(move |event, window, cx| run(event, window, cx))
|
||||
.on_click(move |event, window, cx| {
|
||||
if shut.read(cx).is_open() {
|
||||
Open::close(&shut, window, cx);
|
||||
}
|
||||
if let Some(run) = &run {
|
||||
run(event, window, cx);
|
||||
}
|
||||
});
|
||||
let more = Button::new((self.id.clone(), "more"), "")
|
||||
.variant(self.variant)
|
||||
@@ -179,6 +187,7 @@ impl RenderOnce for SplitButton {
|
||||
ButtonGroup::new().button(main).button(more),
|
||||
&self.menu,
|
||||
&state,
|
||||
None,
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
@@ -212,14 +221,92 @@ impl ParentElement for ContextMenu {
|
||||
impl RenderOnce for ContextMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = state(&self.id, window, cx);
|
||||
let (open, menu) = (state.clone(), self.menu.clone());
|
||||
let (open, shut, menu) = (state.clone(), state.clone(), self.menu.clone());
|
||||
div()
|
||||
.id(self.id.clone())
|
||||
.on_mouse_down(MouseButton::Right, move |event, _, cx| {
|
||||
.relative()
|
||||
.on_mouse_down(MouseButton::Right, move |event, window, cx| {
|
||||
window.prevent_default();
|
||||
log::info!("context menu: at {:?}", event.position);
|
||||
Open::show(&open, &menu, Spot::At(event.position), false, cx);
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, move |_, window, cx| {
|
||||
if shut.read(cx).is_open() {
|
||||
window.prevent_default();
|
||||
Open::close(&shut, window, cx);
|
||||
}
|
||||
})
|
||||
.children(self.children)
|
||||
.children(hang(&self.id, &self.menu, &state, window, cx))
|
||||
.child(measure_host(state.clone(), false))
|
||||
.children(hang(&self.id, &self.menu, &state, None, window, cx))
|
||||
}
|
||||
}
|
||||
|
||||
/// A button that opens a menu headed by a filter field. Typing narrows the rows, best first, and marks the letters that matched.
|
||||
#[derive(IntoElement)]
|
||||
pub struct SearchableMenu {
|
||||
id: ElementId,
|
||||
label: SharedString,
|
||||
icon: Option<IconName>,
|
||||
placeholder: SharedString,
|
||||
menu: Menu,
|
||||
}
|
||||
|
||||
impl SearchableMenu {
|
||||
pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>, menu: Menu) -> Self {
|
||||
Self {
|
||||
id: id.into(),
|
||||
label: label.into(),
|
||||
icon: None,
|
||||
placeholder: "Filter".into(),
|
||||
menu,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn icon(mut self, icon: IconName) -> Self {
|
||||
self.icon = Some(icon);
|
||||
self
|
||||
}
|
||||
|
||||
pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
|
||||
self.placeholder = text.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderOnce for SearchableMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = state(&self.id, window, cx);
|
||||
let field = state.read(cx).is_open().then(|| {
|
||||
let placeholder = self.placeholder.clone();
|
||||
window.use_keyed_state((self.id.clone(), "filter"), cx, move |window, cx| {
|
||||
TextInput::new(window, cx).placeholder(placeholder)
|
||||
})
|
||||
});
|
||||
let query = field
|
||||
.as_ref()
|
||||
.map(|field| field.read(cx).text().trim().to_string());
|
||||
let shown = match &query {
|
||||
Some(query) => self.menu.filtered(query),
|
||||
None => self.menu.clone(),
|
||||
};
|
||||
let last = window.use_keyed_state((self.id.clone(), "query"), cx, |_, _| None::<String>);
|
||||
if *last.read(cx) != query {
|
||||
last.update(cx, |last, _| *last = query.clone());
|
||||
if query.is_some() {
|
||||
log::info!(
|
||||
"searchable menu {:?}: {} rows fit",
|
||||
self.id,
|
||||
shown.entries.len()
|
||||
);
|
||||
Open::restart(&state, &shown, cx);
|
||||
}
|
||||
}
|
||||
let click = toggle(&state, &shown, cx);
|
||||
let button = Button::new((self.id.clone(), "button"), self.label)
|
||||
.trailing_icon(IconName::ChevronDown)
|
||||
.when_some(self.icon, |button, icon| button.icon(icon))
|
||||
.on_click(move |event, window, cx| click(event, window, cx));
|
||||
host(self.id, button, &shown, &state, field.as_ref(), window, cx)
|
||||
}
|
||||
}
|
||||
+105
-173
@@ -1,22 +1,24 @@
|
||||
use std::rc::Rc;
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, Bounds, Div, ElementId, Entity, FontWeight,
|
||||
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, Stateful, Styled,
|
||||
Window, anchored, canvas, deferred, div, prelude::*,
|
||||
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, InteractiveElement,
|
||||
IntoElement, ParentElement, Pixels, Point, Styled, Window, anchored, canvas, deferred, div,
|
||||
prelude::*,
|
||||
};
|
||||
|
||||
use super::model::{Entry, Kind, Menu, MenuItem};
|
||||
use super::{
|
||||
draw,
|
||||
model::{Kind, Menu},
|
||||
};
|
||||
use crate::{
|
||||
forms::{Run, float, surface},
|
||||
forms::{Down, Enter, Run, TextInput, Up, float},
|
||||
motion,
|
||||
primitives::{Icon, IconName, Takeover, give_back, take_focus},
|
||||
theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize},
|
||||
typography::KbdCombo,
|
||||
theme::{ActiveTheme, IconSize},
|
||||
};
|
||||
|
||||
/// The menu open at `depth`, following the marked rows above it; `None` once those rows changed.
|
||||
fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Option<&'a Menu> {
|
||||
pub(super) fn at_depth<'a>(menu: &'a Menu, levels: &[Level], depth: usize) -> Option<&'a Menu> {
|
||||
levels.get(..depth)?.iter().try_fold(menu, |menu, level| {
|
||||
match level
|
||||
.at
|
||||
@@ -49,24 +51,25 @@ 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)]
|
||||
struct Level {
|
||||
at: Option<usize>,
|
||||
row: Bounds<Pixels>,
|
||||
panel: Bounds<Pixels>,
|
||||
pub(super) struct Level {
|
||||
pub(super) at: Option<usize>,
|
||||
pub(super) row: Bounds<Pixels>,
|
||||
pub(super) panel: Bounds<Pixels>,
|
||||
}
|
||||
|
||||
/// Where an open menu hangs: under its host's box, or at a point.
|
||||
/// Where an open menu hangs: under its anchor box, or at a point.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub(crate) enum Spot {
|
||||
Under,
|
||||
At(Point<Pixels>),
|
||||
}
|
||||
|
||||
/// A host's menu: where it hangs, its open levels root first, the host's box, and the focus to hand back.
|
||||
/// A host's menu: where it hangs, its open levels root first, the host's box, the box it hangs under, and the focus to hand back.
|
||||
#[derive(Default)]
|
||||
pub(crate) struct Open {
|
||||
spot: Option<Spot>,
|
||||
levels: Vec<Level>,
|
||||
pub(super) levels: Vec<Level>,
|
||||
host: Bounds<Pixels>,
|
||||
anchor: Bounds<Pixels>,
|
||||
takeover: Option<Entity<Takeover>>,
|
||||
}
|
||||
@@ -76,6 +79,16 @@ impl Open {
|
||||
self.spot.is_some()
|
||||
}
|
||||
|
||||
/// Hangs the menu under `anchor`, kept current as its owner moves.
|
||||
pub(crate) fn set_anchor(state: &Entity<Open>, anchor: Bounds<Pixels>, cx: &mut App) {
|
||||
if state.read(cx).anchor != anchor {
|
||||
state.update(cx, |open, cx| {
|
||||
open.anchor = anchor;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Opens at `spot`, with the first row marked when `marked`.
|
||||
pub(crate) fn show(state: &Entity<Open>, menu: &Menu, spot: Spot, marked: bool, cx: &mut App) {
|
||||
let first = menu.step(None, 1).filter(|_| marked);
|
||||
@@ -89,6 +102,18 @@ impl Open {
|
||||
});
|
||||
}
|
||||
|
||||
/// Starts over on new rows with the first marked; the menu stays where it hangs.
|
||||
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()
|
||||
}];
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
/// Closes and hands focus back to whatever held it before the menu opened.
|
||||
pub(crate) fn close(state: &Entity<Open>, window: &mut Window, cx: &mut App) {
|
||||
let takeover = state.update(cx, |open, cx| {
|
||||
@@ -104,7 +129,14 @@ impl Open {
|
||||
}
|
||||
|
||||
/// Marks row `at` of level `depth` and drops deeper levels; a submenu row opens when `open_sub`.
|
||||
fn mark(state: &Entity<Open>, menu: &Menu, depth: usize, at: usize, open_sub: bool, cx: &mut App) {
|
||||
pub(super) fn mark(
|
||||
state: &Entity<Open>,
|
||||
menu: &Menu,
|
||||
depth: usize,
|
||||
at: usize,
|
||||
open_sub: bool,
|
||||
cx: &mut App,
|
||||
) {
|
||||
state.update(cx, |open, cx| {
|
||||
let Some(here) = at_depth(menu, &open.levels, depth) else {
|
||||
return;
|
||||
@@ -123,7 +155,7 @@ fn mark(state: &Entity<Open>, menu: &Menu, depth: usize, at: usize, open_sub: bo
|
||||
}
|
||||
|
||||
/// Runs a row. A submenu opens with its first row marked; anything else closes the menu first.
|
||||
fn choose(
|
||||
pub(super) fn choose(
|
||||
state: &Entity<Open>,
|
||||
menu: &Menu,
|
||||
depth: usize,
|
||||
@@ -203,7 +235,7 @@ fn keys(
|
||||
}
|
||||
|
||||
/// Records a box into the host's state: the host's own, a marked row's or a panel's.
|
||||
fn measure(
|
||||
pub(super) fn measure(
|
||||
state: Entity<Open>,
|
||||
write: impl Fn(&mut Open, Bounds<Pixels>) -> bool + 'static,
|
||||
) -> impl IntoElement {
|
||||
@@ -221,164 +253,24 @@ fn measure(
|
||||
.size_full()
|
||||
}
|
||||
|
||||
/// Measures the host's box, which a dropdown hangs under.
|
||||
pub(crate) fn measure_host(state: Entity<Open>) -> impl IntoElement {
|
||||
measure(state, |open, bounds| {
|
||||
let changed = open.anchor != bounds;
|
||||
open.anchor = bounds;
|
||||
/// Measures the host's box: presses on it are the host's, and when `hangs_under` the menu hangs under it.
|
||||
pub(crate) fn measure_host(state: Entity<Open>, hangs_under: bool) -> impl IntoElement {
|
||||
measure(state, move |open, bounds| {
|
||||
let changed = open.host != bounds || (hangs_under && open.anchor != bounds);
|
||||
open.host = bounds;
|
||||
if hangs_under {
|
||||
open.anchor = bounds;
|
||||
}
|
||||
changed
|
||||
})
|
||||
}
|
||||
|
||||
fn row(item: &MenuItem, ix: usize, marked: bool, cx: &App) -> Stateful<Div> {
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let fg = if item.disabled {
|
||||
colors.fg_disabled
|
||||
} else {
|
||||
colors.fg
|
||||
};
|
||||
let (lead, lead_color) = match &item.kind {
|
||||
Kind::Check(true) => (Some(IconName::Check), fg),
|
||||
Kind::Radio(true) => (Some(IconName::Dot), fg),
|
||||
_ => (item.icon, if item.disabled { fg } else { colors.fg_muted }),
|
||||
};
|
||||
div()
|
||||
.id(("row", ix))
|
||||
.relative()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.h(theme.control_height(ControlSize::Md))
|
||||
.px_2()
|
||||
.rounded(theme.radius(Radius::Md))
|
||||
.text_color(fg)
|
||||
.when(marked && !item.disabled, |row| row.bg(colors.hover))
|
||||
.when(!item.disabled, |row| row.cursor_pointer())
|
||||
.child(
|
||||
div()
|
||||
.flex()
|
||||
.flex_none()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(theme.icon_size(IconSize::Sm))
|
||||
.when_some(lead, |slot, icon| {
|
||||
slot.child(Icon::new(icon).size(IconSize::Sm).color(lead_color))
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.min_w_0()
|
||||
.overflow_hidden()
|
||||
.text_ellipsis()
|
||||
.whitespace_nowrap()
|
||||
.child(item.label.clone()),
|
||||
)
|
||||
.when_some(item.keys.clone(), |row, keys| {
|
||||
row.child(
|
||||
div()
|
||||
.flex_none()
|
||||
.pl_4()
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(KbdCombo::new(&keys)),
|
||||
)
|
||||
})
|
||||
.when(matches!(item.kind, Kind::Sub(_)), |row| {
|
||||
row.child(
|
||||
Icon::new(IconName::ChevronRight)
|
||||
.size(IconSize::Xs)
|
||||
.color(colors.fg_subtle),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn panel(
|
||||
id: &ElementId,
|
||||
menu: &Menu,
|
||||
state: &Entity<Open>,
|
||||
depth: usize,
|
||||
close: &Run,
|
||||
cx: &App,
|
||||
) -> Stateful<Div> {
|
||||
let open = state.read(cx);
|
||||
let here = at_depth(menu, &open.levels, depth).expect("settled levels follow submenu rows");
|
||||
let at = open.levels[depth].at;
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rows = here
|
||||
.entries
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, entry)| match entry {
|
||||
Entry::Separator => div().h_px().my_1().bg(colors.border).into_any_element(),
|
||||
Entry::Heading(title) => div()
|
||||
.px_2()
|
||||
.pt_1p5()
|
||||
.pb_1()
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(colors.fg_subtle)
|
||||
.child(title.clone())
|
||||
.into_any_element(),
|
||||
Entry::Item(item) => {
|
||||
let marked = at == Some(ix);
|
||||
let sub = matches!(item.kind, Kind::Sub(_));
|
||||
let (hover, click, close, menu) =
|
||||
(state.clone(), state.clone(), close.clone(), menu.clone());
|
||||
let hover_menu = menu.clone();
|
||||
row(item, ix, marked, cx)
|
||||
.when(!item.disabled, |row| {
|
||||
row.on_mouse_move(move |_, _, cx| {
|
||||
let open = hover.read(cx);
|
||||
let Some(level) = open.levels.get(depth) else {
|
||||
return;
|
||||
};
|
||||
let shown = open.levels.len() > depth + 1;
|
||||
if level.at != Some(ix) || (sub && !shown) {
|
||||
mark(&hover, &hover_menu, depth, ix, true, cx);
|
||||
}
|
||||
})
|
||||
.on_mouse_down(
|
||||
MouseButton::Left,
|
||||
move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
cx.stop_propagation();
|
||||
choose(&click, &menu, depth, ix, &close, window, cx);
|
||||
},
|
||||
)
|
||||
})
|
||||
.when(marked && sub, |row| {
|
||||
row.child(measure(state.clone(), move |open, bounds| {
|
||||
let level = &mut open.levels[depth];
|
||||
let changed = level.row != bounds;
|
||||
level.row = bounds;
|
||||
changed
|
||||
}))
|
||||
})
|
||||
.into_any_element()
|
||||
}
|
||||
});
|
||||
surface((id.clone(), format!("panel-{depth}")), cx)
|
||||
.relative()
|
||||
.min_w(theme.menu_width())
|
||||
.p_1()
|
||||
.flex()
|
||||
.flex_col()
|
||||
.children(rows)
|
||||
.child(measure(state.clone(), move |open, bounds| {
|
||||
let level = &mut open.levels[depth];
|
||||
let changed = level.panel != bounds;
|
||||
level.panel = bounds;
|
||||
changed
|
||||
}))
|
||||
}
|
||||
|
||||
/// The open menu of host `id`, placed at its spot. It holds focus while open and gives it back on every close.
|
||||
pub(crate) fn hang(
|
||||
id: &ElementId,
|
||||
menu: &Menu,
|
||||
state: &Entity<Open>,
|
||||
field: Option<&Entity<TextInput>>,
|
||||
window: &mut Window,
|
||||
cx: &mut App,
|
||||
) -> Option<AnyElement> {
|
||||
@@ -395,11 +287,17 @@ pub(crate) fn hang(
|
||||
Open::close(&state, window, cx);
|
||||
})
|
||||
};
|
||||
if !focus.is_focused(window) {
|
||||
if !focus.contains_focused(window, cx) {
|
||||
log::info!("menu {id:?}: focus left");
|
||||
close(window, cx);
|
||||
return None;
|
||||
}
|
||||
let typing = field.map(|field| field.read(cx).focus().clone());
|
||||
if let Some(typing) = &typing
|
||||
&& focus.is_focused(window)
|
||||
{
|
||||
window.focus(typing);
|
||||
}
|
||||
let mut levels = state.read(cx).levels.clone();
|
||||
settle(menu, &mut levels);
|
||||
if levels != state.read(cx).levels {
|
||||
@@ -412,13 +310,47 @@ pub(crate) fn hang(
|
||||
let (keyed, key_menu, key_close) = (state.clone(), menu.clone(), close.clone());
|
||||
let (lifted, lift_menu, lift_close) = (state.clone(), menu.clone(), close.clone());
|
||||
let (out, out_close) = (state.clone(), close.clone());
|
||||
let root = panel(id, menu, state, 0, &close, cx)
|
||||
let spaced = typing.is_none();
|
||||
let confirm = move |key: &str| key == "enter" || (spaced && key == "space");
|
||||
let head = field.map(|field| {
|
||||
let theme = cx.theme();
|
||||
div()
|
||||
.flex()
|
||||
.items_center()
|
||||
.gap_2()
|
||||
.px_2()
|
||||
.pb_1()
|
||||
.mb_1()
|
||||
.border_b_1()
|
||||
.border_color(theme.colors.border)
|
||||
.child(
|
||||
Icon::new(IconName::Search)
|
||||
.size(IconSize::Sm)
|
||||
.color(theme.colors.fg_subtle),
|
||||
)
|
||||
.child(div().flex_1().min_w_0().child(field.clone()))
|
||||
.into_any_element()
|
||||
});
|
||||
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 root = draw::panel(id, menu, state, 0, &close, head, cx)
|
||||
.track_focus(&focus)
|
||||
.when(!spaced, |root| {
|
||||
root.capture_action(move |_: &Up, window, cx| {
|
||||
cx.stop_propagation();
|
||||
keys(&up_menu, &up_state, "up", &up_close, window, cx);
|
||||
})
|
||||
.capture_action(move |_: &Down, window, cx| {
|
||||
cx.stop_propagation();
|
||||
keys(&down_menu, &down_state, "down", &down_close, window, cx);
|
||||
})
|
||||
.capture_action(|_: &Enter, _, cx| cx.stop_propagation())
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
let stroke = &event.keystroke;
|
||||
let key = stroke.key.as_str();
|
||||
if matches!(key, "enter" | "space") {
|
||||
if !stroke.modifiers.modified() {
|
||||
if confirm(key) && !stroke.modifiers.modified() {
|
||||
cx.stop_propagation();
|
||||
}
|
||||
return;
|
||||
@@ -430,7 +362,7 @@ pub(crate) fn hang(
|
||||
.on_key_up(move |event, window, cx| {
|
||||
let stroke = &event.keystroke;
|
||||
let key = stroke.key.as_str();
|
||||
if matches!(key, "enter" | "space")
|
||||
if confirm(key)
|
||||
&& !stroke.modifiers.modified()
|
||||
&& keys(&lift_menu, &lifted, key, &lift_close, window, cx)
|
||||
{
|
||||
@@ -439,7 +371,7 @@ pub(crate) fn hang(
|
||||
})
|
||||
.on_mouse_down_out(move |event, window, cx| {
|
||||
let open = out.read(cx);
|
||||
let on_host = open.spot == Some(Spot::Under) && open.anchor.contains(&event.position);
|
||||
let on_host = open.host.contains(&event.position);
|
||||
if !on_host
|
||||
&& !open
|
||||
.levels
|
||||
@@ -462,7 +394,7 @@ pub(crate) fn hang(
|
||||
(row != Bounds::default()).then(|| {
|
||||
deferred(
|
||||
anchored().position(row.top_right()).snap_to_window().child(
|
||||
panel(id, menu, state, level, &close, cx)
|
||||
draw::panel(id, menu, state, level, &close, None, cx)
|
||||
.mt_neg_1()
|
||||
.with_animation(
|
||||
(id.clone(), format!("sub-{level}")),
|
||||
|
||||
+6
-1
@@ -1,8 +1,13 @@
|
||||
mod bar;
|
||||
mod draw;
|
||||
mod hosts;
|
||||
mod menu;
|
||||
mod model;
|
||||
mod pie;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
mod tests;
|
||||
|
||||
pub use hosts::{ContextMenu, DropdownMenu, OverflowMenu, SplitButton};
|
||||
pub use bar::MenuBar;
|
||||
pub use hosts::{ContextMenu, DropdownMenu, OverflowMenu, SearchableMenu, SplitButton};
|
||||
pub use model::{Menu, MenuItem};
|
||||
pub use pie::{PieItem, PieMenu};
|
||||
+32
-2
@@ -1,8 +1,8 @@
|
||||
use std::rc::Rc;
|
||||
use std::{ops::Range, rc::Rc};
|
||||
|
||||
use gpui::{App, SharedString, Window};
|
||||
|
||||
use crate::{forms::Run, primitives::IconName};
|
||||
use crate::{forms::Run, navigation::fuzzy, primitives::IconName};
|
||||
|
||||
/// What a row does when chosen.
|
||||
#[derive(Clone)]
|
||||
@@ -22,6 +22,7 @@ pub struct MenuItem {
|
||||
pub(super) kind: Kind,
|
||||
pub(super) disabled: bool,
|
||||
pub(super) on_click: Option<Run>,
|
||||
pub(super) hits: Vec<Range<usize>>,
|
||||
}
|
||||
|
||||
impl MenuItem {
|
||||
@@ -33,6 +34,7 @@ impl MenuItem {
|
||||
kind,
|
||||
disabled: false,
|
||||
on_click: None,
|
||||
hits: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,4 +140,32 @@ impl Menu {
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// The rows whose labels fit `query`, best first, with the letters that matched. All of it for an empty query.
|
||||
pub(super) fn filtered(&self, query: &str) -> Menu {
|
||||
if query.is_empty() {
|
||||
return self.clone();
|
||||
}
|
||||
let mut found: Vec<(i32, MenuItem)> = self
|
||||
.entries
|
||||
.iter()
|
||||
.filter_map(|entry| match entry {
|
||||
Entry::Item(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));
|
||||
Menu {
|
||||
entries: found
|
||||
.into_iter()
|
||||
.map(|(_, item)| Entry::Item(item))
|
||||
.collect(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
use std::{f32::consts::TAU, rc::Rc};
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt, AnyElement, App, Bounds, ElementId, Entity, FontWeight,
|
||||
InteractiveElement, IntoElement, MouseButton, ParentElement, Pixels, Point, RenderOnce,
|
||||
SharedString, Styled, Window, anchored, canvas, deferred, div, point,
|
||||
};
|
||||
use smallvec::SmallVec;
|
||||
|
||||
use crate::{
|
||||
forms::{Pick, Run},
|
||||
motion,
|
||||
primitives::{Icon, IconName, give_back, take_focus},
|
||||
theme::{ActiveTheme, Elevation, IconSize, TextSize},
|
||||
};
|
||||
|
||||
/// One slice of a pie menu.
|
||||
#[derive(Clone)]
|
||||
pub struct PieItem {
|
||||
icon: IconName,
|
||||
label: SharedString,
|
||||
on_click: Option<Run>,
|
||||
}
|
||||
|
||||
impl PieItem {
|
||||
pub fn new(icon: IconName, label: impl Into<SharedString>) -> Self {
|
||||
Self {
|
||||
icon,
|
||||
label: label.into(),
|
||||
on_click: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn on_click(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
|
||||
self.on_click = Some(Rc::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Where the ring opened, where its center landed, and the slice the pointer points at.
|
||||
#[derive(Default)]
|
||||
struct Pie {
|
||||
at: Option<Point<Pixels>>,
|
||||
center: Point<Pixels>,
|
||||
marked: Option<usize>,
|
||||
}
|
||||
|
||||
/// The slice at `pointer`: item 0 sits at the top and the rest follow clockwise; the hub picks none.
|
||||
fn slice(
|
||||
center: Point<Pixels>,
|
||||
pointer: Point<Pixels>,
|
||||
hub: Pixels,
|
||||
count: usize,
|
||||
) -> Option<usize> {
|
||||
let (dx, dy) = (
|
||||
f32::from(pointer.x - center.x),
|
||||
f32::from(pointer.y - center.y),
|
||||
);
|
||||
if dx.hypot(dy) < f32::from(hub) / 2.0 {
|
||||
return None;
|
||||
}
|
||||
let wedge = TAU / count as f32;
|
||||
let turn = (dy.atan2(dx) + TAU / 4.0 + wedge / 2.0).rem_euclid(TAU);
|
||||
Some((turn / wedge) as usize % count)
|
||||
}
|
||||
|
||||
/// Its children, and a ring of choices that opens at the pointer on a right click. The pointer's direction marks one; a click runs it.
|
||||
#[derive(IntoElement)]
|
||||
pub struct PieMenu {
|
||||
id: ElementId,
|
||||
items: Vec<PieItem>,
|
||||
children: SmallVec<[AnyElement; 2]>,
|
||||
}
|
||||
|
||||
impl PieMenu {
|
||||
pub fn new(id: impl Into<ElementId>, items: impl IntoIterator<Item = PieItem>) -> Self {
|
||||
let items: Vec<PieItem> = items.into_iter().collect();
|
||||
assert!(items.len() >= 2, "a pie menu needs two slices or more");
|
||||
Self {
|
||||
id: id.into(),
|
||||
items,
|
||||
children: SmallVec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ParentElement for PieMenu {
|
||||
fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
|
||||
self.children.extend(elements);
|
||||
}
|
||||
}
|
||||
|
||||
fn show(state: &Entity<Pie>, at: Option<Point<Pixels>>, cx: &mut App) {
|
||||
state.update(cx, |pie, cx| {
|
||||
*pie = Pie {
|
||||
at,
|
||||
..Pie::default()
|
||||
};
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
|
||||
impl RenderOnce for PieMenu {
|
||||
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
|
||||
let state = window.use_keyed_state((self.id.clone(), "pie"), cx, |_, _| Pie::default());
|
||||
let opener = state.clone();
|
||||
let region = div()
|
||||
.id(self.id.clone())
|
||||
.on_mouse_down(MouseButton::Right, move |event, window, cx| {
|
||||
window.prevent_default();
|
||||
log::info!("pie menu: at {:?}", event.position);
|
||||
show(&opener, Some(event.position), cx);
|
||||
})
|
||||
.children(self.children);
|
||||
let Some(at) = state.read(cx).at else {
|
||||
return region;
|
||||
};
|
||||
let takeover = take_focus((self.id.clone(), "takeover"), window, cx);
|
||||
let focus = takeover.read(cx).focus.clone();
|
||||
let close: Run = {
|
||||
let (state, id) = (state.clone(), self.id.clone());
|
||||
Rc::new(move |window, cx| {
|
||||
log::info!("pie menu {id:?}: closed");
|
||||
show(&state, None, cx);
|
||||
give_back(&takeover, window, cx);
|
||||
})
|
||||
};
|
||||
if !focus.is_focused(window) {
|
||||
close(window, cx);
|
||||
return region;
|
||||
}
|
||||
let items = Rc::new(self.items);
|
||||
let count = items.len();
|
||||
let run: Pick = {
|
||||
let (items, close) = (items.clone(), close.clone());
|
||||
Rc::new(move |ix, window, cx| {
|
||||
let item = &items[ix];
|
||||
log::info!("pie menu: {}", item.label);
|
||||
close(window, cx);
|
||||
if let Some(on_click) = &item.on_click {
|
||||
on_click(window, cx);
|
||||
}
|
||||
})
|
||||
};
|
||||
let theme = cx.theme();
|
||||
let colors = &theme.colors;
|
||||
let rem = window.rem_size();
|
||||
let (radius, bubble) = theme.pie();
|
||||
let (radius, bubble) = (radius.to_pixels(rem), bubble.to_pixels(rem));
|
||||
let hub = bubble * 1.75;
|
||||
let span = (radius + bubble) * 2.0;
|
||||
let marked = state.read(cx).marked.filter(|&ix| ix < count);
|
||||
let bloom =
|
||||
Animation::new(motion::duration(motion::BASE, cx)).with_easing(motion::ease_out_cubic);
|
||||
let bubbles = items.iter().enumerate().map(|(ix, item)| {
|
||||
let angle = ix as f32 * TAU / count as f32 - TAU / 4.0;
|
||||
let on = marked == Some(ix);
|
||||
let (fg, bg) = if on {
|
||||
(colors.on_accent, colors.accent)
|
||||
} else {
|
||||
(colors.fg_muted, colors.overlay)
|
||||
};
|
||||
div()
|
||||
.absolute()
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(bubble)
|
||||
.rounded_full()
|
||||
.bg(bg)
|
||||
.border_1()
|
||||
.border_color(if on { colors.accent } else { colors.border })
|
||||
.shadow(theme.elevation(Elevation::Floating))
|
||||
.child(Icon::new(item.icon).size(IconSize::Md).color(fg))
|
||||
.with_animation(
|
||||
(self.id.clone(), format!("bubble-{ix}")),
|
||||
bloom.clone(),
|
||||
move |bubble_el, t| {
|
||||
let late = (ix as f32 * 0.06).min(0.4);
|
||||
let t = ((t - late) / (1.0 - late)).clamp(0.0, 1.0);
|
||||
let reach = radius * motion::spring(t);
|
||||
let middle = span / 2.0;
|
||||
bubble_el
|
||||
.left(middle + reach * angle.cos() - bubble / 2.0)
|
||||
.top(middle + reach * angle.sin() - bubble / 2.0)
|
||||
.opacity(t)
|
||||
},
|
||||
)
|
||||
});
|
||||
let label = marked.map_or(SharedString::from("Cancel"), |ix| items[ix].label.clone());
|
||||
let measure = state.clone();
|
||||
let ring = div()
|
||||
.relative()
|
||||
.size(span)
|
||||
.child(
|
||||
div()
|
||||
.absolute()
|
||||
.left(radius + bubble - hub / 2.0)
|
||||
.top(radius + bubble - hub / 2.0)
|
||||
.flex()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.size(hub)
|
||||
.rounded_full()
|
||||
.bg(colors.overlay)
|
||||
.border_1()
|
||||
.border_color(colors.border)
|
||||
.shadow(theme.elevation(Elevation::Modal))
|
||||
.text_size(theme.text_size(TextSize::Xs))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
.text_color(if marked.is_some() {
|
||||
colors.fg
|
||||
} else {
|
||||
colors.fg_subtle
|
||||
})
|
||||
.child(label),
|
||||
)
|
||||
.children(bubbles)
|
||||
.child(
|
||||
canvas(
|
||||
move |bounds: Bounds<Pixels>, _, cx| {
|
||||
let center = bounds.center();
|
||||
if measure.read(cx).center != center {
|
||||
measure.update(cx, |pie, cx| {
|
||||
pie.center = center;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
},
|
||||
|_, _, _, _| {},
|
||||
)
|
||||
.absolute()
|
||||
.top_0()
|
||||
.left_0()
|
||||
.size_full(),
|
||||
);
|
||||
let (aim, pick, keys, lift) = (state.clone(), state.clone(), state.clone(), state.clone());
|
||||
let (run_click, run_key) = (run.clone(), run);
|
||||
let (close_click, close_right, escape) = (close.clone(), close.clone(), close);
|
||||
let viewport = window.viewport_size();
|
||||
let catcher = div()
|
||||
.id((self.id.clone(), "catcher"))
|
||||
.track_focus(&focus)
|
||||
.w(viewport.width)
|
||||
.h(viewport.height)
|
||||
.occlude()
|
||||
.on_mouse_move(move |event, _, cx| {
|
||||
let pie = aim.read(cx);
|
||||
let to = slice(pie.center, event.position, hub, count);
|
||||
if pie.marked != to {
|
||||
aim.update(cx, |pie, cx| {
|
||||
pie.marked = to;
|
||||
cx.notify();
|
||||
});
|
||||
}
|
||||
})
|
||||
.on_mouse_down(MouseButton::Left, move |event, window, cx| {
|
||||
window.prevent_default();
|
||||
let pie = pick.read(cx);
|
||||
match slice(pie.center, event.position, hub, count) {
|
||||
Some(ix) => run_click(ix, window, cx),
|
||||
None => close_click(window, cx),
|
||||
}
|
||||
})
|
||||
.on_mouse_down(MouseButton::Right, move |_, window, cx| {
|
||||
window.prevent_default();
|
||||
close_right(window, cx)
|
||||
})
|
||||
.on_key_down(move |event, window, cx| {
|
||||
let stroke = &event.keystroke;
|
||||
let by: isize = match stroke.key.as_str() {
|
||||
"right" | "down" => 1,
|
||||
"left" | "up" => -1,
|
||||
"escape" => {
|
||||
cx.stop_propagation();
|
||||
return escape(window, cx);
|
||||
}
|
||||
"enter" | "space" if !stroke.modifiers.modified() => {
|
||||
return cx.stop_propagation();
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
cx.stop_propagation();
|
||||
keys.update(cx, |pie, cx| {
|
||||
let from = pie
|
||||
.marked
|
||||
.filter(|&at| at < count)
|
||||
.map_or(if by > 0 { -1 } else { 0 }, |at| at as isize);
|
||||
pie.marked = Some((from + by).rem_euclid(count as isize) as usize);
|
||||
cx.notify();
|
||||
});
|
||||
})
|
||||
.on_key_up(move |event, window, cx| {
|
||||
let stroke = &event.keystroke;
|
||||
if matches!(stroke.key.as_str(), "enter" | "space") && !stroke.modifiers.modified()
|
||||
{
|
||||
cx.stop_propagation();
|
||||
if let Some(ix) = lift.read(cx).marked.filter(|&ix| ix < count) {
|
||||
run_key(ix, window, cx);
|
||||
}
|
||||
}
|
||||
});
|
||||
region.child(
|
||||
deferred(
|
||||
anchored()
|
||||
.position(point(Pixels::ZERO, Pixels::ZERO))
|
||||
.child(
|
||||
catcher.child(
|
||||
anchored()
|
||||
.position(at - point(span / 2.0, span / 2.0))
|
||||
.snap_to_window()
|
||||
.child(ring),
|
||||
),
|
||||
),
|
||||
)
|
||||
.with_priority(1),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,12 @@
|
||||
use gpui::{
|
||||
App, Context, FocusHandle, InteractiveElement, IntoElement, KeyUpEvent, Keystroke, Modifiers,
|
||||
MouseButton, ParentElement, Render, SharedString, Styled, TestAppContext, VisualTestContext,
|
||||
Window, div, point, px,
|
||||
Window, div, point, prelude::*, px,
|
||||
};
|
||||
|
||||
use super::{ContextMenu, DropdownMenu, Menu, MenuItem};
|
||||
mod more;
|
||||
|
||||
use super::{ContextMenu, DropdownMenu, Menu, MenuItem, SearchableMenu};
|
||||
use crate::{primitives::FocusScope, theme::Theme};
|
||||
|
||||
/// What the rows did, and the state the check and radio rows show.
|
||||
@@ -15,6 +17,7 @@ struct Desk {
|
||||
theme: SharedString,
|
||||
view_rows: bool,
|
||||
enters_above: usize,
|
||||
shift: bool,
|
||||
}
|
||||
|
||||
impl Desk {
|
||||
@@ -74,20 +77,25 @@ impl Render for Desk {
|
||||
.child(
|
||||
FocusScope::new(&self.root)
|
||||
.size_full()
|
||||
.when(self.shift, |scope| scope.child(div().h(px(80.0))))
|
||||
.child(DropdownMenu::new("edit", "Edit", menu.clone()))
|
||||
.child(ContextMenu::new("area", menu).child(div().w(px(200.0)).h(px(120.0)))),
|
||||
.child(
|
||||
ContextMenu::new("area", menu.clone())
|
||||
.child(div().w(px(200.0)).h(px(120.0))),
|
||||
)
|
||||
.child(SearchableMenu::new("find", "Find", menu)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn settle(cx: &mut VisualTestContext) {
|
||||
pub(super) fn settle(cx: &mut VisualTestContext) {
|
||||
cx.run_until_parked();
|
||||
cx.update(|window, _| window.refresh());
|
||||
cx.run_until_parked();
|
||||
}
|
||||
|
||||
/// Presses and releases `key`, with a frame in between as on a real keyboard.
|
||||
fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
pub(super) fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
cx.simulate_keystrokes(key);
|
||||
settle(cx);
|
||||
cx.simulate_event(KeyUpEvent {
|
||||
@@ -97,7 +105,10 @@ fn press(key: &str, cx: &mut VisualTestContext) {
|
||||
}
|
||||
|
||||
fn desk(cx: &mut TestAppContext) -> (gpui::Entity<Desk>, &mut VisualTestContext) {
|
||||
cx.update(Theme::init);
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
crate::forms::bind_keys(cx);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Desk {
|
||||
root: cx.focus_handle(),
|
||||
ran: Vec::new(),
|
||||
@@ -105,6 +116,7 @@ fn desk(cx: &mut TestAppContext) -> (gpui::Entity<Desk>, &mut VisualTestContext)
|
||||
theme: "light".into(),
|
||||
view_rows: true,
|
||||
enters_above: 0,
|
||||
shift: false,
|
||||
});
|
||||
settle(cx);
|
||||
(view, cx)
|
||||
@@ -265,3 +277,103 @@ fn modified_confirm_keys_do_not_pick(cx: &mut TestAppContext) {
|
||||
press("enter", cx);
|
||||
assert_eq!(ran(&view, cx), ["cut"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_second_right_click_moves_the_context_menu(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
for at in [point(px(150.0), px(120.0)), point(px(20.0), px(40.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);
|
||||
}
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
ran(&view, cx),
|
||||
["cut"],
|
||||
"the menu stayed open at the second click"
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_left_press_in_the_region_closes_the_context_menu(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
let at = point(px(150.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);
|
||||
click(20.0, 40.0, cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert!(ran(&view, cx).is_empty(), "the left press closed the menu");
|
||||
}
|
||||
|
||||
fn open_find(cx: &mut VisualTestContext) {
|
||||
cx.update(|window, _| {
|
||||
window.focus_next();
|
||||
window.focus_next();
|
||||
});
|
||||
press("enter", cx);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_filter_runs_the_best_fit(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
open_find(cx);
|
||||
cx.simulate_input("pa");
|
||||
settle(cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(ran(&view, cx), ["paste"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn space_types_into_the_filter(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
open_find(cx);
|
||||
cx.simulate_input("pa");
|
||||
settle(cx);
|
||||
press("space", cx);
|
||||
assert!(ran(&view, cx).is_empty(), "space picked a row");
|
||||
press("enter", cx);
|
||||
assert_eq!(ran(&view, cx), ["paste"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_walk_rows_from_the_filter(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
open_find(cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(ran(&view, cx), ["paste"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn an_open_dropdown_follows_its_host(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
click(12.0, 12.0, cx);
|
||||
view.update(cx, |desk, cx| {
|
||||
desk.shift = true;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
settle(cx);
|
||||
cx.simulate_mouse_move(point(px(40.0), px(131.0)), None, Modifiers::none());
|
||||
settle(cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(ran(&view, cx), ["cut"], "the panel moved with its button");
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn clearing_the_filter_marks_the_first_row_again(cx: &mut TestAppContext) {
|
||||
let (view, cx) = desk(cx);
|
||||
open_find(cx);
|
||||
cx.simulate_input("t");
|
||||
settle(cx);
|
||||
press("down", cx);
|
||||
press("backspace", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(ran(&view, cx), ["cut"]);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
use gpui::{
|
||||
Context, FocusHandle, InteractiveElement, IntoElement, KeyBinding, Modifiers, MouseButton,
|
||||
OwnedMenu, OwnedMenuItem, ParentElement, Render, Styled, TestAppContext, VisualTestContext,
|
||||
Window, actions, div, point, px,
|
||||
};
|
||||
|
||||
use super::{press, settle};
|
||||
use crate::{
|
||||
menus::{MenuBar, PieItem, PieMenu},
|
||||
primitives::{FocusScope, IconName},
|
||||
theme::Theme,
|
||||
};
|
||||
|
||||
actions!(menu_bar_test, [OpenFile, Save, Undo, Deep]);
|
||||
|
||||
/// A bar over two menus, the second with a submenu, recording what they dispatch.
|
||||
struct Bar {
|
||||
root: FocusHandle,
|
||||
ran: Vec<&'static str>,
|
||||
}
|
||||
|
||||
fn action(name: &str, action: impl gpui::Action) -> OwnedMenuItem {
|
||||
OwnedMenuItem::Action {
|
||||
name: name.into(),
|
||||
action: Box::new(action),
|
||||
os_action: None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Render for Bar {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let menus = vec![
|
||||
OwnedMenu {
|
||||
name: "File".into(),
|
||||
items: vec![action("Open", OpenFile), action("Save", Save)],
|
||||
},
|
||||
OwnedMenu {
|
||||
name: "Edit".into(),
|
||||
items: vec![
|
||||
action("Undo", Undo),
|
||||
OwnedMenuItem::Separator,
|
||||
OwnedMenuItem::Submenu(OwnedMenu {
|
||||
name: "More".into(),
|
||||
items: vec![action("Deep", Deep)],
|
||||
}),
|
||||
],
|
||||
},
|
||||
];
|
||||
div()
|
||||
.size_full()
|
||||
.on_action(cx.listener(|bar, _: &OpenFile, _, _| bar.ran.push("open")))
|
||||
.on_action(cx.listener(|bar, _: &Save, _, _| bar.ran.push("save")))
|
||||
.on_action(cx.listener(|bar, _: &Undo, _, _| bar.ran.push("undo")))
|
||||
.on_action(cx.listener(|bar, _: &Deep, _, _| bar.ran.push("deep")))
|
||||
.child(
|
||||
FocusScope::new(&self.root)
|
||||
.size_full()
|
||||
.child(MenuBar::new("bar", menus)),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn bar(cx: &mut TestAppContext) -> (gpui::Entity<Bar>, &mut VisualTestContext) {
|
||||
cx.update(|cx| {
|
||||
Theme::init(cx);
|
||||
cx.bind_keys([KeyBinding::new("secondary-s", Save, None)]);
|
||||
});
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Bar {
|
||||
root: cx.focus_handle(),
|
||||
ran: Vec::new(),
|
||||
});
|
||||
settle(cx);
|
||||
cx.update(|window, _| window.focus_next());
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_bar_walks_menus_and_dispatches_their_actions(cx: &mut TestAppContext) {
|
||||
let (view, cx) = bar(cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
press("down", cx);
|
||||
press("right", cx);
|
||||
press("up", cx);
|
||||
press("right", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(
|
||||
view.read_with(cx, |bar, _| bar.ran.clone()),
|
||||
["open", "deep"]
|
||||
);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_move_between_closed_titles(cx: &mut TestAppContext) {
|
||||
let (view, cx) = bar(cx);
|
||||
press("right", cx);
|
||||
press("down", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |bar, _| bar.ran.clone()), ["undo"]);
|
||||
}
|
||||
|
||||
/// Four slices around a big area: Copy on top, then Cut, Paste and Share clockwise.
|
||||
struct Wheel {
|
||||
ran: Vec<&'static str>,
|
||||
count: usize,
|
||||
root: FocusHandle,
|
||||
before: FocusHandle,
|
||||
enters_above: usize,
|
||||
}
|
||||
|
||||
impl Render for Wheel {
|
||||
fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let slice = |icon, name: &'static str| {
|
||||
let view = cx.entity();
|
||||
PieItem::new(icon, name)
|
||||
.on_click(move |_, cx| view.update(cx, |wheel, _| wheel.ran.push(name)))
|
||||
};
|
||||
let mut items = vec![
|
||||
slice(IconName::Copy, "copy"),
|
||||
slice(IconName::Scissors, "cut"),
|
||||
slice(IconName::Clipboard, "paste"),
|
||||
slice(IconName::Share2, "share"),
|
||||
];
|
||||
items.truncate(self.count);
|
||||
let above = cx.entity();
|
||||
div()
|
||||
.size_full()
|
||||
.on_key_down(move |event, _, cx| {
|
||||
let stroke = &event.keystroke;
|
||||
if stroke.key == "enter" && !stroke.modifiers.modified() {
|
||||
above.update(cx, |wheel, _| wheel.enters_above += 1);
|
||||
}
|
||||
})
|
||||
.child(
|
||||
FocusScope::new(&self.root)
|
||||
.size_full()
|
||||
.child(div().id("before").track_focus(&self.before))
|
||||
.child(PieMenu::new("pie", items).child(div().w(px(600.0)).h(px(600.0)))),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn wheel(cx: &mut TestAppContext) -> (gpui::Entity<Wheel>, &mut VisualTestContext) {
|
||||
cx.update(Theme::init);
|
||||
let (view, cx) = cx.add_window_view(|_, cx| Wheel {
|
||||
ran: Vec::new(),
|
||||
count: 4,
|
||||
root: cx.focus_handle(),
|
||||
before: cx.focus_handle(),
|
||||
enters_above: 0,
|
||||
});
|
||||
settle(cx);
|
||||
cx.update(|window, cx| window.focus(&view.read(cx).before.clone()));
|
||||
let at = point(px(300.0), px(300.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);
|
||||
(view, cx)
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn the_pointer_direction_picks_a_slice(cx: &mut TestAppContext) {
|
||||
let (view, cx) = wheel(cx);
|
||||
let right = point(px(400.0), px(300.0));
|
||||
cx.simulate_mouse_move(right, None, Modifiers::none());
|
||||
cx.simulate_click(right, Modifiers::none());
|
||||
settle(cx);
|
||||
assert_eq!(view.read_with(cx, |wheel, _| wheel.ran.clone()), ["cut"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn arrows_turn_the_pie_and_enter_runs(cx: &mut TestAppContext) {
|
||||
let (view, cx) = wheel(cx);
|
||||
press("right", cx);
|
||||
press("right", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |wheel, _| wheel.ran.clone()), ["cut"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_press_on_the_hub_cancels(cx: &mut TestAppContext) {
|
||||
let (view, cx) = wheel(cx);
|
||||
let hub = point(px(300.0), px(300.0));
|
||||
cx.simulate_mouse_move(hub, None, Modifiers::none());
|
||||
cx.simulate_click(hub, Modifiers::none());
|
||||
settle(cx);
|
||||
press("right", cx);
|
||||
press("enter", cx);
|
||||
assert!(view.read_with(cx, |wheel, _| wheel.ran.is_empty()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn right_moves_a_menu_opened_by_the_mouse(cx: &mut TestAppContext) {
|
||||
let (view, cx) = bar(cx);
|
||||
let title = point(px(10.0), px(10.0));
|
||||
cx.simulate_mouse_move(title, None, Modifiers::none());
|
||||
cx.simulate_click(title, Modifiers::none());
|
||||
settle(cx);
|
||||
press("right", cx);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |bar, _| bar.ran.clone()), ["undo"]);
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn a_pie_that_shrinks_forgets_a_mark_past_its_end(cx: &mut TestAppContext) {
|
||||
let (view, cx) = wheel(cx);
|
||||
for _ in 0..4 {
|
||||
press("right", cx);
|
||||
}
|
||||
view.update(cx, |wheel, cx| {
|
||||
wheel.count = 2;
|
||||
cx.notify();
|
||||
});
|
||||
settle(cx);
|
||||
press("enter", cx);
|
||||
assert!(view.read_with(cx, |wheel, _| wheel.ran.is_empty()));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn cancelling_on_the_hub_hands_focus_back(cx: &mut TestAppContext) {
|
||||
let (view, cx) = wheel(cx);
|
||||
let hub = point(px(300.0), px(300.0));
|
||||
cx.simulate_mouse_move(hub, None, Modifiers::none());
|
||||
cx.simulate_click(hub, Modifiers::none());
|
||||
settle(cx);
|
||||
let before = view.read_with(cx, |wheel, _| wheel.before.clone());
|
||||
assert!(cx.update(|window, _| before.is_focused(window)));
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn pie_confirm_keys_stay_inside_and_ignore_modifiers(cx: &mut TestAppContext) {
|
||||
let (view, cx) = wheel(cx);
|
||||
press("right", cx);
|
||||
press("ctrl-enter", cx);
|
||||
assert!(
|
||||
view.read_with(cx, |wheel, _| wheel.ran.is_empty()),
|
||||
"a modified key ran"
|
||||
);
|
||||
press("enter", cx);
|
||||
assert_eq!(view.read_with(cx, |wheel, _| wheel.ran.clone()), ["copy"]);
|
||||
assert_eq!(view.read_with(cx, |wheel, _| wheel.enters_above), 0);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ mod nav;
|
||||
mod overflow;
|
||||
mod pages;
|
||||
mod palette;
|
||||
pub(crate) use palette::{fuzzy, marked};
|
||||
mod steps;
|
||||
mod tabs;
|
||||
#[cfg(all(test, feature = "test-support"))]
|
||||
|
||||
@@ -228,6 +228,11 @@ impl Theme {
|
||||
px_to_rems(480.0)
|
||||
}
|
||||
|
||||
/// A pie menu's ring radius and the size of each slice's button.
|
||||
pub fn pie(&self) -> (Rems, Rems) {
|
||||
(px_to_rems(84.0), px_to_rems(40.0))
|
||||
}
|
||||
|
||||
/// Narrowest a menu's panel gets.
|
||||
pub fn menu_width(&self) -> Rems {
|
||||
px_to_rems(200.0)
|
||||
|
||||
Reference in new issue
Block a user