From d8663593b6485db28888bf3915a91c2c08bf0d4e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 8 Nov 2026 17:03:38 +0700 Subject: [PATCH] feat: add dropdown menu component --- crates/core/Cargo.toml | 4 +- crates/core/src/dropdown_menu.rs | 450 +++++++++++++++++++++++++++++ crates/core/src/lib.rs | 3 + crates/core/src/menu.rs | 403 +++++++++++++++----------- crates/core/tests/context_menu.rs | 11 +- crates/core/tests/dropdown_menu.rs | 324 +++++++++++++++++++++ crates/core/tests/menu.rs | 26 +- crates/core/tests/select.rs | 20 +- docs/CHECKLIST.md | 16 +- examples/gallery/src/main.rs | 184 ++++++++++++ 10 files changed, 1244 insertions(+), 197 deletions(-) create mode 100644 crates/core/src/dropdown_menu.rs create mode 100644 crates/core/tests/dropdown_menu.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4bf28f9..61254eb 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -42,13 +42,14 @@ full = [ "command_palette", "tooltip", "menu", + "dropdown_menu", "select", "dialog", "empty_state", "alert", "toast", ] -button = [] +button = ["spinner"] checkbox = [] context_menu = ["menu"] switch = [] @@ -73,6 +74,7 @@ combobox = ["input", "scroll_area"] command_palette = ["input", "scroll_area"] tooltip = [] menu = ["scroll_area"] +dropdown_menu = ["menu", "button"] select = ["menu"] dialog = [] empty_state = [] diff --git a/crates/core/src/dropdown_menu.rs b/crates/core/src/dropdown_menu.rs new file mode 100644 index 0000000..26a154b --- /dev/null +++ b/crates/core/src/dropdown_menu.rs @@ -0,0 +1,450 @@ +//! 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, hsla, relative, +}; +use gpui_native_icons::{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; +type ItemsFn = Box Vec>; + +/// A push button with an attached dropdown menu. +#[derive(IntoElement)] +pub struct DropdownMenu { + id: ElementId, + label: Option, + icon: Option, + variant: ButtonVariant, + size: Size, + disabled: bool, + loading: bool, + tab_stop: bool, + open: bool, + a11y_label: Option, + on_click: Option, + on_open_change: Option, + items: Option, +} + +impl DropdownMenu { + /// Creates a closed dropdown menu rendered as a primary button. + pub fn new(id: I) -> Self + where + I: Into, + { + 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(mut self, label: L) -> Self + where + L: Into, + { + self.label = Some(label.into()); + self + } + + /// Shows an icon beside the label. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + 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(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } + + /// The main zone's action. Without one, activating the button opens the menu. + pub fn on_click(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(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(mut self, items: F) -> Self + where + F: Fn(&mut Window, &mut App) -> Vec + '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(|| hsla(0.0, 0.0, 0.0, 0.0)) + }; + + 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("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, role| { + zone.child(icon(role).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| build(window, cx)) + .unwrap_or_default() + } else { + Vec::new() + }; + + 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 + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8a97f9e..b5d10a3 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -57,6 +57,9 @@ pub mod empty_state; #[cfg(feature = "menu")] pub mod menu; +#[cfg(feature = "dropdown_menu")] +pub mod dropdown_menu; + #[cfg(feature = "alert")] pub mod alert; diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index f6aea86..cc5d414 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -18,8 +18,10 @@ use crate::scroll_area::{ScrollArea, ScrollAxes}; /// Runs the item's action. The menu closes itself before the handler runs. pub type ItemHandler = Rc; -/// Reports the next open state of a [`Menu`]: `true` to open, `false` to close. -type ChangeHandler = Rc; +/// Reports the next open state of a menu: `true` to open, `false` to close. +pub(crate) type ChangeHandler = Rc; +/// Bounds captured during the last paint, shared with the dismissal listener. +pub(crate) type TriggerBoundsCell = Rc>>>; type TriggerFn = Box AnyElement>; type ItemsFn = Box Vec>; @@ -124,12 +126,13 @@ impl MenuItem { /// Menu chrome styles. #[derive(Clone)] -struct MenuChrome { +pub(crate) struct MenuChrome { surface: gpui::Hsla, border: gpui::Hsla, panel_border: gpui::Hsla, foreground: gpui::Hsla, muted_foreground: gpui::Hsla, + muted_hover: gpui::Hsla, primary: gpui::Hsla, primary_foreground: gpui::Hsla, destructive: gpui::Hsla, @@ -158,6 +161,40 @@ pub struct Menu { items: Option, } +impl MenuChrome { + /// Reads the menu's chrome and enter duration from the active theme. + pub(crate) fn read(cx: &App) -> (Self, std::time::Duration) { + let theme = cx.theme(); + ( + Self { + surface: theme.colors.surface, + border: theme.colors.border, + panel_border: if theme.borders.overlays { + theme.colors.border + } else { + theme.colors.surface + }, + foreground: theme.colors.foreground, + muted_foreground: theme.colors.muted_foreground, + muted_hover: theme.colors.muted_hover, + primary: theme.ramps.primary.base, + primary_foreground: theme.colors.primary_foreground, + destructive: theme.ramps.destructive.base, + destructive_foreground: theme.colors.destructive_foreground, + radius_overlay: theme.radius.overlay, + radius_sm: theme.radius.sm, + icon_size: theme.sizes.medium.icon_size, + dense_icon_size: theme.sizes.medium.dense_icon_size, + menu_item_height: theme.sizes.medium.menu_item_height, + spacing_unit: theme.spacing.unit, + text_size: theme.sizes.medium.text_size, + shadow: theme.elevations.popover.shadows(), + }, + theme.motion.overlay, + ) + } +} + impl Menu { /// Creates a closed menu. pub fn new(id: I) -> Self @@ -240,41 +277,9 @@ impl Menu { impl RenderOnce for Menu { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let (chrome, enter_duration) = { - let theme = cx.theme(); - ( - MenuChrome { - surface: theme.colors.surface, - border: theme.colors.border, - panel_border: if theme.borders.overlays { - theme.colors.border - } else { - theme.colors.surface - }, - foreground: theme.colors.foreground, - muted_foreground: theme.colors.muted_foreground, - primary: theme.ramps.primary.base, - primary_foreground: theme.colors.primary_foreground, - destructive: theme.ramps.destructive.base, - destructive_foreground: theme.colors.destructive_foreground, - radius_overlay: theme.radius.overlay, - radius_sm: theme.radius.sm, - icon_size: theme.sizes.medium.icon_size, - dense_icon_size: theme.sizes.medium.dense_icon_size, - menu_item_height: theme.sizes.medium.menu_item_height, - spacing_unit: theme.spacing.unit, - text_size: theme.sizes.medium.text_size, - shadow: theme.elevations.popover.shadows(), - }, - theme.motion.overlay, - ) - }; - + let (chrome, enter_duration) = MenuChrome::read(cx); let trigger_bounds: Rc>>> = Rc::new(RefCell::new(None)); - let selection = window.use_keyed_state("menu-selection", cx, |_, _| 0usize); - let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| self.open); - let fallback_label = self .a11y_label .clone() @@ -293,13 +298,9 @@ impl RenderOnce for Menu { let open = self.open; let trigger_button = self.trigger_button; let cell = trigger_bounds.clone(); - let was_open = was_open.clone(); - move |_, _, window, cx| { - let trigger_bounds = cell; - if !open { - was_open.update(cx, |was_open, _| *was_open = false); - } + move |_, _, window, _cx| { + let trigger_bounds = cell; window.on_mouse_event({ let on_change = on_change.clone(); @@ -333,128 +334,179 @@ impl RenderOnce for Menu { wrapper.child(trigger(window, cx)) }); - if self.open { - let on_change = self.on_change.clone(); - let label = fallback_label.clone(); - - let items = match self.items { + let items = if self.open { + match self.items { Some(build) => build(window, cx), None => Vec::new(), - }; - - // Keyboard focus rides the panel across frames. - let focus = gpui_native_utils::focus_handle(&self.id, window, cx); - let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new()); - - let selected = if *was_open.read(cx) { - *selection.read(cx) - } else if let Some(requested) = self.initial_selection { - requested.min(items.len().saturating_sub(1)) - } else { - let first = items.iter().position(|item| item.selectable()).unwrap_or(0); - selection.update(cx, |selection, _| *selection = first); - first - }; - was_open.update(cx, |was_open, _| *was_open = true); - - let rows: Vec = items - .iter() - .enumerate() - .map(|(index, item)| { - render_item( - item, - index, - selected == index, - &selection, - &on_change, - &chrome, - ) - }) - .collect(); - - let panel = v_flex() - .id("menu-panel") - .debug_selector(|| "menu-panel".to_string()) - .occlude() - .track_focus(&focus) - .role(Role::Menu) - .aria_label(label) - .min_w_56() - .p(chrome.spacing_unit) - .bg(chrome.surface) - .text_color(chrome.foreground) - .rounded(chrome.radius_overlay) - .border_1() - .border_color(chrome.panel_border) - .shadow(chrome.shadow.clone()) - .overflow_hidden() - .on_mouse_down_out({ - let on_change = on_change.clone(); - let trigger_bounds = trigger_bounds.clone(); - - move |event, window, cx| { - if trigger_bounds - .borrow() - .is_some_and(|bounds| bounds.contains(&event.position)) - { - return; - } - if let Some(on_change) = &on_change { - on_change(false, window, cx); - } - } - }) - .on_key_down({ - let items = items.clone(); - let on_change = on_change.clone(); - let selection = selection.clone(); - move |event: &KeyDownEvent, window, cx| { - handle_menu_key(event, &items, &selection, &on_change, window, cx); - } - }) - .child( - ScrollArea::new("menu-items") - .handle(scroll.read(cx).clone()) - .axes(ScrollAxes::Vertical) - .max_h(px(320.)) - .children(rows), - ) - .child( - canvas(|_, _, _| {}, { - let focus = focus.clone(); - move |_, _, window, cx| { - if !focus.contains_focused(window, cx) { - window.focus(&focus, cx); - } - } - }) - .absolute() - .inset_0(), - ); - - let anchored = match self.open_at { - Some(position) => anchored().position(position), - None => anchored(), } - .offset(point(px(0.), chrome.spacing_unit)); + } else { + Vec::new() + }; - wrapper = wrapper.child(deferred(anchored.child(panel.with_animation( - "menu-enter", - Animation::new(enter_duration).with_easing(ease_out_quint()), - |panel, delta| panel.opacity(delta), - )))); + if let Some(panel) = anchored_menu_panel( + window, + cx, + &self.id, + self.open, + items, + self.initial_selection, + false, + self.on_change, + fallback_label, + self.open_at, + &trigger_bounds, + &chrome, + enter_duration, + ) { + wrapper = wrapper.child(panel); } wrapper } } +/// Assembles the floating menu panel. +#[allow(clippy::too_many_arguments)] +pub(crate) fn anchored_menu_panel( + window: &mut Window, + cx: &mut App, + id: &ElementId, + open: bool, + items: Vec, + initial_selection: Option, + preselect: bool, + on_change: Option, + label: SharedString, + position: Option>, + trigger_bounds: &TriggerBoundsCell, + chrome: &MenuChrome, + enter_duration: std::time::Duration, +) -> Option { + let was_open = window.use_keyed_state("menu-was-open", cx, |_, _| false); + + if !open { + was_open.update(cx, |was_open, _| *was_open = false); + return None; + } + + let on_change = on_change.clone(); + // Keyboard focus rides the panel across frames + let focus = gpui_native_utils::focus_handle(&(id.clone(), "panel").into(), window, cx); + let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new()); + let selection = window.use_keyed_state("menu-selection", cx, |_, _| None); + + let selected = if *was_open.read(cx) { + *selection.read(cx) + } else { + let requested = initial_selection + .map(|index| index.min(items.len().saturating_sub(1))) + .or_else(|| { + preselect + .then(|| items.iter().position(|item| item.selectable())) + .flatten() + }); + selection.update(cx, |selection, _| *selection = requested); + requested + }; + was_open.update(cx, |was_open, _| *was_open = true); + + let rows: Vec = items + .iter() + .enumerate() + .map(|(index, item)| { + render_item( + item, + index, + selected == Some(index), + &selection, + &on_change, + chrome, + ) + }) + .collect(); + + let panel = v_flex() + .id("menu-panel") + .debug_selector(|| "menu-panel".to_string()) + .occlude() + .track_focus(&focus) + .role(Role::Menu) + .aria_label(label) + .min_w_56() + .p(chrome.spacing_unit) + .bg(chrome.surface) + .text_color(chrome.foreground) + .rounded(chrome.radius_overlay) + .border_1() + .border_color(chrome.panel_border) + .shadow(chrome.shadow.clone()) + .overflow_hidden() + .on_mouse_down_out({ + let on_change = on_change.clone(); + let trigger_bounds = trigger_bounds.clone(); + + move |event, window, cx| { + if trigger_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)) + { + return; + } + if let Some(on_change) = &on_change { + on_change(false, window, cx); + } + } + }) + .on_key_down({ + let items = items.clone(); + let on_change = on_change.clone(); + let selection = selection.clone(); + move |event: &KeyDownEvent, window, cx| { + handle_menu_key(event, &items, &selection, &on_change, window, cx); + } + }) + .child( + ScrollArea::new("menu-items") + .handle(scroll.read(cx).clone()) + .axes(ScrollAxes::Vertical) + .max_h(px(320.)) + .child(v_flex().gap_0p5().children(rows)), + ) + .child( + canvas(|_, _, _| {}, { + let focus = focus.clone(); + move |_, _, window, cx| { + if !focus.contains_focused(window, cx) { + window.focus(&focus, cx); + } + } + }) + .absolute() + .inset_0(), + ); + + let anchored = match position { + Some(position) => anchored().position(position), + None => anchored(), + } + .offset(point(px(0.), chrome.spacing_unit)); + + Some( + deferred(anchored.child(panel.with_animation( + "menu-enter", + Animation::new(enter_duration).with_easing(ease_out_quint()), + |panel, delta| panel.opacity(delta), + ))) + .into_any_element(), + ) +} + /// Renders one menu row. fn render_item( item: &MenuItem, index: usize, selected: bool, - selection: &Entity, + selection: &Entity>, on_change: &Option, chrome: &MenuChrome, ) -> AnyElement { @@ -469,6 +521,7 @@ fn render_item( MenuItemKind::Action => { let label_color = row_color(item, selected, chrome); let disabled = item.disabled; + let selectable = item.selectable(); let destructive = item.destructive; let checked = item.checked; let shortcut = item.shortcut.clone(); @@ -495,17 +548,15 @@ fn render_item( chrome.primary }; row.bg(highlight) - } else if destructive { - row.hover(|row| row.bg(chrome.destructive)) } else { - row.hover(|row| row.bg(chrome.muted_foreground)) + row.hover(|row| row.bg(chrome.muted_hover)) } }) .on_mouse_move({ let selection = selection.clone(); move |_, _window, cx| { - if *selection.read(cx) != index { - selection.update(cx, |selection, _| *selection = index); + if selectable && *selection.read(cx) != Some(index) { + selection.update(cx, |selection, _| *selection = Some(index)); } } }) @@ -579,7 +630,7 @@ fn row_color(item: &MenuItem, selected: bool, chrome: &MenuChrome) -> gpui::Hsla fn handle_menu_key( event: &KeyDownEvent, items: &[MenuItem], - selection: &Entity, + selection: &Entity>, on_change: &Option, window: &mut Window, cx: &mut App, @@ -592,32 +643,34 @@ fn handle_menu_key( .collect(); let current = *selection.read(cx); - let position = selectable.iter().position(|index| *index == current); + let position = + current.and_then(|current| selectable.iter().position(|index| *index == current)); match event.keystroke.key.as_str() { - "down" => { - let next = match position { - Some(pos) => (pos + 1).min(selectable.len() - 1), - None => 0, + "down" | "up" | "home" | "end" => { + if selectable.is_empty() { + return; + } + let next = match event.keystroke.key.as_str() { + "down" => match position { + Some(pos) => (pos + 1).min(selectable.len() - 1), + None => 0, + }, + "up" => match position { + Some(pos) => pos.saturating_sub(1), + None => selectable.len() - 1, + }, + "home" => 0, + _ => selectable.len() - 1, }; - selection.update(cx, |selection, _| *selection = selectable[next]); + selection.update(cx, |selection, _| *selection = Some(selectable[next])); } - "up" => { - let next = match position { - Some(pos) => pos.saturating_sub(1), - None => selectable.len() - 1, - }; - selection.update(cx, |selection, _| *selection = selectable[next]); - } - "home" => selection.update(cx, |selection, _| *selection = selectable[0]), - "end" => selection.update(cx, |selection, _| { - *selection = selectable[selectable.len() - 1] - }), "enter" | "space" => { - if let Some(item) = items.get(current) + if let Some(current) = current + && let Some(item) = items.get(current) && item.selectable() { - if let Some(on_change) = on_change { + if let Some(on_change) = &on_change { on_change(false, window, cx); } if let Some(handler) = &item.handler { @@ -626,7 +679,7 @@ fn handle_menu_key( } } "escape" => { - if let Some(on_change) = on_change { + if let Some(on_change) = &on_change { on_change(false, window, cx); } } diff --git a/crates/core/tests/context_menu.rs b/crates/core/tests/context_menu.rs index 53fad98..f7888c8 100644 --- a/crates/core/tests/context_menu.rs +++ b/crates/core/tests/context_menu.rs @@ -78,9 +78,14 @@ fn right_click_opens_the_menu_and_items_activate() { right_click(&mut visual, point(px(100.), px(30.))); draw(&mut visual); - // The panel claims focus, so the first item is keyboard-highlighted. + // A pointer open arms no row, so Enter alone fires nothing. visual.simulate_keystrokes("enter"); draw(&mut visual); + assert_eq!(*actions.borrow(), Vec::<&str>::new()); + + // The first down arms Rename; enter fires it and closes the menu. + visual.simulate_keystrokes("down enter"); + draw(&mut visual); assert_eq!(*actions.borrow(), vec!["rename"]); // The menu closed with the choice, so Enter reports nothing more. @@ -88,10 +93,10 @@ fn right_click_opens_the_menu_and_items_activate() { draw(&mut visual); assert_eq!(*actions.borrow(), vec!["rename"]); - // Right-click again reopens; Down reaches the second item. + // Right-click again reopens; two downs reach the second item. right_click(&mut visual, point(px(100.), px(30.))); draw(&mut visual); - visual.simulate_keystrokes("down"); + visual.simulate_keystrokes("down down"); visual.simulate_keystrokes("enter"); draw(&mut visual); assert_eq!(*actions.borrow(), vec!["rename", "delete"]); diff --git a/crates/core/tests/dropdown_menu.rs b/crates/core/tests/dropdown_menu.rs new file mode 100644 index 0000000..227b6c1 --- /dev/null +++ b/crates/core/tests/dropdown_menu.rs @@ -0,0 +1,324 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + AnyWindowHandle, AppContext as _, Context, IntoElement, KeyDownEvent, KeyUpEvent, Keystroke, + Modifiers, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, point, px, +}; +use gpui_native_core::button::ButtonVariant; +use gpui_native_core::dropdown_menu::DropdownMenu; +use gpui_native_core::menu::MenuItem; +use gpui_native_theme::Theme; + +struct Harness { + open: bool, + split: bool, + disabled: bool, + changes: Rc>>, + clicks: Rc>, + activated: Rc>>, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let changes = self.changes.clone(); + let clicks = self.clicks.clone(); + let activated = self.activated.clone(); + let mut trigger = DropdownMenu::new("test-dd") + .label("Save") + .variant(ButtonVariant::Secondary) + .open(self.open) + .on_open_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.open = next; + cx.notify(); + }); + }) + .items(move |_, _| { + vec![ + export_item(activated.clone(), "png"), + export_item(activated.clone(), "pdf"), + ] + }); + + if self.disabled { + trigger = trigger.disabled(true); + } + if self.split { + trigger = trigger.on_click(move |_, _, _| *clicks.borrow_mut() += 1); + } + + div().size_full().child(trigger) + } +} + +fn export_item(activated: Rc>>, format: &'static str) -> MenuItem { + MenuItem::new(format).handler(move |_, _| activated.borrow_mut().push(format)) +} + +type Changes = Rc>>; +type Clicks = Rc>; +type Activated = Rc>>; + +/// A maximized window whose trigger starts in the top-left corner, so the +/// main zone is under the cursor at the button's usual tap position and the +/// chevron zone is found through its debug bounds. +fn dropdown_window( + split: bool, + disabled: bool, +) -> ( + TestAppContext, + VisualTestContext, + AnyWindowHandle, + Changes, + Clicks, + Activated, +) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let clicks = Rc::new(RefCell::new(0)); + let activated = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let harness_clicks = clicks.clone(); + let harness_activated = activated.clone(); + let window = cx.add_window(move |_, _| Harness { + open: false, + split, + disabled, + changes: harness_changes, + clicks: harness_clicks, + activated: harness_activated, + }); + let window = window.into(); + cx.update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); + let visual = VisualTestContext::from_window(window, &cx); + (cx, visual, window, changes, clicks, activated) +} + +fn draw(visual: &mut VisualTestContext, window: AnyWindowHandle) { + visual + .update_window(window, |_, window, cx| { + window.draw(cx).clear(cx); + }) + .unwrap(); +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); +} + +/// The painted center of an element found through its debug selector. +fn center(visual: &mut VisualTestContext, selector: &'static str) -> gpui::Point { + let bounds = visual + .debug_bounds(selector) + .unwrap_or_else(|| panic!("{selector} should have been laid out")); + point( + bounds.origin.x + bounds.size.width / 2., + bounds.origin.y + bounds.size.height / 2., + ) +} + +/// Split form: clicking the main zone fires the action and the menu stays +/// closed — the button keeps its normal behavior under a child menu. +#[test] +fn action_button_fires_and_does_not_open() { + let (_cx, mut visual, window, changes, clicks, _activated) = dropdown_window(true, false); + + click(&mut visual, point(px(30.), px(14.))); + assert_eq!(*clicks.borrow(), 1, "the main zone fires on_click"); + assert!( + changes.borrow().is_empty(), + "the menu must stay closed: {:?}", + changes.borrow() + ); + + draw(&mut visual, window); + assert!(visual.debug_bounds("menu-panel").is_none()); +} + +/// Split form: only the chevron zone opens the menu, and an activated row +/// closes it. +#[test] +fn chevron_opens_and_row_activation_closes() { + let (_cx, mut visual, window, changes, clicks, activated) = dropdown_window(true, false); + + let chevron = center(&mut visual, "test-dd-chevron"); + click(&mut visual, chevron); + assert_eq!(*changes.borrow(), vec![true]); + + draw(&mut visual, window); + let row = center(&mut visual, "menu-item-0"); + click(&mut visual, row); + assert_eq!(*activated.borrow(), vec!["png"]); + assert_eq!(*changes.borrow(), vec![true, false]); + assert_eq!(*clicks.borrow(), 0, "row activation never fires on_click"); +} + +/// Menu-only form: the whole button opens the menu on click, like a menu +/// button. +#[test] +fn plain_button_activates_the_menu() { + let (_cx, mut visual, _window, changes, clicks, _activated) = dropdown_window(false, false); + + click(&mut visual, point(px(30.), px(14.))); + assert_eq!(*changes.borrow(), vec![true]); + assert_eq!(*clicks.borrow(), 0); +} + +/// Keyboard focus returns to the trigger when the menu closes, so the down +/// arrow opens it again. +#[test] +fn escape_returns_focus_to_the_trigger() { + let (_cx, mut visual, window, changes, _clicks, _activated) = dropdown_window(true, false); + + let chevron = center(&mut visual, "test-dd-chevron"); + click(&mut visual, chevron); + draw(&mut visual, window); + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![true, false]); + + draw(&mut visual, window); + visual.simulate_keystrokes("down"); + assert_eq!( + *changes.borrow(), + vec![true, false, true], + "the focused trigger reopened the menu" + ); +} + +/// Enter and Space fire the split button's action while it is focused, like +/// any push button. +#[test] +fn enter_and_space_fire_the_action_while_focused() { + let (_cx, mut visual, window, changes, clicks, _activated) = dropdown_window(true, false); + + click(&mut visual, point(px(30.), px(14.))); + draw(&mut visual, window); + + for key in ["enter", "space"] { + visual.simulate_event(KeyDownEvent { + keystroke: Keystroke::parse(key).unwrap(), + is_held: false, + prefer_character_input: false, + }); + visual.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).unwrap(), + }); + } + + assert_eq!(*clicks.borrow(), 3, "the pointer click, Enter, and Space"); + assert!(changes.borrow().is_empty()); +} + +/// Enter opens the menu while a menu-only trigger is focused. +#[test] +fn enter_opens_the_menu_only_trigger() { + let (_cx, mut visual, window, changes, _clicks, _activated) = dropdown_window(false, false); + + // Opening hands focus to the panel; dismissing hands it back to the + // trigger, whose Enter then reopens the menu. + click(&mut visual, point(px(30.), px(14.))); + draw(&mut visual, window); + visual.simulate_keystrokes("escape"); + draw(&mut visual, window); + + { + let key = "enter"; + visual.simulate_event(KeyDownEvent { + keystroke: Keystroke::parse(key).unwrap(), + is_held: false, + prefer_character_input: false, + }); + visual.simulate_event(KeyUpEvent { + keystroke: Keystroke::parse(key).unwrap(), + }); + } + + assert_eq!(*changes.borrow(), vec![true, false, true]); +} + +/// Action rows sit a half-step apart so the rounded selection highlight +/// reads as its own chip instead of a continuous block. +#[test] +fn action_rows_sit_a_half_step_apart() { + let (_cx, mut visual, window, _changes, _clicks, _activated) = dropdown_window(true, false); + + let chevron = center(&mut visual, "test-dd-chevron"); + click(&mut visual, chevron); + draw(&mut visual, window); + + let first = visual + .debug_bounds("menu-item-0") + .expect("first row should have been laid out"); + let second = visual + .debug_bounds("menu-item-1") + .expect("second row should have been laid out"); + let gap = second.origin.y - (first.origin.y + first.size.height); + // Painted bounds are snapped to device pixels; allow half a pixel. + assert!( + (gap - px(2.)).abs() <= px(0.5), + "rows should sit 2px apart, got {gap:?}" + ); +} + +/// A pointer-opened menu arms no row, so enter cannot fire one; the first +/// down arms a row and enter fires it. +#[test] +fn pointer_opens_arm_nothing() { + let (_cx, mut visual, window, changes, _clicks, activated) = dropdown_window(true, false); + + let chevron = center(&mut visual, "test-dd-chevron"); + click(&mut visual, chevron); + draw(&mut visual, window); + + visual.simulate_keystrokes("enter"); + assert!( + activated.borrow().is_empty(), + "no row is armed on pointer open" + ); + assert_eq!(*changes.borrow(), vec![true], "the menu stays open"); + + visual.simulate_keystrokes("down enter"); + assert_eq!(*activated.borrow(), vec!["png"]); + assert_eq!(*changes.borrow(), vec![true, false]); +} + +/// A keyboard-opened menu (down arrow from the focused trigger) arms the +/// first selectable row, so enter fires it right away. +#[test] +fn keyboard_opens_arm_the_first_row() { + let (_cx, mut visual, window, changes, _clicks, activated) = dropdown_window(false, false); + + click(&mut visual, point(px(30.), px(14.))); + draw(&mut visual, window); + visual.simulate_keystrokes("escape"); + draw(&mut visual, window); + + visual.simulate_keystrokes("down"); + draw(&mut visual, window); + visual.simulate_keystrokes("enter"); + assert_eq!(*activated.borrow(), vec!["png"]); + assert_eq!(*changes.borrow(), vec![true, false, true, false]); +} + +/// A disabled trigger ignores both zones. +#[test] +fn disabled_trigger_ignores_clicks() { + let (_cx, mut visual, window, changes, clicks, _activated) = dropdown_window(true, true); + + click(&mut visual, point(px(30.), px(14.))); + let chevron = center(&mut visual, "test-dd-chevron"); + click(&mut visual, chevron); + draw(&mut visual, window); + + assert_eq!(*clicks.borrow(), 0); + assert!(changes.borrow().is_empty()); + assert!(visual.debug_bounds("menu-panel").is_none()); +} diff --git a/crates/core/tests/menu.rs b/crates/core/tests/menu.rs index 8782117..e9e6520 100644 --- a/crates/core/tests/menu.rs +++ b/crates/core/tests/menu.rs @@ -138,18 +138,16 @@ fn rows_render_at_the_stated_menu_row_height() { fn arrow_navigation_skips_separators_and_disabled_items() { let (_cx, mut visual, _window, changes, activated) = menu_window(true); - // Selection starts on the first item; down moves to the next - // selectable item (Copy, past the separator), and enter activates it, - // closing the menu. - visual.simulate_keystrokes("down"); - visual.simulate_keystrokes("enter"); + // Nothing is highlighted when the menu opens; down arms the first + // selectable item, the next down crosses the separator to Copy, and + // enter activates it, closing the menu. + visual.simulate_keystrokes("down down enter"); draw(&mut visual); assert_eq!(*activated.borrow(), vec!["copy"]); assert_eq!(*changes.borrow(), vec![false]); - // Reopen: the selection starts over on the first item, and end jumps - // straight to the last selectable item, over the separator and the - // disabled row. + // Reopen: pointer opens highlight nothing again, and end jumps straight + // to the last selectable item, over the separator and the disabled row. click(&mut visual, point(px(10.), px(16.))); draw(&mut visual); visual.simulate_keystrokes("end"); @@ -158,6 +156,18 @@ fn arrow_navigation_skips_separators_and_disabled_items() { assert_eq!(*changes.borrow(), vec![false, true, false]); } +#[test] +fn enter_without_navigation_activates_nothing() { + let (_cx, mut visual, _window, changes, activated) = menu_window(true); + + // A pointer-opened menu arms no row, so enter cannot fire one and the + // menu stays open. + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert!(activated.borrow().is_empty()); + assert!(changes.borrow().is_empty(), "the menu stays open"); +} + #[test] fn escape_dismisses_without_activating() { let (_cx, mut visual, _window, changes, activated) = menu_window(true); diff --git a/crates/core/tests/select.rs b/crates/core/tests/select.rs index 8926deb..2a98ac3 100644 --- a/crates/core/tests/select.rs +++ b/crates/core/tests/select.rs @@ -142,12 +142,16 @@ fn pair_window() -> (TestAppContext, VisualTestContext, Changes, Changes) { fn choosing_an_option_reports_the_value_and_closes() { let (_cx, mut visual, changes) = select_window(); - // The trigger opens the popup; the panel grabs focus, so the first option - // is keyboard-highlighted and Enter activates it. + // The trigger opens the popup; with no value yet nothing is armed, so + // the first Enter does nothing and the next Down arms the first option. click(&mut visual, point(px(100.), px(31.))); draw(&mut visual); visual.simulate_keystrokes("enter"); draw(&mut visual); + assert_eq!(*changes.borrow(), Vec::>::new()); + + visual.simulate_keystrokes("down enter"); + draw(&mut visual); assert_eq!(*changes.borrow(), vec![Some(Strategy::Curated)]); // The popup closed with the choice, so another Enter reports nothing. @@ -155,7 +159,8 @@ fn choosing_an_option_reports_the_value_and_closes() { draw(&mut visual); assert_eq!(*changes.borrow(), vec![Some(Strategy::Curated)]); - // The trigger reopens; the second option is two Downs away. + // The trigger reopens with the chosen value armed; the next option is + // one Down away. click(&mut visual, point(px(100.), px(31.))); draw(&mut visual); visual.simulate_keystrokes("down"); @@ -173,11 +178,12 @@ fn the_controlled_value_round_trips_through_the_owner() { click(&mut visual, point(px(100.), px(31.))); draw(&mut visual); - visual.simulate_keystrokes("down"); + visual.simulate_keystrokes("down down"); visual.simulate_keystrokes("enter"); draw(&mut visual); - // The harness stored the reported value, so the select now holds it. + // The harness stored the reported value, so the select now holds it; + // reopening arms it and Enter reports it straight back. click(&mut visual, point(px(100.), px(31.))); draw(&mut visual); visual.simulate_keystrokes("enter"); @@ -195,7 +201,7 @@ fn two_selects_keep_their_popups_independent() { // Opening the second select never touches the first. click(&mut visual, point(px(100.), px(69.))); draw(&mut visual); - visual.simulate_keystrokes("enter"); + visual.simulate_keystrokes("down enter"); draw(&mut visual); assert_eq!(*first_changes.borrow(), vec![]); @@ -232,7 +238,7 @@ fn a_click_on_another_control_dismisses_and_reaches_it() { // the second. click(&mut visual, point(px(300.), px(69.))); draw(&mut visual); - visual.simulate_keystrokes("enter"); + visual.simulate_keystrokes("down enter"); draw(&mut visual); assert_eq!(*first_changes.borrow(), Vec::>::new()); diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 97ac0f9..8bef9bc 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -22,8 +22,6 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. - [ ] **Editable code field** — syntax-highlighted editable multiline field, the code view's highlighter over the input's editing model; signed's repo file browser. -- [ ] **Dropdown menu** — trigger button + popover + menu composite, three - signed call sites; must stay a real component, not a wrapper. - [ ] **Motion easing helpers** — cubic ease-out and lerp beside `theme::Motion`; low priority, gpui's animation primitives cover most cases. @@ -96,7 +94,13 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. (and dark mode); the panel padding and separator margins come from `Theme::spacing`, and every action row renders at the stated `menu_item_height` with content centered, so the stated metric is the - whole row; rows carry debug selectors for headless geometry tests + whole row; rows sit a half-step apart (`gap_0p5`) so the rounded + highlight reads as its own chip, and the unselected pointer wash is the + preset's `muted_hover`, not a foreground token; pointer opens arm no + row — a keyboard open (arrow or Enter on the trigger) arms the first + selectable row, and Select arms its current value — so Enter cannot + fire an option nobody chose; rows carry debug selectors for headless + geometry tests - [x] Popover platform chrome — the panel border behind `Theme::borders.overlays` (and dark mode); the anchor gap is the spacing unit and rides `anchored().offset`, not a margin, so the @@ -139,6 +143,12 @@ Gaps surfaced by the signed comparison; ordered by signed's usage. `Theme::borders.overlays` (and dark mode), taking the surface color where the platform paints none; it rides the dialog elevation, so dark mode's no-shadow rule applies to it too +- [x] Dropdown menu — trigger button + menu composite reusing the menu's + anchored panel; the trigger takes the full button variant vocabulary; + the split form keeps the main zone a working button (`on_click` fires + anywhere outside the chevron) while the chevron zone opens the menu, + the menu-only form opens on activation, ArrowDown opens from focus, and + focus returns to the trigger on every close - [x] Switch platform chrome — the track and knob take the stated `switch_height` / `switch_width` / `switch_knob` (the knob's inset derived to center it in the track, its travel from the remaining diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 186c2e3..fd5fa76 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -18,6 +18,7 @@ use gpui_native_core::command_palette::{Command, CommandPalette, CommandPaletteS use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::dialog::Dialog; +use gpui_native_core::dropdown_menu::DropdownMenu; use gpui_native_core::empty_state::EmptyState; use gpui_native_core::field::{Field, FieldVariant}; use gpui_native_core::input::{Input, InputState}; @@ -106,6 +107,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("dialog", "Dialog"), ("command-palette", "Command palette"), ("menu", "Menu"), + ("dropdown-menu", "Dropdown menu"), ("context-menu", "Context menu"), ("popover", "Popover"), ("tooltip", "Tooltip"), @@ -612,6 +614,16 @@ impl Gallery { }), cx, ); + dock.register_panel( + PanelDescriptor::new("dropdown-menu", "Dropdown menu", |_, cx| { + cx.new(|_| DropdownMenuDemo { + open_menu: None, + status: None, + }) + .into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("context-menu", "Context menu", |_, cx| { cx.new(|_| ContextMenuDemo { @@ -2690,6 +2702,178 @@ impl Render for MenuDemo { } } +#[derive(Clone, Copy, PartialEq)] +enum DemoDropdown { + Export, + Save, + Duplicate, + Delete, + More, +} + +struct DropdownMenuDemo { + open_menu: Option, + status: Option, +} + +impl DropdownMenuDemo { + fn set_open(&mut self, menu: DemoDropdown, open: bool, cx: &mut Context) { + self.open_menu = open.then_some(menu); + cx.notify(); + } + + fn set_status(&mut self, message: impl Into, cx: &mut Context) { + self.status = Some(message.into()); + cx.notify(); + } +} + +impl Render for DropdownMenuDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let colors = cx.theme().colors; + let status: SharedString = match &self.status { + Some(message) => message.clone(), + None => "click the Save button's body; open its menu from the chevron".into(), + }; + + let export = DropdownMenu::new("dropdown-demo-export") + .label("Export") + .open(self.open_menu == Some(DemoDropdown::Export)) + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |demo, cx| { + demo.set_open(DemoDropdown::Export, open, cx); + }) + } + }) + .items(|_, _| { + vec![ + MenuItem::new("PNG").handler(|_, cx| { + eprintln!("dropdown: export png"); + let _ = cx; + }), + MenuItem::new("SVG"), + MenuItem::new("PDF"), + ] + }); + + let save = DropdownMenu::new("dropdown-demo-save") + .label("Save") + .open(self.open_menu == Some(DemoDropdown::Save)) + .on_click({ + let view = view.clone(); + move |_, _, cx| { + view.update(cx, |demo, cx| { + demo.set_status("saved document", cx); + }); + } + }) + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |demo, cx| { + demo.set_open(DemoDropdown::Save, open, cx); + }) + } + }) + .items(|_, _| { + vec![ + MenuItem::new("Save as template"), + MenuItem::new("Save a copy"), + MenuItem::separator(), + MenuItem::new("Export"), + ] + }); + + let duplicate = DropdownMenu::new("dropdown-demo-duplicate") + .label("Duplicate") + .variant(ButtonVariant::Secondary) + .open(self.open_menu == Some(DemoDropdown::Duplicate)) + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |demo, cx| { + demo.set_open(DemoDropdown::Duplicate, open, cx); + }) + } + }) + .items(|_, _| { + vec![ + MenuItem::new("Duplicate window"), + MenuItem::new("Duplicate tab"), + ] + }); + + let delete = DropdownMenu::new("dropdown-demo-delete") + .label("Delete") + .variant(ButtonVariant::Destructive) + .open(self.open_menu == Some(DemoDropdown::Delete)) + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |demo, cx| { + demo.set_open(DemoDropdown::Delete, open, cx); + }) + } + }) + .items(|_, _| { + vec![ + MenuItem::new("Delete file").destructive(true), + MenuItem::separator(), + MenuItem::new("Move to trash"), + ] + }); + + let more = DropdownMenu::new("dropdown-demo-more") + .label("More") + .variant(ButtonVariant::Ghost) + .open(self.open_menu == Some(DemoDropdown::More)) + .on_open_change({ + let view = view.clone(); + move |open, _, cx| { + view.update(cx, |demo, cx| { + demo.set_open(DemoDropdown::More, open, cx); + }) + } + }) + .items(|_, _| { + vec![ + MenuItem::new("Copy link"), + MenuItem::new("Share"), + MenuItem::new("Properties"), + ] + }); + + ScrollArea::new("dropdown-menu-demo") + .size_full() + .p_4() + .child( + v_flex() + .gap_3() + .child(demo_section( + "menu-only trigger and split button", + colors, + h_flex().gap_3().child(export).child(save), + )) + .child(demo_section( + "trigger variants", + colors, + h_flex().gap_3().child(duplicate).child(delete).child(more), + )) + .child(demo_section( + "status", + colors, + div() + .text_sm() + .text_color(colors.muted_foreground) + .child(status), + )), + ) + } +} + struct ContextMenuDemo { action: SharedString, }