use std::rc::Rc;
use gpui::{
AnyElement, App, Context, EventEmitter, FocusHandle, Focusable, InteractiveElement,
IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, ParentElement, Pixels, Point, Render,
SharedString, StatefulInteractiveElement, Styled, Transformation, Window, div,
prelude::FluentBuilder, px,
};
use gpui_kit_assets::{Icon, icon};
use gpui_kit_semantics::{NodeSpec, Role, Semantic};
use gpui_kit_theme::{ActiveTheme, ControlSize, Elevation, Space, Theme};
use crate::controls::button::{Button, ButtonJoin, ButtonVariant};
use crate::display::icon::flips;
use crate::foundation::direction::ActiveDirection;
use crate::foundation::{Ident, Pressable, Sizable, StyledExt};
use crate::motion;
use crate::overlay::focus::FocusTrap;
use crate::overlay::kbd::Kbd;
use crate::overlay::layer::{Overlay, Placement, surface};
use crate::overlay::popover::{self, MenuKey};
const PANEL_MIN_WIDTH: f32 = 200.0;
const GLYPH_SLOT: f32 = 16.0;
#[derive(Debug, Clone, PartialEq, Eq)]
enum MenuItemKind {
Command,
Check(bool),
Separator,
Section,
Submenu(Vec<MenuItem>),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MenuItem {
id: SharedString,
label: SharedString,
kind: MenuItemKind,
shortcut: Option<SharedString>,
icon: Option<Icon>,
disabled: bool,
}
impl MenuItem {
pub fn command(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
id: id.into(),
label: label.into(),
kind: MenuItemKind::Command,
shortcut: None,
icon: None,
disabled: false,
}
}
pub fn check(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
checked: bool,
) -> Self {
Self {
kind: MenuItemKind::Check(checked),
..Self::command(id, label)
}
}
pub fn separator(id: impl Into<SharedString>) -> Self {
Self {
kind: MenuItemKind::Separator,
..Self::command(id, "")
}
}
pub fn section(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
Self {
kind: MenuItemKind::Section,
..Self::command(id, label)
}
}
pub fn submenu(
id: impl Into<SharedString>,
label: impl Into<SharedString>,
items: impl IntoIterator<Item = MenuItem>,
) -> Self {
Self {
kind: MenuItemKind::Submenu(items.into_iter().collect()),
..Self::command(id, label)
}
}
pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
self.shortcut = Some(keystroke.into());
self
}
pub fn icon(mut self, glyph: Icon) -> Self {
self.icon = Some(glyph);
self
}
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
pub fn id(&self) -> &SharedString {
&self.id
}
pub fn label(&self) -> &SharedString {
&self.label
}
pub fn is_disabled(&self) -> bool {
self.disabled
}
fn is_selectable(&self) -> bool {
!self.disabled
&& matches!(
self.kind,
MenuItemKind::Command | MenuItemKind::Check(_) | MenuItemKind::Submenu(_)
)
}
fn children(&self) -> Option<&[MenuItem]> {
match &self.kind {
MenuItemKind::Submenu(children) => Some(children),
_ => None,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Activation {
Invoked(SharedString),
OpenedSubmenu,
Ignored,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
struct MenuState {
path: Vec<usize>,
active: Option<usize>,
}
fn level<'a>(items: &'a [MenuItem], path: &[usize]) -> &'a [MenuItem] {
let mut level = items;
for index in path {
match level.get(*index).and_then(MenuItem::children) {
Some(children) => level = children,
None => break,
}
}
level
}
fn item_at<'a>(items: &'a [MenuItem], path: &[usize]) -> Option<&'a MenuItem> {
let (last, parents) = path.split_last()?;
level(items, parents).get(*last)
}
fn first_selectable(items: &[MenuItem]) -> Option<usize> {
items.iter().position(MenuItem::is_selectable)
}
impl MenuState {
fn current<'a>(&self, items: &'a [MenuItem]) -> &'a [MenuItem] {
level(items, &self.path)
}
fn reset(&mut self) {
self.path.clear();
self.active = None;
}
fn step(&mut self, items: &[MenuItem], delta: isize) {
let level = self.current(items);
let count = level.len();
let Some(start) = popover::step(self.active, count, delta) else {
return;
};
let mut index = start;
for _ in 0..count {
if level[index].is_selectable() {
self.active = Some(index);
return;
}
index = (index as isize + delta.signum()).rem_euclid(count as isize) as usize;
}
}
fn jump(&mut self, items: &[MenuItem], letter: char) -> bool {
let labels: Vec<Option<&str>> = self
.current(items)
.iter()
.map(|item| item.is_selectable().then(|| item.label.as_ref()))
.collect();
match popover::jump_to(&labels, self.active, letter) {
Some(index) => {
self.active = Some(index);
true
}
None => false,
}
}
fn enter(&mut self, items: &[MenuItem]) -> bool {
let Some(active) = self.active else {
return false;
};
let Some(item) = self.current(items).get(active) else {
return false;
};
if !item.is_selectable() || item.children().is_none() {
return false;
}
self.path.push(active);
self.active = first_selectable(self.current(items));
true
}
fn leave(&mut self) -> bool {
match self.path.pop() {
Some(index) => {
self.active = Some(index);
true
}
None => false,
}
}
fn activate(&mut self, items: &[MenuItem], path: &[usize]) -> Activation {
let Some((last, parents)) = path.split_last() else {
return Activation::Ignored;
};
let Some(item) = level(items, parents).get(*last) else {
return Activation::Ignored;
};
if !item.is_selectable() {
return Activation::Ignored;
}
self.path = parents.to_vec();
self.active = Some(*last);
if item.children().is_some() {
self.path = path.to_vec();
self.active = first_selectable(self.current(items));
return Activation::OpenedSubmenu;
}
Activation::Invoked(item.id.clone())
}
fn path_to(items: &[MenuItem], id: &str) -> Option<Vec<usize>> {
for (index, item) in items.iter().enumerate() {
if item.id == id {
return Some(vec![index]);
}
if let Some(children) = item.children()
&& let Some(mut rest) = Self::path_to(children, id)
{
rest.insert(0, index);
return Some(rest);
}
}
None
}
}
type Activate<V> = Rc<dyn Fn(&mut V, Vec<usize>, &mut Window, &mut Context<V>)>;
fn panels<V: 'static>(
ident: &Ident,
items: &[MenuItem],
state: &MenuState,
root_parent: SharedString,
theme: &Theme,
cx: &mut Context<V>,
activate: Activate<V>,
) -> gpui::Div {
let depth = state.path.len();
let mut rendered: Vec<AnyElement> = Vec::with_capacity(depth + 1);
for level_depth in 0..=depth {
let base = state.path[..level_depth].to_vec();
let parent = if level_depth == 0 {
root_parent.clone()
} else {
match item_at(items, &base) {
Some(item) => ident.child(item.id.as_ref()).semantic_id(),
None => root_parent.clone(),
}
};
let active = if level_depth == depth {
state.active
} else {
None
};
let opened = (level_depth < depth).then(|| state.path[level_depth]);
rendered.push(panel(
ident,
level(items, &base),
&base,
parent,
active,
opened,
theme,
cx,
activate.clone(),
));
}
div()
.flex()
.flex_row()
.items_start()
.gap(px(theme.space(Space::Xs)))
.children(rendered)
}
#[allow(clippy::too_many_arguments)]
fn panel<V: 'static>(
ident: &Ident,
items: &[MenuItem],
base: &[usize],
parent: SharedString,
active: Option<usize>,
opened: Option<usize>,
theme: &Theme,
cx: &mut Context<V>,
activate: Activate<V>,
) -> AnyElement {
let rows = items
.iter()
.enumerate()
.map(|(index, item)| {
let mut path = base.to_vec();
path.push(index);
row(
ident,
item,
path,
parent.clone(),
active == Some(index),
opened == Some(index),
index,
items.len(),
theme,
cx,
activate.clone(),
)
})
.collect::<Vec<_>>();
surface(theme, Elevation::Overlay)
.min_w(px(PANEL_MIN_WIDTH))
.p_token(theme, Space::Xs)
.children(rows)
.into_any_element()
}
#[allow(clippy::too_many_arguments)]
fn row<V: 'static>(
ident: &Ident,
item: &MenuItem,
path: Vec<usize>,
parent: SharedString,
active: bool,
opened: bool,
index: usize,
count: usize,
theme: &Theme,
cx: &mut Context<V>,
activate: Activate<V>,
) -> AnyElement {
let row_ident = ident.child(item.id.as_ref());
match &item.kind {
MenuItemKind::Separator => popover::separator(theme)
.semantic_in(
cx,
NodeSpec::new(row_ident.semantic_id(), Role::Separator).parent(parent),
)
.into_any_element(),
MenuItemKind::Section => popover::heading(theme, item.label.as_ref())
.semantic_in(
cx,
NodeSpec::new(row_ident.semantic_id(), Role::Heading)
.parent(parent)
.level(2)
.text(item.label.clone()),
)
.into_any_element(),
kind => {
let checked = match kind {
MenuItemKind::Check(checked) => Some(*checked),
_ => None,
};
let submenu = item.children().is_some();
let mut spec = NodeSpec::new(row_ident.semantic_id(), Role::MenuItem)
.parent(parent)
.text(item.label.clone())
.disabled(item.disabled)
.hovered(active);
if let Some(checked) = checked {
spec = spec.checked(checked);
}
if submenu {
spec = spec.expanded(opened);
}
let glyph = match (checked, item.icon) {
(Some(true), _) => Some(Icon::Check),
(Some(false), _) => None,
(None, glyph) => glyph,
};
let row =
popover::menu_row(theme, false, active || opened)
.id(row_ident.element_id())
.when(active, |element| element.aria_active_descendant())
.when(!item.disabled, |element| {
element.cursor_pointer().pressable(cx)
})
.when(item.disabled, |element| {
element.opacity(theme.opacity.disabled)
})
.child(
div()
.flex()
.flex_none()
.w(px(GLYPH_SLOT))
.justify_center()
.children(glyph.map(|glyph| {
icon(glyph).size(px(14.0)).text_color(theme.colors.text)
})),
)
.child(div().flex_1().child(item.label.clone()))
.children(
item.shortcut
.clone()
.map(|keystroke| Kbd::new(keystroke).into_any_element()),
)
.when(submenu, |element| {
let flipped = flips(Icon::AltArrowRight, cx.layout_direction());
element.child(
icon(Icon::AltArrowRight)
.size(px(12.0))
.text_color(theme.colors.text_muted)
.when(flipped, |glyph| {
glyph.with_transformation(Transformation::scale(gpui::size(
-1.0, 1.0,
)))
}),
)
})
.when(!item.disabled, |element| {
element.on_click(cx.listener(move |view, _, window, cx| {
activate(view, path.clone(), window, cx);
}))
})
.semantic_in(cx, spec);
motion::row_in(row_ident.child("in").element_id(), theme, index, count, row)
.into_any_element()
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MenuEvent {
Opened,
Invoked(SharedString),
Dismissed,
Closed,
}
impl EventEmitter<MenuEvent> for Menu {}
pub struct Menu {
ident: Ident,
focus_handle: FocusHandle,
trigger_focus: FocusHandle,
trigger: SharedString,
trigger_icon: Option<Icon>,
trigger_name: Option<SharedString>,
trigger_variant: ButtonVariant,
trigger_join: ButtonJoin,
trigger_size: ControlSize,
items: Vec<MenuItem>,
placement: Placement,
open: bool,
pending_focus: bool,
state: MenuState,
trap: FocusTrap,
}
impl std::fmt::Debug for Menu {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("Menu")
.field("ident", &self.ident)
.field("trigger", &self.trigger)
.field("items", &self.items.len())
.field("open", &self.open)
.field("open_submenus", &self.state.path.len())
.finish()
}
}
impl Menu {
pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
ident: ident.into(),
focus_handle: cx.focus_handle(),
trigger_focus: cx.focus_handle(),
trigger: SharedString::default(),
trigger_icon: None,
trigger_name: None,
trigger_variant: ButtonVariant::Secondary,
trigger_join: ButtonJoin::Alone,
trigger_size: ControlSize::Md,
items: Vec::new(),
placement: Placement::Below,
open: false,
pending_focus: false,
state: MenuState::default(),
trap: FocusTrap::new(),
}
}
pub fn trigger(mut self, label: impl Into<SharedString>) -> Self {
self.trigger = label.into();
self
}
pub fn trigger_icon(mut self, glyph: Icon) -> Self {
self.trigger_icon = Some(glyph);
self
}
pub fn trigger_name(mut self, name: impl Into<SharedString>) -> Self {
self.trigger_name = Some(name.into());
self
}
pub fn set_trigger_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
self.trigger_name = Some(name.into());
cx.notify();
}
pub fn trigger_variant(mut self, variant: ButtonVariant) -> Self {
self.trigger_variant = variant;
self
}
pub fn trigger_join(mut self, join: ButtonJoin) -> Self {
self.trigger_join = join;
self
}
pub fn set_trigger_style(
&mut self,
variant: ButtonVariant,
size: ControlSize,
cx: &mut Context<Self>,
) {
self.trigger_variant = variant;
self.trigger_size = size;
cx.notify();
}
pub fn items(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
self.items = items.into_iter().collect();
self
}
pub fn placement(mut self, placement: Placement) -> Self {
self.placement = placement;
self
}
pub fn offered(&self) -> &[MenuItem] {
&self.items
}
pub fn set_items(&mut self, items: Vec<MenuItem>, cx: &mut Context<Self>) {
self.items = items;
self.state.reset();
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
return;
}
self.open = true;
self.pending_focus = true;
self.state.reset();
self.state.active = first_selectable(&self.items);
self.trap.engage(window, cx);
cx.emit(MenuEvent::Opened);
cx.notify();
}
pub fn open_submenu(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) -> bool {
let Some(path) = MenuState::path_to(&self.items, id) else {
return false;
};
if item_at(&self.items, &path)
.and_then(MenuItem::children)
.is_none()
{
return false;
}
self.open(window, cx);
self.state.path = path;
self.state.active = first_selectable(self.state.current(&self.items));
cx.notify();
true
}
pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
self.open = false;
self.pending_focus = false;
self.state.reset();
self.trap.release(window, cx);
self.trigger_focus.focus(window, cx);
cx.emit(MenuEvent::Closed);
cx.notify();
}
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
cx.emit(MenuEvent::Dismissed);
self.close(window, cx);
}
pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.open {
self.dismiss(window, cx);
} else {
self.open(window, cx);
}
}
fn take(&mut self, path: Vec<usize>, window: &mut Window, cx: &mut Context<Self>) {
match self.state.activate(&self.items, &path) {
Activation::Invoked(id) => {
cx.emit(MenuEvent::Invoked(id));
self.close(window, cx);
}
Activation::OpenedSubmenu => cx.notify(),
Activation::Ignored => {}
}
}
fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
let handled = handle_key(&mut self.state, &self.items, event);
match handled {
Handled::Moved => {
cx.notify();
cx.stop_propagation();
}
Handled::Activate(path) => {
self.take(path, window, cx);
cx.stop_propagation();
}
Handled::Close => {
self.dismiss(window, cx);
cx.stop_propagation();
}
Handled::None => {}
}
}
}
enum Handled {
Moved,
Activate(Vec<usize>),
Close,
None,
}
fn handle_key(state: &mut MenuState, items: &[MenuItem], event: &KeyDownEvent) -> Handled {
let key = popover::classify_key(
event.keystroke.key.as_str(),
event.keystroke.modifiers.platform,
event.keystroke.modifiers.control,
);
match key {
MenuKey::Down => {
state.step(items, 1);
Handled::Moved
}
MenuKey::Up => {
state.step(items, -1);
Handled::Moved
}
MenuKey::Right => {
if state.enter(items) {
Handled::Moved
} else {
Handled::None
}
}
MenuKey::Left => {
if state.leave() {
Handled::Moved
} else {
Handled::None
}
}
MenuKey::Enter => match state.active {
Some(active) => {
let mut path = state.path.clone();
path.push(active);
Handled::Activate(path)
}
None => Handled::Moved,
},
MenuKey::Escape => {
if state.leave() {
Handled::Moved
} else {
Handled::Close
}
}
_ => match popover::typed_letter(event.keystroke.key.as_str(), event.keystroke.modifiers) {
Some(letter) if state.jump(items, letter) => Handled::Moved,
_ => Handled::None,
},
}
}
impl Sizable for Menu {
fn control_size(mut self, size: ControlSize) -> Self {
self.trigger_size = size;
self
}
}
impl Focusable for Menu {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for Menu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
let menu = cx.entity().downgrade();
let glyph_only = self.trigger.is_empty();
let trigger = Button::new(self.ident.child("trigger"))
.label(self.trigger.clone())
.variant(self.trigger_variant)
.join(self.trigger_join)
.control_size(self.trigger_size)
.track_focus(&self.trigger_focus)
.when_some(self.trigger_icon, |button, glyph| {
match (glyph_only, self.trigger_name.clone()) {
(true, Some(name)) => button.icon_only(glyph, name),
_ => button.icon(glyph),
}
})
.when_some(self.trigger_name.clone(), |button, name| {
button.accessible_name(name)
})
.on_click(move |window, cx| {
menu.update(cx, |menu, cx| menu.toggle(window, cx)).ok();
})
.into_any_element();
let overlay = self.open.then(|| {
if self.pending_focus {
self.pending_focus = false;
self.focus_handle.focus(window, cx);
}
let activate: Activate<Self> =
Rc::new(|menu: &mut Self, path, window, cx| menu.take(path, window, cx));
let menu_id = self.ident.child("menu").semantic_id();
let menu_name = self
.trigger_name
.clone()
.unwrap_or_else(|| self.trigger.clone());
let content = panels(
&self.ident,
&self.items,
&self.state,
menu_id.clone(),
&theme,
cx,
activate,
)
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down_out(cx.listener(|menu, _, window, cx| menu.dismiss(window, cx)))
.semantic_in(
cx,
NodeSpec::new(menu_id, Role::Menu)
.parent(self.ident.semantic_id())
.text(menu_name)
.expanded(true)
.focus(&self.focus_handle),
);
Overlay::new(self.ident.child("overlay"))
.placement(self.placement)
.child(content)
.into_any_element()
});
popover::anchored_slot(self.placement, trigger, overlay).semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Group)
.expanded(self.open)
.focus(&self.focus_handle),
)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ContextMenuEvent {
Opened(SharedString),
Invoked(SharedString),
Dismissed,
Closed,
}
impl EventEmitter<ContextMenuEvent> for ContextMenu {}
type Content = Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>;
pub struct ContextMenu {
ident: Ident,
focus_handle: FocusHandle,
name: SharedString,
target: Option<SharedString>,
items: Vec<MenuItem>,
content: Option<Content>,
open: bool,
position: Point<Pixels>,
pending_focus: bool,
state: MenuState,
trap: FocusTrap,
}
impl std::fmt::Debug for ContextMenu {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("ContextMenu")
.field("ident", &self.ident)
.field("target", &self.target)
.field("items", &self.items.len())
.field("open", &self.open)
.finish()
}
}
impl ContextMenu {
pub fn new(ident: impl Into<Ident>, _window: &mut Window, cx: &mut Context<Self>) -> Self {
Self {
ident: ident.into(),
focus_handle: cx.focus_handle(),
name: SharedString::default(),
target: None,
items: Vec::new(),
content: None,
open: false,
position: gpui::point(px(0.0), px(0.0)),
pending_focus: false,
state: MenuState::default(),
trap: FocusTrap::new(),
}
}
pub fn menu(mut self, items: impl IntoIterator<Item = MenuItem>) -> Self {
self.items = items.into_iter().collect();
self
}
pub fn name(mut self, name: impl Into<SharedString>) -> Self {
self.name = name.into();
self
}
pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
self.name = name.into();
cx.notify();
}
pub fn target(mut self, target: impl Into<SharedString>) -> Self {
self.target = Some(target.into());
self
}
pub fn content(
mut self,
content: impl Fn(&mut Window, &mut App) -> AnyElement + 'static,
) -> Self {
self.content = Some(Rc::new(content));
self
}
pub fn set_items(&mut self, items: Vec<MenuItem>, cx: &mut Context<Self>) {
self.items = items;
self.state.reset();
cx.notify();
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn position(&self) -> Point<Pixels> {
self.position
}
pub fn open_at(
&mut self,
position: Point<Pixels>,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.position = position;
self.state.reset();
self.state.active = first_selectable(&self.items);
if !self.open {
self.open = true;
self.pending_focus = true;
self.trap.engage(window, cx);
}
let target = self
.target
.clone()
.unwrap_or_else(|| self.ident.semantic_id());
cx.emit(ContextMenuEvent::Opened(target));
cx.notify();
}
pub fn close(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
self.open = false;
self.pending_focus = false;
self.state.reset();
self.trap.release(window, cx);
cx.emit(ContextMenuEvent::Closed);
cx.notify();
}
pub fn dismiss(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
cx.emit(ContextMenuEvent::Dismissed);
self.close(window, cx);
}
fn take(&mut self, path: Vec<usize>, window: &mut Window, cx: &mut Context<Self>) {
match self.state.activate(&self.items, &path) {
Activation::Invoked(id) => {
cx.emit(ContextMenuEvent::Invoked(id));
self.close(window, cx);
}
Activation::OpenedSubmenu => cx.notify(),
Activation::Ignored => {}
}
}
fn on_key_down(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
if !self.open {
return;
}
match handle_key(&mut self.state, &self.items, event) {
Handled::Moved => {
cx.notify();
cx.stop_propagation();
}
Handled::Activate(path) => {
self.take(path, window, cx);
cx.stop_propagation();
}
Handled::Close => {
self.dismiss(window, cx);
cx.stop_propagation();
}
Handled::None => {}
}
}
}
impl Focusable for ContextMenu {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for ContextMenu {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let theme = cx.theme().clone();
let region = self.content.clone().map(|content| content(window, cx));
let menu_id = self.ident.child("menu").semantic_id();
let overlay = self.open.then(|| {
if self.pending_focus {
self.pending_focus = false;
self.focus_handle.focus(window, cx);
}
let activate: Activate<Self> =
Rc::new(|menu: &mut Self, path, window, cx| menu.take(path, window, cx));
let content = panels(
&self.ident,
&self.items,
&self.state,
menu_id.clone(),
&theme,
cx,
activate,
)
.track_focus(&self.focus_handle)
.on_key_down(cx.listener(Self::on_key_down))
.on_mouse_down_out(cx.listener(|menu, _, window, cx| menu.dismiss(window, cx)))
.semantic_in(
cx,
NodeSpec::new(menu_id.clone(), Role::Menu)
.parent(self.ident.semantic_id())
.text(self.name.clone())
.expanded(true)
.focus(&self.focus_handle),
);
Overlay::new(self.ident.child("overlay"))
.placement(Placement::At(self.position))
.child(content)
.into_any_element()
});
div()
.id(self.ident.element_id())
.on_mouse_down(
MouseButton::Right,
cx.listener(|menu, event: &MouseDownEvent, window, cx| {
menu.open_at(event.position, window, cx);
cx.stop_propagation();
}),
)
.children(region)
.children(overlay)
.semantic_in(
cx,
NodeSpec::new(self.ident.semantic_id(), Role::Region).expanded(self.open),
)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn items() -> Vec<MenuItem> {
vec![
MenuItem::section("edit.section", "Edit"),
MenuItem::command("edit.undo", "Undo").shortcut("cmd-z"),
MenuItem::separator("edit.rule"),
MenuItem::command("edit.paste", "Paste").disabled(true),
MenuItem::check("edit.wrap", "Wrap lines", true),
MenuItem::submenu(
"edit.share",
"Share",
[
MenuItem::command("edit.share.link", "Copy link"),
MenuItem::command("edit.share.mail", "Send by mail"),
],
),
]
}
fn cursor() -> MenuState {
MenuState {
path: Vec::new(),
active: first_selectable(&items()),
}
}
#[test]
fn the_cursor_starts_on_something_that_can_be_taken() {
assert_eq!(cursor().active, Some(1));
}
#[test]
fn moving_steps_over_labels_rules_and_refusals() {
let items = items();
let mut state = cursor();
state.step(&items, 1);
assert_eq!(
state.active,
Some(4),
"the rule and the refusal are skipped"
);
state.step(&items, 1);
assert_eq!(state.active, Some(5));
state.step(&items, 1);
assert_eq!(state.active, Some(1), "the cursor wraps past the section");
state.step(&items, -1);
assert_eq!(state.active, Some(5));
}
#[test]
fn typing_a_letter_jumps_to_the_next_row_that_starts_with_it() {
let items = items();
let mut state = cursor();
assert!(state.jump(&items, 'w'));
assert_eq!(state.active, Some(4));
assert!(!state.jump(&items, 'p'), "a refused row is not jumped to");
}
#[test]
fn a_submenu_is_entered_and_left_without_invoking_anything() {
let items = items();
let mut state = cursor();
state.active = Some(5);
assert!(state.enter(&items));
assert_eq!(state.path, vec![5]);
assert_eq!(state.active, Some(0));
state.step(&items, 1);
assert_eq!(state.active, Some(1));
assert!(state.leave());
assert_eq!(state.path, Vec::<usize>::new());
assert_eq!(state.active, Some(5), "the cursor returns to the submenu");
assert!(!state.leave(), "there is nothing left to fold away");
}
#[test]
fn a_row_that_cannot_be_taken_reports_nothing() {
let items = items();
let mut state = cursor();
assert_eq!(state.activate(&items, &[0]), Activation::Ignored);
assert_eq!(state.activate(&items, &[2]), Activation::Ignored);
assert_eq!(state.activate(&items, &[3]), Activation::Ignored);
}
#[test]
fn taking_a_checkable_row_reports_it_and_changes_nothing() {
let items = items();
let mut state = cursor();
assert_eq!(
state.activate(&items, &[4]),
Activation::Invoked("edit.wrap".into())
);
assert_eq!(
items[4].kind,
MenuItemKind::Check(true),
"the menu does not toggle its own item"
);
}
#[test]
fn acting_in_an_outer_panel_folds_the_open_submenu_away() {
let items = items();
let mut state = cursor();
state.activate(&items, &[5]);
assert_eq!(state.path, vec![5]);
assert_eq!(
state.activate(&items, &[1]),
Activation::Invoked("edit.undo".into())
);
assert_eq!(state.path, Vec::<usize>::new());
}
#[test]
fn an_item_is_addressed_by_identity_at_any_depth() {
let items = items();
assert_eq!(MenuState::path_to(&items, "edit.share"), Some(vec![5]));
assert_eq!(
MenuState::path_to(&items, "edit.share.mail"),
Some(vec![5, 1])
);
assert_eq!(MenuState::path_to(&items, "edit.nothing"), None);
}
}