add context menu
This commit is contained in:
@@ -19,6 +19,7 @@ default = ["full"]
|
|||||||
full = [
|
full = [
|
||||||
"button",
|
"button",
|
||||||
"checkbox",
|
"checkbox",
|
||||||
|
"context_menu",
|
||||||
"switch",
|
"switch",
|
||||||
"radio",
|
"radio",
|
||||||
"toggle",
|
"toggle",
|
||||||
@@ -40,6 +41,7 @@ full = [
|
|||||||
]
|
]
|
||||||
button = []
|
button = []
|
||||||
checkbox = []
|
checkbox = []
|
||||||
|
context_menu = ["menu"]
|
||||||
switch = []
|
switch = []
|
||||||
radio = []
|
radio = []
|
||||||
toggle = []
|
toggle = []
|
||||||
|
|||||||
@@ -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<dyn Fn(&mut Window, &mut App) -> gpui::AnyElement>;
|
||||||
|
/// Builds the menu's items fresh each render.
|
||||||
|
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<MenuItem>>;
|
||||||
|
|
||||||
|
/// A menu that opens at the pointer when its target is right-clicked.
|
||||||
|
#[derive(IntoElement)]
|
||||||
|
pub struct ContextMenu {
|
||||||
|
id: ElementId,
|
||||||
|
target: Option<TargetFn>,
|
||||||
|
items: Option<ItemsFn>,
|
||||||
|
a11y_label: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ContextMenu {
|
||||||
|
/// Creates a context menu over a target set with [`ContextMenu::target`].
|
||||||
|
pub fn new<I>(id: I) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
{
|
||||||
|
Self {
|
||||||
|
id: id.into(),
|
||||||
|
target: None,
|
||||||
|
items: None,
|
||||||
|
a11y_label: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the element that opens the menu on right-click.
|
||||||
|
pub fn target<F>(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<F>(mut self, items: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(&mut Window, &mut App) -> Vec<MenuItem> + 'static,
|
||||||
|
{
|
||||||
|
self.items = Some(Box::new(items));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Overrides the menu's accessibility label.
|
||||||
|
pub fn accessibility_label<L>(mut self, label: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ pub mod button;
|
|||||||
#[cfg(feature = "checkbox")]
|
#[cfg(feature = "checkbox")]
|
||||||
pub mod checkbox;
|
pub mod checkbox;
|
||||||
|
|
||||||
|
#[cfg(feature = "context_menu")]
|
||||||
|
pub mod context_menu;
|
||||||
|
|
||||||
#[cfg(feature = "switch")]
|
#[cfg(feature = "switch")]
|
||||||
pub mod switch;
|
pub mod switch;
|
||||||
|
|
||||||
|
|||||||
+26
-6
@@ -5,7 +5,7 @@ use gpui::prelude::FluentBuilder as _;
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, DispatchPhase, ElementId,
|
Animation, AnimationExt as _, AnyElement, App, Bounds, BoxShadow, DispatchPhase, ElementId,
|
||||||
Entity, InteractiveElement as _, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent,
|
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,
|
StatefulInteractiveElement as _, Styled as _, Window, anchored, canvas, deferred, div,
|
||||||
ease_out_quint, px,
|
ease_out_quint, px,
|
||||||
};
|
};
|
||||||
@@ -146,6 +146,8 @@ pub struct Menu {
|
|||||||
id: ElementId,
|
id: ElementId,
|
||||||
open: bool,
|
open: bool,
|
||||||
initial_selection: Option<usize>,
|
initial_selection: Option<usize>,
|
||||||
|
trigger_button: MouseButton,
|
||||||
|
open_at: Option<Point<Pixels>>,
|
||||||
a11y_label: Option<SharedString>,
|
a11y_label: Option<SharedString>,
|
||||||
on_change: Option<ChangeHandler>,
|
on_change: Option<ChangeHandler>,
|
||||||
trigger: Option<TriggerFn>,
|
trigger: Option<TriggerFn>,
|
||||||
@@ -162,6 +164,8 @@ impl Menu {
|
|||||||
id: id.into(),
|
id: id.into(),
|
||||||
open: false,
|
open: false,
|
||||||
initial_selection: None,
|
initial_selection: None,
|
||||||
|
trigger_button: MouseButton::Left,
|
||||||
|
open_at: None,
|
||||||
a11y_label: None,
|
a11y_label: None,
|
||||||
on_change: None,
|
on_change: None,
|
||||||
trigger: None,
|
trigger: None,
|
||||||
@@ -175,6 +179,18 @@ impl Menu {
|
|||||||
self
|
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<Option<Point<Pixels>>>) -> Self {
|
||||||
|
self.open_at = position.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
/// Sets the current open state.
|
/// Sets the current open state.
|
||||||
pub fn open(mut self, open: bool) -> Self {
|
pub fn open(mut self, open: bool) -> Self {
|
||||||
self.open = open;
|
self.open = open;
|
||||||
@@ -264,6 +280,7 @@ impl RenderOnce for Menu {
|
|||||||
{
|
{
|
||||||
let on_change = self.on_change.clone();
|
let on_change = self.on_change.clone();
|
||||||
let open = self.open;
|
let open = self.open;
|
||||||
|
let trigger_button = self.trigger_button;
|
||||||
let cell = trigger_bounds.clone();
|
let cell = trigger_bounds.clone();
|
||||||
let was_open = was_open.clone();
|
let was_open = was_open.clone();
|
||||||
move |_, _, window, cx| {
|
move |_, _, window, cx| {
|
||||||
@@ -277,7 +294,7 @@ impl RenderOnce for Menu {
|
|||||||
let on_change = on_change.clone();
|
let on_change = on_change.clone();
|
||||||
move |event: &MouseDownEvent, phase, window, cx| {
|
move |event: &MouseDownEvent, phase, window, cx| {
|
||||||
if phase != DispatchPhase::Bubble
|
if phase != DispatchPhase::Bubble
|
||||||
|| event.button != MouseButton::Left
|
|| event.button != trigger_button
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -390,9 +407,7 @@ impl RenderOnce for Menu {
|
|||||||
window.on_mouse_event({
|
window.on_mouse_event({
|
||||||
let on_change = on_change.clone();
|
let on_change = on_change.clone();
|
||||||
move |event: &MouseDownEvent, phase, window, cx| {
|
move |event: &MouseDownEvent, phase, window, cx| {
|
||||||
if phase != DispatchPhase::Bubble
|
if phase != DispatchPhase::Bubble {
|
||||||
|| event.button != MouseButton::Left
|
|
||||||
{
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,7 +434,12 @@ impl RenderOnce for Menu {
|
|||||||
.inset_0(),
|
.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",
|
"menu-enter",
|
||||||
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
Animation::new(enter_duration).with_easing(ease_out_quint()),
|
||||||
|panel, delta| panel.opacity(delta),
|
|panel, delta| panel.opacity(delta),
|
||||||
|
|||||||
@@ -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<RefCell<Vec<&'static str>>>;
|
||||||
|
|
||||||
|
struct Harness {
|
||||||
|
actions: Actions,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Harness {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<gpui::Pixels>) {
|
||||||
|
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<gpui::Pixels>) {
|
||||||
|
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());
|
||||||
|
}
|
||||||
+1
-1
@@ -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` |
|
| 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<T>)` + `on_change(Option<T>)`, 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` |
|
| 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<T>)` + `on_change(Option<T>)`, 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")`) |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ use gpui_native_core::badge::{Badge, BadgeVariant};
|
|||||||
use gpui_native_core::button::{Button, ButtonVariant};
|
use gpui_native_core::button::{Button, ButtonVariant};
|
||||||
use gpui_native_core::checkbox::Checkbox;
|
use gpui_native_core::checkbox::Checkbox;
|
||||||
use gpui_native_core::common::{Sizable as _, h_flex, v_flex};
|
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::dialog::Dialog;
|
||||||
use gpui_native_core::input::{Input, InputState};
|
use gpui_native_core::input::{Input, InputState};
|
||||||
use gpui_native_core::link::Link;
|
use gpui_native_core::link::Link;
|
||||||
@@ -47,6 +48,7 @@ struct Gallery {
|
|||||||
menu_open: bool,
|
menu_open: bool,
|
||||||
strategy: Option<Strategy>,
|
strategy: Option<Strategy>,
|
||||||
explore_filter: ExploreFilter,
|
explore_filter: ExploreFilter,
|
||||||
|
context_action: SharedString,
|
||||||
dialog_open: bool,
|
dialog_open: bool,
|
||||||
alert_open: bool,
|
alert_open: bool,
|
||||||
update_alert_dismissed: bool,
|
update_alert_dismissed: bool,
|
||||||
@@ -76,6 +78,9 @@ enum ExploreFilter {
|
|||||||
Recent,
|
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
|
/// 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.
|
/// expansion set, and flattens the visible nodes for the component.
|
||||||
#[derive(Clone)]
|
#[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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex()
|
.flex()
|
||||||
@@ -1394,6 +1448,7 @@ fn main() {
|
|||||||
menu_open: false,
|
menu_open: false,
|
||||||
strategy: Some(Strategy::Curated),
|
strategy: Some(Strategy::Curated),
|
||||||
explore_filter: ExploreFilter::All,
|
explore_filter: ExploreFilter::All,
|
||||||
|
context_action: "Right-click the card".into(),
|
||||||
dialog_open: false,
|
dialog_open: false,
|
||||||
alert_open: false,
|
alert_open: false,
|
||||||
update_alert_dismissed: false,
|
update_alert_dismissed: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user