diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 4c17b49..9c3257f 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -19,6 +19,7 @@ default = ["full"] full = [ "button", "checkbox", + "context_menu", "switch", "radio", "toggle", @@ -40,6 +41,7 @@ full = [ ] button = [] checkbox = [] +context_menu = ["menu"] switch = [] radio = [] toggle = [] diff --git a/crates/core/src/context_menu.rs b/crates/core/src/context_menu.rs new file mode 100644 index 0000000..0caed0c --- /dev/null +++ b/crates/core/src/context_menu.rs @@ -0,0 +1,120 @@ +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, ElementId, InteractiveElement as _, IntoElement, MouseButton, MouseDownEvent, + ParentElement as _, RenderOnce, SharedString, Window, div, +}; + +use crate::menu::{Menu, MenuItem}; + +/// Builds the element that opens the menu on right-click. +type TargetFn = Box gpui::AnyElement>; +/// Builds the menu's items fresh each render. +type ItemsFn = Box Vec>; + +/// A menu that opens at the pointer when its target is right-clicked. +#[derive(IntoElement)] +pub struct ContextMenu { + id: ElementId, + target: Option, + items: Option, + a11y_label: Option, +} + +impl ContextMenu { + /// Creates a context menu over a target set with [`ContextMenu::target`]. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + target: None, + items: None, + a11y_label: None, + } + } + + /// Sets the element that opens the menu on right-click. + pub fn target(mut self, target: F) -> Self + where + F: Fn(&mut Window, &mut App) -> gpui::AnyElement + 'static, + { + self.target = Some(Box::new(target)); + 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 menu's accessibility label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl RenderOnce for ContextMenu { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let open = window.with_element_namespace(self.id.clone(), |window| { + window + .use_keyed_state("context-open", cx, |_, _| false) + .clone() + }); + + let at = window.with_element_namespace(self.id.clone(), |window| { + window + .use_keyed_state("context-position", cx, |_, _| None) + .clone() + }); + + let is_open = *open.read(cx); + let position = *at.read(cx); + + let target = self.target; + let at_for_target = at.clone(); + + let trigger = move |window: &mut Window, cx: &mut App| { + let base = target + .as_ref() + .map(|build| build(window, cx)) + .unwrap_or_else(|| div().into_any_element()); + + div() + .on_mouse_down(MouseButton::Right, { + let at = at_for_target.clone(); + move |event: &MouseDownEvent, _window, cx| { + at.update(cx, |at, _| *at = Some(event.position)); + } + }) + .child(base) + .into_any_element() + }; + + let items = move |window: &mut Window, cx: &mut App| { + self.items + .as_ref() + .map(|build| build(window, cx)) + .unwrap_or_default() + }; + + Menu::new(self.id) + .open(is_open) + .on_change(move |next, _window, cx| open.update(cx, |open, _| *open = next)) + .open_at(position) + .trigger_button(MouseButton::Right) + .trigger(trigger) + .items(items) + .when_some(self.a11y_label.clone(), |menu, label| { + menu.accessibility_label(label) + }) + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index a2aa287..30dd233 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -12,6 +12,9 @@ pub mod button; #[cfg(feature = "checkbox")] pub mod checkbox; +#[cfg(feature = "context_menu")] +pub mod context_menu; + #[cfg(feature = "switch")] pub mod switch; diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index c5f18c1..48fe83e 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -5,7 +5,7 @@ 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, + ParentElement as _, Pixels, Point, RenderOnce, Role, ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div, ease_out_quint, px, }; @@ -146,6 +146,8 @@ pub struct Menu { id: ElementId, open: bool, initial_selection: Option, + trigger_button: MouseButton, + open_at: Option>, a11y_label: Option, on_change: Option, trigger: Option, @@ -162,6 +164,8 @@ impl Menu { id: id.into(), open: false, initial_selection: None, + trigger_button: MouseButton::Left, + open_at: None, a11y_label: None, on_change: None, trigger: None, @@ -175,6 +179,18 @@ impl Menu { self } + /// Sets the mouse button that toggles the menu; right for context menus. + pub fn trigger_button(mut self, button: MouseButton) -> Self { + self.trigger_button = button; + self + } + + /// Positions the panel at this window point when it opens. + pub fn open_at(mut self, position: impl Into>>) -> Self { + self.open_at = position.into(); + self + } + /// Sets the current open state. pub fn open(mut self, open: bool) -> Self { self.open = open; @@ -264,6 +280,7 @@ impl RenderOnce for Menu { { let on_change = self.on_change.clone(); let open = self.open; + let trigger_button = self.trigger_button; let cell = trigger_bounds.clone(); let was_open = was_open.clone(); move |_, _, window, cx| { @@ -277,7 +294,7 @@ impl RenderOnce for Menu { let on_change = on_change.clone(); move |event: &MouseDownEvent, phase, window, cx| { if phase != DispatchPhase::Bubble - || event.button != MouseButton::Left + || event.button != trigger_button { return; } @@ -390,9 +407,7 @@ impl RenderOnce for Menu { window.on_mouse_event({ let on_change = on_change.clone(); move |event: &MouseDownEvent, phase, window, cx| { - if phase != DispatchPhase::Bubble - || event.button != MouseButton::Left - { + if phase != DispatchPhase::Bubble { return; } @@ -419,7 +434,12 @@ impl RenderOnce for Menu { .inset_0(), ); - wrapper = wrapper.child(deferred(anchored().child(panel.with_animation( + let anchored = match self.open_at { + Some(position) => anchored().position(position), + None => anchored(), + }; + + 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), diff --git a/crates/core/tests/context_menu.rs b/crates/core/tests/context_menu.rs new file mode 100644 index 0000000..53fad98 --- /dev/null +++ b/crates/core/tests/context_menu.rs @@ -0,0 +1,129 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, MouseButton, ParentElement as _, Render, Styled as _, TestAppContext, + VisualTestContext, Window, div, point, px, +}; +use gpui_native_core::context_menu::ContextMenu; +use gpui_native_core::menu::MenuItem; +use gpui_native_theme::Theme; + +type Actions = Rc>>; + +struct Harness { + actions: Actions, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let actions = self.actions.clone(); + div().size_full().p_4().child( + ContextMenu::new("ctx") + .target(|_, _| div().h(px(40.)).w(px(200.)).child("Row").into_any_element()) + .items({ + let actions = actions.clone(); + move |_, _| { + vec![ + MenuItem::new("Rename").handler({ + let actions = actions.clone(); + move |_, _| actions.borrow_mut().push("rename") + }), + MenuItem::new("Delete").handler({ + let actions = actions.clone(); + move |_, _| actions.borrow_mut().push("delete") + }), + ] + } + }), + ) + } +} + +/// The target row rides the `p_4` band: `16..216` horizontally, `16..56` +/// vertically, so right-clicks land near `(100, 30)`. +fn context_window() -> (TestAppContext, VisualTestContext, Actions) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let actions = Rc::new(RefCell::new(Vec::new())); + let harness_actions = actions.clone(); + let window = cx.add_window(move |_, _| Harness { + actions: harness_actions, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, actions) +} + +fn right_click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Right, gpui::Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Right, gpui::Modifiers::default()); +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, gpui::Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default()); +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +#[test] +fn right_click_opens_the_menu_and_items_activate() { + let (_cx, mut visual, actions) = context_window(); + + right_click(&mut visual, point(px(100.), px(30.))); + draw(&mut visual); + // The panel claims focus, so the first item is keyboard-highlighted. + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*actions.borrow(), vec!["rename"]); + + // The menu closed with the choice, so Enter reports nothing more. + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*actions.borrow(), vec!["rename"]); + + // Right-click again reopens; Down reaches the second item. + right_click(&mut visual, point(px(100.), px(30.))); + draw(&mut visual); + visual.simulate_keystrokes("down"); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*actions.borrow(), vec!["rename", "delete"]); +} + +#[test] +fn a_left_click_on_the_target_does_not_open_the_menu() { + let (_cx, mut visual, actions) = context_window(); + + click(&mut visual, point(px(100.), px(30.))); + draw(&mut visual); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*actions.borrow(), Vec::<&str>::new()); +} + +#[test] +fn a_click_outside_closes_the_menu() { + let (_cx, mut visual, actions) = context_window(); + + right_click(&mut visual, point(px(100.), px(30.))); + draw(&mut visual); + click(&mut visual, point(px(450.), px(300.))); + draw(&mut visual); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*actions.borrow(), Vec::<&str>::new()); + + // A right-click outside the target never opens it either. + right_click(&mut visual, point(px(450.), px(300.))); + draw(&mut visual); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*actions.borrow(), Vec::<&str>::new()); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index ee81533..96b0553 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -263,7 +263,7 @@ Wave M1 — unblocks the port; each through the review gate: | M1 | core | Badge ✅ Landed (2026-11-01) | unsynced-count pills on sidebar repo rows | bezel ships quiet + active variants; gpuikit secondary/outline/destructive. Shipped five semantic variants — Neutral (muted fill), Accent, Success, Warning, Danger (the ramps) — painted from existing roles, no badge palette (D22); pill = `Radius.full`, size-table text at 0.8×, caller-refinable via `Styled` | | M2 | core | Select ✅ Landed (2026-11-01) | default-branch chooser, event-fetch-strategy setting, explore filter | bezel: field-shaped trigger over a caller-assembled menu; gpuikit: placeholder + clear. Shipped as a controlled value (`selected(Option)` + `on_change(Option)`, tuples in) with the popup owned per instance via element-namespace-keyed state; composes `Menu` — field-shaped trigger (`input`/`border`/`ring` tokens, `Role::ComboBox`), options as checked items. Menu gained `initial_selection` so a popup opens keyboard-highlighted on the current value, like native popups, and its panel dropped the `max_h(relative(0.75))` fraction — resolved against the wrong box inside nested flex/scroll shells, it clipped the last items (found in the gallery); row height is governed by the ScrollArea's 320 px cap and `anchored` handles window fit. Feature `select = ["menu"]`. **Follow-ups (2026-11-01):** Menu's check indicator moved to the row's right end (Adwaita dropdown look; the optional icon keeps the leading slot), and Select options gained optional leading icons — `SelectOption::new(value, label).icon(role)`, with `(value, label)` tuples still accepted through `From` | | M3 | core | Segmented control ✅ Landed (2026-11-01) | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select only, multiple waits. Shipped as a controlled single-select (`Segmented::new(id, selected)` + `options((value, label))` + `on_change(T)` — exactly one segment always active); muted pill track, the selected segment on a raised `surface` plate with border; left/right/home/end move and activate when the strip has focus (no wrap), `RadioGroup`/`RadioButton` roles with `aria_toggled`; renders hugging via a wrapper so it works in block and flex parents alike. Plain `segmented` feature, no deps. **Follow-ups (2026-11-01):** the selected plate dropped its border for a small raised shadow — a third `Elevations.control` class, profiled per platform like popover/dialog (D24); `Size::Small` became the default and medium/large resolve through the size table — taller tracks (`control_height × 1.15 / 1.35`), a scaled track inset (`0.125 / 0.25 / 0.375 rem`) and segment padding (`0.5× / 0.75× / 0.75×` of `padding_x`), with the large label staying at the medium size; options take optional leading icons (`SegmentedOption::new(value, label).icon(role)`, tuples still accepted through `From`); and the selected plate is now a measured, absolutely-positioned layer that springs between segments (per-segment measure canvases feed keyed geometry; the plate springs on `left` and `width` separately, Switch-style — mounts at its target so the first frame never animates, retargets mid-flight, and snaps under reduce-motion). **Follow-up 2 (2026-11-01):** the track inset also scales per size (`0.125 / 0.25 / 0.375 rem`), and a settled-plate regression test guards the plate's existence and slot (`debug_bounds("segmented-plate")`) | -| M4 | core | Context menu | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Reuses the Menu panel | +| M4 | core | Context menu ✅ Landed (2026-11-01) | repo rows, file-tree rows, tab bar | gpuikit attaches the menu to an existing element; bezel keys one-open-at-a-time. Shipped as a self-contained `ContextMenu::new(id).target(el).items(...)` — open state and pointer position are namespaced keyed state, right-click opens at the pointer, left clicks pass through to the target untouched, and any click outside, escape, or activation closes. Composes `Menu`, which gained `trigger_button(MouseButton)` and `open_at(Point)` for pointer-anchored opening (outside-click dismissal now closes on any button). Plain `context_menu` feature, depends on `menu` | | M5 | core | Empty state | inbox, issues, search misses | bezel: icon + headline + hint + optional action. Trivial paint over tokens | | M6 | input | Password input | identity unlock + `nsec` import passphrase | lifts §6.2's "password masking on first need" — the need arrived | | M7 | input | Multiline textarea | discussion composer, patch + commit messages | bezel: fixed rows or min/max auto-grow; gpuikit: rows on InputState. Reuses Input's buffer; D12's editor crate stays separate | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 8f56d66..f650301 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -12,6 +12,7 @@ use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; use gpui_native_core::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::dialog::Dialog; use gpui_native_core::input::{Input, InputState}; use gpui_native_core::link::Link; @@ -47,6 +48,7 @@ struct Gallery { menu_open: bool, strategy: Option, explore_filter: ExploreFilter, + context_action: SharedString, dialog_open: bool, alert_open: bool, update_alert_dismissed: bool, @@ -76,6 +78,9 @@ enum ExploreFilter { Recent, } +/// The context menu section's demo actions. +const CONTEXT_ACTIONS: [&str; 3] = ["Rename", "Duplicate", "Archive"]; + /// A demo model for the tree section: the app owns the real tree and its /// expansion set, and flattens the visible nodes for the component. #[derive(Clone)] @@ -473,6 +478,55 @@ impl Render for Gallery { ) }), ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label( + "context menu · native refs — NSMenu (context), WinUI MenuFlyout, Adwaita context popover", + colors, + )) + .child({ + let gallery = gallery.clone(); + ContextMenu::new("gallery-context") + .target(move |_, cx| { + let theme = cx.theme(); + div() + .px_3() + .py_2() + .rounded(theme.radius.overlay) + .border_1() + .border_color(theme.colors.border) + .bg(theme.colors.surface) + .text_size(theme.sizes.medium.text_size) + .child("Right-click this card") + .into_any_element() + }) + .items(move |_, _| { + CONTEXT_ACTIONS + .iter() + .map(|action| { + let gallery = gallery.clone(); + MenuItem::new(*action).handler(move |_, cx| { + gallery.update(cx, |gallery, cx| { + gallery.context_action = + SharedString::from(*action); + cx.notify(); + }); + }) + }) + .collect() + }) + .accessibility_label("Card actions") + }) + .child( + div() + .text_sm() + .text_color(colors.muted_foreground) + .child(self.context_action.clone()), + ), + ) .child( div() .flex() @@ -1394,6 +1448,7 @@ fn main() { menu_open: false, strategy: Some(Strategy::Curated), explore_filter: ExploreFilter::All, + context_action: "Right-click the card".into(), dialog_open: false, alert_open: false, update_alert_dismissed: false,