diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 9d242cc..31eb87d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -32,6 +32,7 @@ full = [ "scroll_area", "tooltip", "menu", + "select", "dialog", "alert", "toast", @@ -51,6 +52,7 @@ popover = [] scroll_area = [] tooltip = [] menu = ["scroll_area"] +select = ["menu"] dialog = [] alert = [] toast = [] diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 52dee57..4f576d7 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -18,6 +18,9 @@ pub mod switch; #[cfg(feature = "radio")] pub mod radio; +#[cfg(feature = "select")] +pub mod select; + #[cfg(feature = "toggle")] pub mod toggle; diff --git a/crates/core/src/menu.rs b/crates/core/src/menu.rs index 1a98626..0f83caf 100644 --- a/crates/core/src/menu.rs +++ b/crates/core/src/menu.rs @@ -7,7 +7,7 @@ use gpui::{ 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, + ease_out_quint, px, }; use gpui_native_icons::{IconRole, icon as icon_glyph}; use gpui_native_theme::ActiveTheme as _; @@ -144,6 +144,7 @@ struct MenuChrome { pub struct Menu { id: ElementId, open: bool, + initial_selection: Option, a11y_label: Option, on_change: Option, trigger: Option, @@ -159,6 +160,7 @@ impl Menu { Self { id: id.into(), open: false, + initial_selection: None, a11y_label: None, on_change: None, trigger: None, @@ -166,6 +168,12 @@ impl Menu { } } + /// Sets the row keyboard-highlighted when the menu opens. + pub fn initial_selection(mut self, index: usize) -> Self { + self.initial_selection = Some(index); + self + } + /// Sets the current open state. pub fn open(mut self, open: bool) -> Self { self.open = open; @@ -310,6 +318,8 @@ impl RenderOnce for Menu { 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); @@ -339,7 +349,6 @@ impl RenderOnce for Menu { .aria_label(label) .mt_1() .min_w_40() - .max_h(relative(0.75)) .p_1() .bg(chrome.surface) .text_color(chrome.foreground) @@ -490,15 +499,6 @@ fn render_item( } } }) - .when_some(checked, |row, checked| { - row.child(div().flex_none().when(checked, |slot| { - slot.child( - icon_glyph(IconRole::Check) - .size(chrome.icon_size * 0.75) - .color(label_color), - ) - })) - }) .when_some(icon_role, |row, role| { row.child(icon_glyph(role).size(chrome.icon_size).color(label_color)) }) @@ -519,6 +519,13 @@ fn render_item( .child(shortcut), ) }) + .when(checked == Some(true), |row| { + row.child( + icon_glyph(IconRole::Check) + .size(chrome.icon_size * 0.75) + .color(label_color), + ) + }) .into_any_element() } } diff --git a/crates/core/src/select.rs b/crates/core/src/select.rs new file mode 100644 index 0000000..b70c20c --- /dev/null +++ b/crates/core/src/select.rs @@ -0,0 +1,277 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, ElementId, InteractiveElement as _, IntoElement, ParentElement as _, RenderOnce, Role, + SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled as _, Window, +}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::{ActiveTheme as _, Size}; + +use crate::common::{Sizable, StyledExt, h_flex}; +use crate::menu::{Menu, MenuItem}; + +/// Reports the value the user chose. `None` means the select holds no value. +pub type SelectHandler = Rc, &mut Window, &mut App)>; + +/// Select option for a [`Select`] control. +#[derive(Clone)] +pub struct SelectOption { + value: T, + label: SharedString, + icon: Option, +} + +impl SelectOption { + /// Creates a choice showing `label`; picking it reports `value`. + pub fn new(value: T, label: L) -> Self + where + L: Into, + { + Self { + value, + label: label.into(), + icon: None, + } + } + + /// Sets a leading icon for the option's row. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + self + } +} + +impl From<(T, L)> for SelectOption +where + L: Into, +{ + fn from((value, label): (T, L)) -> Self { + Self { + value, + label: label.into(), + icon: None, + } + } +} + +/// A control that shows the chosen value and opens a menu of the choices. +#[derive(IntoElement)] +pub struct Select +where + T: Clone + PartialEq + 'static, +{ + id: ElementId, + options: Vec>, + selected: Option, + placeholder: SharedString, + a11y_label: Option, + size: Size, + style: StyleRefinement, + on_change: Option>, +} + +impl Select +where + T: Clone + PartialEq + 'static, +{ + /// Creates a closed select holding no value. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + options: Vec::new(), + selected: None, + placeholder: SharedString::from("Select..."), + a11y_label: None, + size: Size::default(), + style: StyleRefinement::default(), + on_change: None, + } + } + + /// Sets the choice. + pub fn options(mut self, options: I) -> Self + where + I: IntoIterator, + I::Item: Into>, + { + self.options = options.into_iter().map(Into::into).collect(); + self + } + + /// Sets the current value. `None` shows the placeholder. + pub fn selected(mut self, selected: impl Into>) -> Self { + self.selected = selected.into(); + self + } + + /// Sets the text shown while no value is chosen. + pub fn placeholder(mut self, placeholder: L) -> Self + where + L: Into, + { + self.placeholder = placeholder.into(); + self + } + + /// Reports the chosen value, when the user activates an option. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(Option, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Overrides the trigger's accessibility label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +impl Sizable for Select +where + T: Clone + PartialEq + 'static, +{ + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + +impl gpui::Styled for Select +where + T: Clone + PartialEq + 'static, +{ + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + +impl RenderOnce for Select +where + T: Clone + PartialEq + 'static, +{ + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let metrics = theme.sizes.resolve(self.size); + let border = theme.colors.border; + let ring = theme.colors.ring; + let input = theme.colors.input; + let muted = theme.colors.muted; + let foreground = theme.colors.foreground; + let muted_foreground = theme.colors.muted_foreground; + let radius = theme.radius.control; + let icon_size = metrics.icon_size * 0.75; + + let label = self + .a11y_label + .clone() + .unwrap_or_else(|| self.placeholder.clone()); + + let open = window.with_element_namespace(self.id.clone(), |window| { + window + .use_keyed_state("select-open", cx, |_, _| false) + .clone() + }); + let is_open = *open.read(cx); + + let selected_index = self.selected.as_ref().and_then(|value| { + self.options + .iter() + .position(|option| &option.value == value) + }); + + let trigger_options = self.options.clone(); + let trigger_selected = self.selected.clone(); + let trigger_placeholder = self.placeholder.clone(); + let trigger_id = self.id.clone(); + let trigger_label = label.clone(); + let trigger_style = self.style; + let trigger = move |_window: &mut Window, _cx: &mut App| { + let text = trigger_selected + .as_ref() + .and_then(|value| { + trigger_options + .iter() + .find(|option| &option.value == value) + .map(|option| option.label.clone()) + }) + .unwrap_or_else(|| trigger_placeholder.clone()); + + h_flex() + .id((trigger_id.clone(), "select-trigger")) + .role(Role::ComboBox) + .aria_label(trigger_label.clone()) + .cursor_pointer() + .justify_between() + .gap_2() + .w_full() + .h(metrics.input_height) + .px(metrics.padding_x / 2.0) + .rounded(radius) + .border_1() + .border_color(if is_open { ring } else { border }) + .bg(input) + .text_size(metrics.text_size) + .text_color(if trigger_selected.is_some() { + foreground + } else { + muted_foreground + }) + .hover(|style| style.bg(muted)) + .child(text) + .child( + icon(IconRole::ChevronDown) + .size(icon_size) + .color(muted_foreground), + ) + .refine_style(&trigger_style) + .into_any_element() + }; + + let open_for_items = open.clone(); + let items = move |_window: &mut Window, _cx: &mut App| { + self.options + .iter() + .map(|option| { + let is_selected = self.selected.as_ref() == Some(&option.value); + + let handler = { + let open = open_for_items.clone(); + let value = option.value.clone(); + let on_change = self.on_change.clone(); + move |window: &mut Window, cx: &mut App| { + open.update(cx, |open, _| *open = false); + if let Some(on_change) = &on_change { + on_change(Some(value.clone()), window, cx); + } + } + }; + + let mut row = MenuItem::new(option.label.clone()); + if let Some(role) = option.icon { + row = row.icon(role); + } + row.checked(is_selected).handler(handler) + }) + .collect() + }; + + let menu = Menu::new(self.id) + .open(is_open) + .on_change(move |next, _window, cx| { + open.update(cx, |open, _| *open = next); + }) + .trigger(trigger) + .items(items); + + menu.when_some(selected_index, |menu, index| menu.initial_selection(index)) + } +} diff --git a/crates/core/tests/select.rs b/crates/core/tests/select.rs new file mode 100644 index 0000000..5f7f77b --- /dev/null +++ b/crates/core/tests/select.rs @@ -0,0 +1,202 @@ +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::select::Select; +use gpui_native_theme::Theme; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Strategy { + Curated, + Uncensored, +} + +const STRATEGIES: [(Strategy, &str); 2] = [ + (Strategy::Curated, "Curated"), + (Strategy::Uncensored, "Uncensored"), +]; + +type Changes = Rc>>>; + +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, gpui::Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, gpui::Modifiers::default()); +} + +struct Harness { + strategy: Option, + changes: Changes, +} + +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(); + div().size_full().p_4().child( + Select::new("strategy") + .options(STRATEGIES) + .selected(self.strategy) + .on_change(move |next, _window, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.strategy = next; + cx.notify(); + }); + }), + ) + } +} + +/// One select: trigger band is `16..46` vertically (`p_4` + the 30 px field). +fn select_window() -> (TestAppContext, VisualTestContext, Changes) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + strategy: None, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + draw(&mut visual); + (cx, visual, changes) +} + +struct Pair { + first: Option, + second: Option, + first_changes: Changes, + second_changes: Changes, +} + +impl Render for Pair { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let view = cx.entity(); + let first_changes = self.first_changes.clone(); + let second_changes = self.second_changes.clone(); + let first_view = view.clone(); + let second_view = view.clone(); + + div().size_full().p_4().child( + div() + .flex() + .flex_col() + .gap_2() + .child( + Select::new("first") + .options(STRATEGIES) + .selected(self.first) + .on_change(move |next, _window, cx| { + first_changes.borrow_mut().push(next); + first_view.update(cx, |pair, cx| { + pair.first = next; + cx.notify(); + }); + }), + ) + .child( + Select::new("second") + .options(STRATEGIES) + .selected(self.second) + .on_change(move |next, _window, cx| { + second_changes.borrow_mut().push(next); + second_view.update(cx, |pair, cx| { + pair.second = next; + cx.notify(); + }); + }), + ), + ) + } +} + +/// Two selects: bands `16..46` and `54..84` (the 8 px gap between). +fn pair_window() -> (TestAppContext, VisualTestContext, Changes, Changes) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let first_changes = Rc::new(RefCell::new(Vec::new())); + let second_changes = Rc::new(RefCell::new(Vec::new())); + let first = first_changes.clone(); + let second = second_changes.clone(); + let window = cx.add_window(move |_, _| Pair { + first: None, + second: None, + first_changes: first, + second_changes: second, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + draw(&mut visual); + (cx, visual, first_changes, second_changes) +} + +#[test] +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. + click(&mut visual, point(px(100.), px(31.))); + draw(&mut visual); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(Strategy::Curated)]); + + // The popup closed with the choice, so another Enter reports nothing. + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(Strategy::Curated)]); + + // The trigger reopens; the second option is two Downs away. + click(&mut visual, point(px(100.), px(31.))); + draw(&mut visual); + visual.simulate_keystrokes("down"); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!( + *changes.borrow(), + vec![Some(Strategy::Curated), Some(Strategy::Uncensored)] + ); +} + +#[test] +fn the_controlled_value_round_trips_through_the_owner() { + let (_cx, mut visual, changes) = select_window(); + + click(&mut visual, point(px(100.), px(31.))); + draw(&mut visual); + visual.simulate_keystrokes("down"); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + + // The harness stored the reported value, so the select now holds it. + click(&mut visual, point(px(100.), px(31.))); + draw(&mut visual); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + assert_eq!( + *changes.borrow(), + vec![Some(Strategy::Uncensored), Some(Strategy::Uncensored)] + ); +} + +#[test] +fn two_selects_keep_their_popups_independent() { + let (_cx, mut visual, first_changes, second_changes) = pair_window(); + + // Opening the second select never touches the first. + click(&mut visual, point(px(100.), px(69.))); + draw(&mut visual); + visual.simulate_keystrokes("enter"); + draw(&mut visual); + + assert_eq!(*first_changes.borrow(), vec![]); + assert_eq!(*second_changes.borrow(), vec![Some(Strategy::Curated)]); +} diff --git a/crates/core/tests/select_pointer.rs b/crates/core/tests/select_pointer.rs new file mode 100644 index 0000000..c24c4d9 --- /dev/null +++ b/crates/core/tests/select_pointer.rs @@ -0,0 +1,98 @@ +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::select::Select; +use gpui_native_theme::Theme; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Level { + Low, + Mid, + High, +} + +const LEVELS: [(Level, &str); 3] = [ + (Level::Low, "Low"), + (Level::Mid, "Mid"), + (Level::High, "High"), +]; + +type Changes = Rc>>>; + +struct Harness { + value: Option, + changes: Changes, +} + +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(); + div().size_full().p_4().child( + Select::new("level") + .options(LEVELS) + .selected(self.value) + .on_change(move |next, _window, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.value = next; + cx.notify(); + }); + }), + ) + } +} + +fn level_window() -> (TestAppContext, VisualTestContext, Changes) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + value: None, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, changes) +} + +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()); +} + +/// The trigger band is `16..46`; the panel hangs 4 px below it with a 4 px +/// inner padding, so the three rows ride `~58..90`, `~90..122`, `~122..154`. +/// Every option must answer a pointer press — a popup that clips its last +/// item never does. +#[test] +fn every_option_row_answers_the_pointer() { + let (_cx, mut visual, changes) = level_window(); + + let expected = [ + (px(74.), Level::Low), + (px(106.), Level::Mid), + (px(138.), Level::High), + ]; + for (y, value) in expected { + click(&mut visual, point(px(100.), px(31.))); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + click(&mut visual, point(px(100.), y)); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + assert_eq!( + changes.borrow().last(), + Some(&Some(value)), + "pressing {value:?}'s row must choose it" + ); + } +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 20ffd35..34af462 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -261,7 +261,7 @@ Wave M1 — unblocks the port; each through the review gate: | Unit | Crate | Component | Signed consumer | Survey notes | | --- | --- | --- | --- | --- | | 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 | default-branch chooser, event-fetch-strategy setting, explore filter | bezel: field-shaped trigger over a caller-assembled menu; gpuikit: placeholder + clear. Composes our Popover + Menu — no new overlay machinery | +| 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 | All/Popular/Recent explore filters | bezel's toggle-group pill; single-select first, multiple waits | | 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 | | M5 | core | Empty state | inbox, issues, search misses | bezel: icon + headline + hint + optional action. Trivial paint over tokens | diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index e255918..60e389a 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -20,6 +20,7 @@ use gpui_native_core::popover::Popover; use gpui_native_core::progress::Progress; use gpui_native_core::radio::{Radio, RadioGroup}; use gpui_native_core::scroll_area::ScrollArea; +use gpui_native_core::select::{Select, SelectOption}; use gpui_native_core::slider::Slider; use gpui_native_core::switch::Switch; use gpui_native_core::toast::{Toast, ToastAction, Toasts, ToastsView}; @@ -43,6 +44,7 @@ struct Gallery { input_state: Entity, popover_open: bool, menu_open: bool, + strategy: Option, dialog_open: bool, alert_open: bool, update_alert_dismissed: bool, @@ -56,6 +58,13 @@ struct Gallery { sidebar_selected: Option, } +/// The select section's demo value. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Strategy { + Curated, + Uncensored, +} + /// 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)] @@ -410,6 +419,49 @@ impl Render for Gallery { }) }), ) + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(section_label( + "select · native refs — NSPopUpButton, WinUI ComboBox, Adwaita Dropdown", + colors, + )) + .child({ + let gallery = gallery.clone(); + div().w(gpui::px(280.)).flex().flex_col().gap_3().child( + Select::new("gallery-strategy") + .options(vec![ + SelectOption::new(Strategy::Curated, "Curated") + .icon(IconRole::Check), + SelectOption::new(Strategy::Uncensored, "Uncensored") + .icon(IconRole::Warning), + ]) + .selected(self.strategy) + .placeholder("Fetching strategy") + .on_change(move |next, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.strategy = next; + cx.notify(); + }); + }), + ) + }) + .child({ + div().w(gpui::px(280.)).child( + Select::new("gallery-size") + .options(vec![ + ("small", "Small"), + ("medium", "Medium"), + ("large", "Large"), + ]) + .selected("medium") + .placeholder("Control size") + .small(), + ) + }), + ) .child( v_flex() .gap_2() @@ -1277,6 +1329,7 @@ fn main() { input_state, popover_open: false, menu_open: false, + strategy: Some(Strategy::Curated), dialog_open: false, alert_open: false, update_alert_dismissed: false,