From 2f81f2848d7111aeecb75858c81c4690acd40f87 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 29 Oct 2026 19:58:13 +0700 Subject: [PATCH] add menu --- crates/core/Cargo.toml | 2 +- crates/core/src/lib.rs | 3 + crates/core/src/menu.rs | 610 +++++++++++++++++++++++++++++++++++ crates/core/tests/menu.rs | 191 +++++++++++ docs/PLAN.md | 9 +- examples/gallery/src/main.rs | 46 +++ 6 files changed, 859 insertions(+), 2 deletions(-) create mode 100644 crates/core/src/menu.rs create mode 100644 crates/core/tests/menu.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 7c73c9f..8d6c4ea 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -46,7 +46,7 @@ input = [] popover = [] scroll_area = [] tooltip = [] -menu = [] +menu = ["scroll_area"] dialog = [] sheet = [] toast = [] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 1ccefd6..7e4869d 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -36,6 +36,9 @@ pub mod avatar; #[cfg(feature = "input")] pub mod input; +#[cfg(feature = "menu")] +pub mod menu; + #[cfg(feature = "popover")] pub mod popover; diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs new file mode 100644 index 0000000..e346eff --- /dev/null +++ b/crates/core/src/menu.rs @@ -0,0 +1,610 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, DispatchPhase, ElementId, + Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + ParentElement as _, Pixels, RenderOnce, Role, ScrollHandle, SharedString, + StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div, + ease_out_quint, px, relative, +}; +use gpui_native_icons::{IconRole, icon as icon_glyph}; +use gpui_native_theme::ActiveTheme as _; + +use crate::common::v_flex; +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; +type TriggerFn = Box AnyElement>; +type ItemsFn = Box Vec>; + +/// One row of a [`Menu`]: an action, a checkable action, or a separator. +#[derive(Clone)] +pub struct MenuItem { + kind: MenuItemKind, + label: SharedString, + icon: Option, + shortcut: Option, + handler: Option, + disabled: bool, + destructive: bool, + checked: Option, +} + +#[derive(Clone)] +enum MenuItemKind { + Action, + Separator, +} + +impl MenuItem { + /// An action row with this label. + pub fn new(label: L) -> Self + where + L: Into, + { + Self { + kind: MenuItemKind::Action, + label: label.into(), + icon: None, + shortcut: None, + handler: None, + disabled: false, + destructive: false, + checked: None, + } + } + + /// A divider between groups of actions. + pub fn separator() -> Self { + Self { + kind: MenuItemKind::Separator, + label: SharedString::default(), + icon: None, + shortcut: None, + handler: None, + disabled: false, + destructive: false, + checked: None, + } + } + + /// Runs this action when the item is activated. The menu closes first. + pub fn handler(mut self, handler: F) -> Self + where + F: Fn(&mut Window, &mut App) + 'static, + { + self.handler = Some(Rc::new(handler)); + self + } + + /// Shows an icon before the label. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + self + } + + /// Shows a shortcut hint after the label. + pub fn shortcut(mut self, shortcut: S) -> Self + where + S: Into, + { + self.shortcut = Some(shortcut.into()); + self + } + + /// Marks the item non-selectable and non-activatable. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } + + /// Paints the row in the danger role. + pub fn destructive(mut self, destructive: bool) -> Self { + self.destructive = destructive; + self + } + + /// Shows a check slot before the label. + pub fn checked(mut self, checked: bool) -> Self { + self.checked = Some(checked); + self + } + + /// Whether keyboard navigation and activation may land on this item. + fn selectable(&self) -> bool { + matches!(self.kind, MenuItemKind::Action) && !self.disabled + } +} + +/// Menu chrome styles. +#[derive(Clone)] +struct MenuChrome { + surface: gpui::Hsla, + border: gpui::Hsla, + foreground: gpui::Hsla, + muted_foreground: gpui::Hsla, + primary: gpui::Hsla, + primary_foreground: gpui::Hsla, + destructive: gpui::Hsla, + destructive_foreground: gpui::Hsla, + radius_overlay: Pixels, + radius_sm: Pixels, + icon_size: Pixels, + shadow: Vec, +} + +/// A floating menu of actions anchored below its trigger. +#[derive(IntoElement)] +pub struct Menu { + id: ElementId, + open: bool, + a11y_label: Option, + on_change: Option, + trigger: Option, + items: Option, +} + +impl Menu { + /// Creates a closed menu. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + open: false, + a11y_label: None, + on_change: None, + trigger: None, + items: None, + } + } + + /// Sets the current open state. + pub fn open(mut self, open: bool) -> Self { + self.open = open; + self + } + + /// Reports the next open state, from dismissals, toggles, and activations alike. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(bool, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Sets the element the panel anchors below. + pub fn trigger(mut self, trigger: F) -> Self + where + F: Fn(&mut Window, &mut App) -> AnyElement + 'static, + { + self.trigger = Some(Box::new(trigger)); + 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 + } + + /// Overrides the accessibility label of the panel. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +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, + 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, + shadow: theme.elevations.popover.shadows(), + }, + theme.motion.overlay, + ) + }; + + let trigger_bounds: Rc>>> = Rc::new(RefCell::new(None)); + let panel_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() + .unwrap_or_else(|| SharedString::from(self.id.to_string())); + + let mut wrapper = div() + .relative() + .child( + canvas( + { + let cell = trigger_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + { + let on_change = self.on_change.clone(); + let open = self.open; + 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); + } + + window.on_mouse_event({ + let on_change = on_change.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble + || event.button != MouseButton::Left + { + return; + } + + let on_trigger = trigger_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + if !on_trigger { + return; + } + + if let Some(on_change) = &on_change { + on_change(!open, window, cx); + } + cx.stop_propagation(); + } + }); + } + }, + ) + .absolute() + .inset_0(), + ) + .when_some(self.trigger, |wrapper, trigger| { + 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 { + Some(build) => build(window, cx), + None => Vec::new(), + }; + + // Keyboard focus rides the panel across frames. + let focus_handle = window.use_keyed_state("menu-focus", cx, |_, cx| cx.focus_handle()); + let scroll = window.use_keyed_state("menu-scroll", cx, |_, _| ScrollHandle::new()); + + let selected = if *was_open.read(cx) { + *selection.read(cx) + } 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") + .track_focus(&focus_handle.read(cx).clone()) + .role(Role::Menu) + .aria_label(label) + .mt_1() + .min_w_40() + .max_h(relative(0.75)) + .p_1() + .bg(chrome.surface) + .text_color(chrome.foreground) + .rounded(chrome.radius_overlay) + .border_1() + .border_color(chrome.border) + .shadow(chrome.shadow.clone()) + .overflow_hidden() + .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 cell = panel_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + { + let on_change = on_change.clone(); + let focus = focus_handle; + move |_, _, window, cx| { + if !focus.read(cx).contains_focused(window, cx) { + window.focus(&focus.read(cx).clone(), cx); + } + + window.on_mouse_event({ + let on_change = on_change.clone(); + move |event: &MouseDownEvent, phase, window, cx| { + if phase != DispatchPhase::Bubble + || event.button != MouseButton::Left + { + return; + } + + let on_panel = panel_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + let on_trigger = trigger_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + if on_panel || on_trigger { + return; + } + + if let Some(on_change) = &on_change { + on_change(false, window, cx); + } + cx.stop_propagation(); + } + }); + } + }, + ) + .absolute() + .inset_0(), + ); + + 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), + )))); + } + + wrapper + } +} + +/// Renders one menu row. +fn render_item( + item: &MenuItem, + index: usize, + selected: bool, + selection: &Entity, + on_change: &Option, + chrome: &MenuChrome, +) -> AnyElement { + match item.kind { + MenuItemKind::Separator => div() + .h(px(1.)) + .mx_2() + .my_1() + .bg(chrome.border) + .into_any_element(), + MenuItemKind::Action => { + let label_color = row_color(item, selected, chrome); + let disabled = item.disabled; + let destructive = item.destructive; + let checked = item.checked; + let shortcut = item.shortcut.clone(); + let icon_role = item.icon; + + div() + .id(("menu-item", index)) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(chrome.radius_sm) + .map(|row| { + if disabled { + row + } else if selected { + let highlight = if destructive { + chrome.destructive + } else { + chrome.primary + }; + row.bg(highlight) + } else if destructive { + row.hover(|row| row.bg(chrome.destructive)) + } else { + row.hover(|row| row.bg(chrome.muted_foreground)) + } + }) + .on_mouse_move({ + let selection = selection.clone(); + move |_, _window, cx| { + if *selection.read(cx) != index { + selection.update(cx, |selection, _| *selection = index); + } + } + }) + .on_click({ + let handler = item.handler.clone(); + let on_change = on_change.clone(); + move |_, window, cx| { + if disabled { + return; + } + if let Some(on_change) = &on_change { + on_change(false, window, cx); + } + if let Some(handler) = &handler { + handler(window, cx); + } + } + }) + .when_some(checked, |row, checked| { + row.child(div().flex_none().when(checked, |slot| { + slot.child( + icon_glyph(IconRole::Check) + .size(f32::from(chrome.icon_size) * 0.75) + .color(label_color), + ) + })) + }) + .when_some(icon_role, |row, role| { + row.child( + icon_glyph(role) + .size(f32::from(chrome.icon_size)) + .color(label_color), + ) + }) + .child( + div() + .flex_1() + .text_color(label_color) + .child(item.label.clone()), + ) + .when_some(shortcut, |row, shortcut| { + row.child( + div() + .text_color(if selected && !destructive && !disabled { + chrome.primary_foreground + } else { + chrome.muted_foreground + }) + .child(shortcut), + ) + }) + .into_any_element() + } + } +} + +/// The row's label/icon color. +fn row_color(item: &MenuItem, selected: bool, chrome: &MenuChrome) -> gpui::Hsla { + if item.disabled { + return chrome.muted_foreground; + } + if item.destructive { + return if selected { + chrome.destructive_foreground + } else { + chrome.destructive + }; + } + if selected { + chrome.primary_foreground + } else { + chrome.foreground + } +} + +/// Handles the panel's navigation keys. +fn handle_menu_key( + event: &KeyDownEvent, + items: &[MenuItem], + selection: &Entity, + on_change: &Option, + window: &mut Window, + cx: &mut App, +) { + let selectable: Vec = items + .iter() + .enumerate() + .filter(|(_, item)| item.selectable()) + .map(|(index, _)| index) + .collect(); + + let current = *selection.read(cx); + let position = 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, + }; + selection.update(cx, |selection, _| *selection = 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) + && item.selectable() + { + if let Some(on_change) = on_change { + on_change(false, window, cx); + } + if let Some(handler) = &item.handler { + handler(window, cx); + } + } + } + "escape" => { + if let Some(on_change) = on_change { + on_change(false, window, cx); + } + } + _ => return, + } + + cx.stop_propagation(); +} diff --git a/crates/core/tests/menu.rs b/crates/core/tests/menu.rs new file mode 100644 index 0000000..6021dc0 --- /dev/null +++ b/crates/core/tests/menu.rs @@ -0,0 +1,191 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px, +}; +use gpui_native_core::menu::{Menu, MenuItem}; +use gpui_native_theme::Theme; + +struct Harness { + open: bool, + changes: Rc>>, + activated: Rc>>, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + let activated = self.activated.clone(); + div().size_full().child( + Menu::new("test-menu") + .open(self.open) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.open = next; + cx.notify(); + }); + }) + .trigger(|_, _| { + div() + .w(px(100.)) + .h(px(32.)) + .bg(gpui::black()) + .into_any_element() + }) + .items(move |_, _| { + vec![ + cut_item(activated.clone()), + MenuItem::separator(), + copy_item(activated.clone()), + MenuItem::new("Unavailable").disabled(true).handler({ + let activated = activated.clone(); + move |_, _| record(&activated, "unavailable") + }), + delete_item(activated.clone()), + ] + }), + ) + } +} + +fn cut_item(activated: Rc>>) -> MenuItem { + MenuItem::new("Cut") + .shortcut("cmd-x") + .handler(move |_, _| record(&activated, "cut")) +} + +fn copy_item(activated: Rc>>) -> MenuItem { + MenuItem::new("Copy").handler(move |_, _| record(&activated, "copy")) +} + +fn delete_item(activated: Rc>>) -> MenuItem { + MenuItem::new("Delete") + .destructive(true) + .handler(move |_, _| record(&activated, "delete")) +} + +fn record(sink: &Rc>>, what: &'static str) { + sink.borrow_mut().push(what); +} + +type Changes = Rc>>; +type Activated = Rc>>; + +fn menu_window( + open: bool, +) -> ( + TestAppContext, + VisualTestContext, + WindowHandle, + Changes, + Activated, +) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let activated = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let harness_activated = activated.clone(); + let window = cx.add_window(move |_, _| Harness { + open, + changes: harness_changes, + activated: harness_activated, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, window, changes, activated) +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +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()); +} + +#[test] +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"); + 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. + click(&mut visual, point(px(10.), px(16.))); + draw(&mut visual); + visual.simulate_keystrokes("end"); + visual.simulate_keystrokes("enter"); + assert_eq!(*activated.borrow(), vec!["copy", "delete"]); + assert_eq!(*changes.borrow(), vec![false, true, false]); +} + +#[test] +fn escape_dismisses_without_activating() { + let (_cx, mut visual, _window, changes, activated) = menu_window(true); + + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false]); + assert!(activated.borrow().is_empty()); + + // A closed menu is inert. + draw(&mut visual); + visual.simulate_keystrokes("escape"); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn clicking_outside_dismisses_and_swallows_the_click() { + let (_cx, mut visual, _window, changes, activated) = menu_window(true); + + click(&mut visual, point(px(600.), px(600.))); + assert_eq!(*changes.borrow(), vec![false]); + assert!(activated.borrow().is_empty()); + + draw(&mut visual); + click(&mut visual, point(px(600.), px(600.))); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn clicking_the_trigger_toggles() { + let (_cx, mut visual, _window, changes, _activated) = menu_window(false); + + click(&mut visual, point(px(10.), px(16.))); + assert_eq!(*changes.borrow(), vec![true]); +} + +#[test] +fn clicking_an_item_activates_and_closes() { + let (_cx, mut visual, _window, changes, activated) = menu_window(true); + + // The first row hangs just below the trigger's panel padding. + click(&mut visual, point(px(60.), px(50.))); + assert_eq!(*activated.borrow(), vec!["cut"]); + assert_eq!(*changes.borrow(), vec![false]); +} + +#[test] +fn enter_activates_the_selected_item_and_closes() { + let (_cx, mut visual, _window, changes, activated) = menu_window(true); + + visual.simulate_keystrokes("enter"); + assert_eq!(*activated.borrow(), vec!["cut"]); + assert_eq!(*changes.borrow(), vec![false]); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 6c1337b..38a9ac5 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -210,6 +210,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 9 | core | Avatar | Initials; image slot later. | | 10 | core | Input (single-line) then multiline, NumberInput | String buffer + `unicode-segmentation` word movement; the field owns its own selection. May seed `crates/editor`. Single-line finishing pass: §6.2. | | 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred | +| 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling | | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | | 11+ | core | Tooltip, Menu, Dialog, AlertDialog, Sheet, Toast | One unit each, same gate. Menu reuses the popover shell + `Radius.overlay`/`Elevations.popover`; Dialog consumes `colors.overlay` (scrim) + `Elevations.dialog` | | then | data (new) | List, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | @@ -310,4 +311,10 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 1. ~~Phase A scaffold~~ ✅ — workspace, toolchain pin, CI, theme seed, icons crate, core feature scaffolding, facade, gallery shell. 2. ~~Phase B theme engine core~~ ✅ — resolve pipeline, OKLCH ramps, presets, macOS source + watchers, gallery theme inspector. -3. **Unit 1: Button** (next) — API per §4.3 modeled on gpui-kit `base`'s Button (single activation path, `disabled`, a11y role + label), painted from theme ramps; tests for activation / disabled inertness / keyboard activation; gallery page. → your review, then unit 2 (Checkbox). +3. ~~Phase C controls~~ ✅ — units 1–9 (Button → Avatar), each through the review gate; sizing API (§6.1). +4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput and multiline remain open as deferred units. +5. **Phase E overlays (in progress)** — Popover ✅ (unit 11), ScrollArea ✅ (unit 11.5, support). **Next: Unit 12 Menu** — reuses the popover shell + ScrollArea; items with icons/shortcuts/separators, keyboard navigation, activation. Then Tooltip → Dialog/AlertDialog (scrim + focus trap) → Sheet → Toast (brings `success`/`warning` tokens). Phase gate: focus trapping and escape/dismiss semantics per overlay. +6. Then — Phase F: `crates/data` (List, Table, Tree, Tabs, Sidebar) + Windows/GNOME/KDE/Omarchy theme sources + Segoe Fluent/freedesktop icon resolvers, with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. +7. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. + +Batched polish candidates between units: Switch thumb slide (motion now exists), scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 29be9a2..9023644 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -10,6 +10,7 @@ use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; use gpui_native_core::input::{Input, InputState}; use gpui_native_core::link::Link; +use gpui_native_core::menu::{Menu, MenuItem}; use gpui_native_core::popover::Popover; use gpui_native_core::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; @@ -30,6 +31,7 @@ struct Gallery { volume: f32, input_state: Entity, popover_open: bool, + menu_open: bool, scroll: gpui::ScrollHandle, } @@ -278,6 +280,49 @@ impl Render for Gallery { ), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "menu · native ref — NSMenu, WinUI MenuFlyout, Adwaita popover menu", + colors, + )) + .child({ + let gallery = gallery.clone(); + Menu::new("gallery-menu") + .open(self.menu_open) + .on_change(move |open, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.menu_open = open; + cx.notify(); + }); + }) + .trigger(|_, _| { + Button::new("gallery-menu-trigger") + .label("Actions") + .variant(ButtonVariant::Secondary) + .into_any_element() + }) + .items(|_, _| { + vec![ + MenuItem::new("Cut") + .shortcut("cmd-x") + .handler(|_, cx| { + eprintln!("menu: cut"); + let _ = cx; + }), + MenuItem::new("Copy").shortcut("cmd-c"), + MenuItem::new("Paste").shortcut("cmd-v"), + MenuItem::separator(), + MenuItem::new("Use quiet mode").checked(true), + MenuItem::new("Show hidden files"), + MenuItem::separator(), + MenuItem::new("Unavailable").disabled(true), + MenuItem::new("Delete").destructive(true), + ] + }) + }), + ) .child( v_flex() .gap_2() @@ -679,6 +724,7 @@ fn main() { volume: 40.0, input_state, popover_open: false, + menu_open: false, scroll: gpui::ScrollHandle::new(), }) },