OpenCut/apps/desktop/src/components/context_menu.rs

279 lines
8.7 KiB
Rust

use std::rc::Rc;
use gpui::{
AnyElement, App, ElementId, Entity, IntoElement, MouseButton, Pixels, Point, Render,
SharedString, Window, anchored, deferred, div, px,
};
use crate::components::prelude::*;
type SelectHandler = Rc<dyn Fn(&mut Window, &mut App) + 'static>;
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum ContextMenuItemVariant {
#[default]
Default,
Destructive,
}
pub(crate) enum ContextMenuItem {
Entry {
label: SharedString,
shortcut: Option<SharedString>,
checked: Option<bool>,
disabled: bool,
variant: ContextMenuItemVariant,
on_select: SelectHandler,
},
Label(SharedString),
Separator,
}
impl ContextMenuItem {
pub(crate) fn entry(label: impl Into<SharedString>) -> Self {
Self::Entry {
label: label.into(),
shortcut: None,
checked: None,
disabled: false,
variant: ContextMenuItemVariant::Default,
on_select: Rc::new(|_, _| {}),
}
}
pub(crate) fn label(label: impl Into<SharedString>) -> Self {
Self::Label(label.into())
}
pub(crate) fn separator() -> Self {
Self::Separator
}
pub(crate) fn shortcut(mut self, shortcut: impl Into<SharedString>) -> Self {
if let Self::Entry {
shortcut: item_shortcut,
..
} = &mut self
{
*item_shortcut = Some(shortcut.into());
}
self
}
pub(crate) fn checked(mut self, checked: bool) -> Self {
if let Self::Entry {
checked: item_checked,
..
} = &mut self
{
*item_checked = Some(checked);
}
self
}
pub(crate) fn disabled(mut self, disabled: bool) -> Self {
if let Self::Entry {
disabled: item_disabled,
..
} = &mut self
{
*item_disabled = disabled;
}
self
}
pub(crate) fn variant(mut self, variant: ContextMenuItemVariant) -> Self {
if let Self::Entry {
variant: item_variant,
..
} = &mut self
{
*item_variant = variant;
}
self
}
pub(crate) fn on_select(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
if let Self::Entry {
on_select: item_handler,
..
} = &mut self
{
*item_handler = Rc::new(handler);
}
self
}
}
pub(crate) struct ContextMenu {
items: Vec<ContextMenuItem>,
position: Option<Point<Pixels>>,
}
impl ContextMenu {
pub(crate) fn new(items: impl IntoIterator<Item = ContextMenuItem>) -> Self {
Self {
items: items.into_iter().collect(),
position: None,
}
}
fn open(&mut self, position: Point<Pixels>, cx: &mut gpui::Context<Self>) {
self.position = Some(position);
cx.notify();
}
fn close(&mut self, cx: &mut gpui::Context<Self>) {
self.position = None;
cx.notify();
}
fn activate(&mut self, index: usize, window: &mut Window, cx: &mut gpui::Context<Self>) {
let Some(ContextMenuItem::Entry {
disabled,
on_select,
..
}) = self.items.get(index)
else {
return;
};
if *disabled {
return;
}
let on_select = Rc::clone(on_select);
self.close(cx);
on_select(window, cx);
}
}
impl Render for ContextMenu {
fn render(&mut self, window: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
let Some(position) = self.position else {
return div().into_any_element();
};
let theme = theme(window);
let colors = theme.colors;
let items = self
.items
.iter()
.enumerate()
.map(|(index, item)| -> AnyElement {
match item {
ContextMenuItem::Entry {
label,
shortcut,
checked,
disabled,
variant,
..
} => {
let hover = if *variant == ContextMenuItemVariant::Destructive {
colors.destructive.opacity(0.1)
} else {
colors.accent
};
div()
.id(("context-menu-item", index))
.flex()
.items_center()
.gap(px(8.0))
.min_h(px(28.0))
.px(px(8.0))
.py(px(4.0))
.rounded(px(5.0))
.text_xs()
.when(
*variant == ContextMenuItemVariant::Destructive,
|this| this.text_destructive(theme),
)
.when(
*variant != ContextMenuItemVariant::Destructive,
|this| this.text_popover_foreground(theme),
)
.when(*disabled, |this| this.opacity(0.5).cursor_not_allowed())
.when(!*disabled, |this| {
this.cursor_default()
.hover(move |style| style.bg(hover))
.on_click(cx.listener(move |this, _, window, cx| {
this.activate(index, window, cx);
cx.stop_propagation();
}))
})
.child(
div()
.w(px(12.0))
.text_center()
.child(if checked == &Some(true) { "" } else { "" }),
)
.child(div().flex_1().child(label.clone()))
.when_some(shortcut.clone(), |this, shortcut| {
this.child(
div()
.text_size(px(10.0))
.text_muted_foreground(theme)
.child(shortcut),
)
})
.into_any_element()
}
ContextMenuItem::Label(label) => div()
.px(px(8.0))
.py(px(6.0))
.text_xs()
.text_muted_foreground(theme)
.child(label.clone())
.into_any_element(),
ContextMenuItem::Separator => div()
.h(px(1.0))
.mx(px(-4.0))
.my(px(4.0))
.bg_border_subtle(theme)
.into_any_element(),
}
})
.collect::<Vec<_>>();
deferred(
anchored()
.position(position)
.snap_to_window_with_margin(px(8.0))
.child(
div()
.id("context-menu-content")
.occlude()
.min_w(px(160.0))
.max_h(window.viewport_size().height - px(16.0))
.overflow_y_scroll()
.p(px(4.0))
.rounded_theme(theme)
.bg_popover(theme)
.text_popover_foreground(theme)
.border_1()
.border_foreground_muted(theme)
.shadow_md()
.on_mouse_down_out(cx.listener(|this, _, _, cx| this.close(cx)))
.children(items),
),
)
.with_priority(1)
.into_any_element()
}
}
pub(crate) fn context_menu_trigger(
id: impl Into<ElementId>,
child: impl IntoElement,
menu: Entity<ContextMenu>,
) -> impl IntoElement {
div().id(id).size_full().child(child).on_mouse_down(
MouseButton::Right,
move |event, window, cx| {
window.prevent_default();
cx.stop_propagation();
menu.update(cx, |menu, cx| menu.open(event.position, cx));
},
)
}