Files
gpui-native-kit/crates/core/src/dropdown_menu.rs
T

455 lines
15 KiB
Rust

//! The dropdown menu: a push button with an attached menu of actions.
use std::cell::RefCell;
use std::rc::Rc;
use gpui::prelude::FluentBuilder as _;
use gpui::{
App, ClickEvent, DispatchPhase, ElementId, InteractiveElement as _, IntoElement, KeyDownEvent,
MouseButton, MouseDownEvent, ParentElement as _, RenderOnce, Role, SharedString,
StatefulInteractiveElement as _, Styled as _, Window, canvas, div, relative,
};
use gpui_native_icons::{Icon, IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size};
use gpui_native_utils::{focus_handle, focus_ring};
use crate::button::ButtonVariant;
use crate::common::{Sizable, h_flex};
use crate::menu::{ChangeHandler, MenuChrome, MenuItem, TriggerBoundsCell, anchored_menu_panel};
use crate::spinner::Spinner;
/// The main zone's activation handler, shared by pointer, Enter, and Space.
type ClickHandler = Box<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
/// A push button with an attached dropdown menu.
#[derive(IntoElement)]
pub struct DropdownMenu {
id: ElementId,
label: Option<SharedString>,
icon: Option<Icon>,
variant: ButtonVariant,
size: Size,
disabled: bool,
loading: bool,
tab_stop: bool,
open: bool,
a11y_label: Option<SharedString>,
on_click: Option<ClickHandler>,
on_open_change: Option<ChangeHandler>,
items: Option<ItemsFn>,
}
impl DropdownMenu {
/// Creates a closed dropdown menu rendered as a primary button.
pub fn new<I>(id: I) -> Self
where
I: Into<ElementId>,
{
Self {
id: id.into(),
label: None,
icon: None,
variant: ButtonVariant::default(),
size: Size::default(),
disabled: false,
loading: false,
tab_stop: true,
open: false,
a11y_label: None,
on_click: None,
on_open_change: None,
items: None,
}
}
/// Sets the visible label.
pub fn label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.label = Some(label.into());
self
}
/// Shows an icon beside the label.
pub fn icon<I>(mut self, icon: I) -> Self
where
I: Into<Icon>,
{
self.icon = Some(icon.into());
self
}
/// Sets the button's visual language.
pub fn variant(mut self, variant: ButtonVariant) -> Self {
self.variant = variant;
self
}
/// Whether the trigger is a keyboard tab stop, dense toolbars turn it off.
pub fn tab_stop(mut self, tab_stop: bool) -> Self {
self.tab_stop = tab_stop;
self
}
/// Renders the trigger inert, unfocusable, and dimmed.
pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled;
self
}
/// Shows the spinner in place of the icon and suppresses activation.
pub fn loading(mut self, loading: bool) -> Self {
self.loading = loading;
self
}
/// Sets the current open state.
pub fn open(mut self, open: bool) -> Self {
self.open = open;
self
}
/// Overrides the accessibility label, which defaults to the visible label.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
self.a11y_label = Some(label.into());
self
}
/// The main zone's action. Without one, activating the button opens the menu.
pub fn on_click<F>(mut self, handler: F) -> Self
where
F: Fn(&ClickEvent, &mut Window, &mut App) + 'static,
{
self.on_click = Some(Box::new(handler));
self
}
/// Reports the menu's next open state.
pub fn on_open_change<F>(mut self, handler: F) -> Self
where
F: Fn(bool, &mut Window, &mut App) + 'static,
{
self.on_open_change = Some(Rc::new(handler));
self
}
/// Sets the menu's items, built fresh each render.
pub fn items<F>(mut self, items: F) -> Self
where
F: Fn(&mut Window, &mut App) -> Vec<MenuItem> + 'static,
{
self.items = Some(Box::new(items));
self
}
}
impl Sizable for DropdownMenu {
fn apply_size(&mut self, size: Size) {
self.size = size;
}
}
impl RenderOnce for DropdownMenu {
fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
let theme = cx.theme();
let colors = theme.colors;
let ramps = theme.ramps;
let metrics = theme.sizes.resolve(self.size);
let radius = theme.radius.control;
let ring = colors.ring;
let show_border = theme.borders.buttons && self.variant != ButtonVariant::Ghost;
let (panel_chrome, enter_duration) = MenuChrome::read(cx);
let (background, foreground, hover, pressed) = match self.variant {
ButtonVariant::Primary => (
Some(ramps.primary.base),
colors.primary_foreground,
ramps.primary.hover,
ramps.primary.pressed,
),
ButtonVariant::Secondary => (
Some(colors.secondary),
colors.secondary_foreground,
ramps.secondary.hover,
ramps.secondary.pressed,
),
ButtonVariant::Ghost => (
None,
colors.foreground,
colors.muted_hover,
ramps.secondary.pressed,
),
ButtonVariant::Destructive => (
Some(ramps.destructive.base),
colors.destructive_foreground,
ramps.destructive.hover,
ramps.destructive.pressed,
),
};
let border = if show_border {
colors.border
} else {
background.unwrap_or_else(gpui::transparent_black)
};
let split = self.on_click.is_some();
let inert = self.disabled || self.loading;
let label = self
.a11y_label
.clone()
.unwrap_or_else(|| SharedString::from(self.id.to_string()));
let a11y_label = self.a11y_label.or_else(|| self.label.clone());
let focus = focus_handle(&self.id, window, cx).tab_stop(self.tab_stop);
// How the menu was last opened, read by the frame that mounts the panel.
let keyboard_open =
window.use_keyed_state((self.id.clone(), "dropdown-open-origin"), cx, |_, _| false);
let on_open: ChangeHandler = {
let focus = focus.clone();
let on_open_change = self.on_open_change.clone();
let keyboard_open = keyboard_open.clone();
Rc::new(move |next, window, cx| {
if !next {
window.focus(&focus, cx);
keyboard_open.update(cx, |keyboard_open, _| *keyboard_open = false);
}
if let Some(on_open_change) = &on_open_change {
on_open_change(next, window, cx);
}
})
};
let trigger_bounds: TriggerBoundsCell = Rc::new(RefCell::new(None));
let chevron_bounds: TriggerBoundsCell = Rc::new(RefCell::new(None));
let main = h_flex()
.id(self.id.clone())
.debug_selector({
let id = self.id.clone();
move || id.to_string()
})
.justify_center()
.h_full()
.gap_2()
.line_height(relative(1.0))
.text_size(metrics.text_size)
.text_color(foreground)
.role(Role::Button)
.when_some(a11y_label, |zone, label| zone.aria_label(label))
.aria_expanded(self.open)
.px(if split && self.icon.is_none() {
metrics.padding_x
} else {
metrics.dense_padding_x
})
.when(split, |zone| zone.rounded_l(radius))
.when(!split, |zone| {
zone.rounded(radius)
.when(self.open, |zone| zone.bg(pressed))
.child(
icon(IconRole::ChevronDown)
.size(metrics.dense_icon_size)
.color(foreground),
)
})
.when(self.loading, |zone| {
zone.child(
Spinner::new((self.id.clone(), "spinner"))
.with_size(self.size)
.variant(crate::spinner::SpinnerVariant::Icon)
.color(foreground),
)
})
.when(!self.loading, |zone| {
zone.when_some(self.icon, |zone, icon| {
zone.child(icon.size(metrics.icon_size).color(foreground))
})
})
.when_some(self.label, |zone, label| zone.child(label));
let main = if inert {
main
} else {
main.track_focus(&focus)
.cursor_pointer()
.hover(move |style| style.bg(hover))
.active(move |style| style.bg(pressed))
.on_click({
let on_click = self.on_click;
let on_open = on_open.clone();
let keyboard_open = keyboard_open.clone();
let open = self.open;
move |event, window, cx| match (on_click.as_ref(), event) {
(Some(on_click), _) => on_click(event, window, cx),
(None, ClickEvent::Keyboard(_)) => {
keyboard_open.update(cx, |keyboard_open, _| *keyboard_open = true);
on_open(!open, window, cx);
}
(None, _) => {}
}
})
.on_key_down({
let on_open = on_open.clone();
let keyboard_open = keyboard_open.clone();
move |event: &KeyDownEvent, window, cx| {
if event.keystroke.key == "down" {
keyboard_open.update(cx, |keyboard_open, _| *keyboard_open = true);
on_open(true, window, cx);
cx.stop_propagation();
}
}
})
};
let chevron_zone = h_flex()
.id((self.id.clone(), "chevron"))
.debug_selector({
let id = self.id.clone();
move || format!("{id}-chevron")
})
.relative()
.h_full()
.px(metrics.dense_padding_x)
.rounded_r(radius)
.rounded_l_none()
.border_l_1()
.border_color(border)
.when(self.open, |zone| zone.bg(pressed))
.when(!inert, |zone| {
zone.cursor_pointer()
.hover(move |style| style.bg(hover))
.active(move |style| style.bg(pressed))
})
.child(
icon(IconRole::ChevronDown)
.size(metrics.dense_icon_size)
.color(foreground),
)
.child(
canvas(
{
let cell = chevron_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
|_, _, _, _| {},
)
.absolute()
.inset_0(),
);
let trigger = h_flex()
.h(metrics.control_height + BORDER_WIDTH * 2.0)
.rounded(radius)
.border_1()
.border_color(border)
.when_some(background, |trigger, background| trigger.bg(background))
.when(self.disabled, |trigger| trigger.opacity(0.5))
.when(!inert, |trigger| {
trigger
.track_focus(&focus)
.focus_visible(move |style| style.shadow(focus_ring(ring)))
})
.child(main);
let trigger = if split {
trigger.child(chevron_zone)
} else {
trigger
};
let mut wrapper = div()
.relative()
.child(
canvas(
{
let cell = trigger_bounds.clone();
move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds)
},
{
let on_open = on_open.clone();
let trigger_bounds = trigger_bounds.clone();
let chevron_bounds = chevron_bounds.clone();
let keyboard_open = keyboard_open.clone();
let open = self.open;
move |_, _, window, _cx| {
window.on_mouse_event({
let on_open = on_open.clone();
let keyboard_open = keyboard_open.clone();
move |event: &MouseDownEvent, phase, window, cx| {
if phase != DispatchPhase::Bubble
|| event.button != MouseButton::Left
|| inert
{
return;
}
let in_trigger = trigger_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if !in_trigger {
return;
}
let in_chevron = chevron_bounds
.borrow()
.is_some_and(|bounds| bounds.contains(&event.position));
if !open && split && !in_chevron {
return;
}
keyboard_open
.update(cx, |keyboard_open, _| *keyboard_open = false);
on_open(!open, window, cx);
cx.stop_propagation();
}
});
}
},
)
.absolute()
.inset_0(),
)
.child(trigger);
let items = if self.open {
self.items
.map(|build| Rc::from(build(window, cx)))
.unwrap_or_else(|| Rc::from([]))
} else {
Rc::from([])
};
if let Some(panel) = anchored_menu_panel(
window,
cx,
&self.id,
self.open,
items,
None,
*keyboard_open.read(cx),
Some(on_open),
label,
None,
&trigger_bounds,
&panel_chrome,
enter_duration,
) {
wrapper = wrapper.child(panel);
}
wrapper
}
}