From ded23ab2f20e49b6dc82859e42011d0e2f9b6dc0 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 4 Nov 2026 08:08:02 +0700 Subject: [PATCH] feat: add combobox component --- crates/core/Cargo.toml | 2 + crates/core/src/combobox.rs | 670 ++++++++++++++++++++++++++++ crates/core/src/lib.rs | 7 + crates/core/tests/combobox.rs | 227 ++++++++++ crates/core/tests/combobox_bench.rs | 88 ++++ docs/CHECKLIST.md | 5 +- examples/gallery/src/main.rs | 130 ++++++ 7 files changed, 1126 insertions(+), 3 deletions(-) create mode 100644 crates/core/src/combobox.rs create mode 100644 crates/core/tests/combobox.rs create mode 100644 crates/core/tests/combobox_bench.rs diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 083ec1a..737d29d 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -35,6 +35,7 @@ full = [ "spinner", "field", "status_dot", + "combobox", "tooltip", "menu", "select", @@ -61,6 +62,7 @@ segmented = [] spinner = [] field = [] status_dot = [] +combobox = ["input"] tooltip = [] menu = ["scroll_area"] select = ["menu"] diff --git a/crates/core/src/combobox.rs b/crates/core/src/combobox.rs new file mode 100644 index 0000000..3ed5f89 --- /dev/null +++ b/crates/core/src/combobox.rs @@ -0,0 +1,670 @@ +//! The combobox: an editable field that filters its options while typing. +//! +//! The state is the view: it owns the query field and the options, and the +//! owner renders the entity itself. Typing filters, the arrow keys move a +//! highlight, Enter commits, and what happens to unmatched text when the +//! field loses focus is [`UnmatchedText`] — revert by default. + +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + App, Bounds, Context, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, + KeyBinding, MouseButton, MouseDownEvent, ParentElement as _, Pixels, Render, Role, + ScrollHandle, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, + actions, canvas, deferred, div, px, +}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::{ActiveTheme as _, Size}; + +use crate::common::{Sizable, h_flex, v_flex}; +use crate::input::{Input, InputState, InputStateEvent}; +use crate::scroll_area::ScrollArea; + +actions!( + combobox, + [HighlightNext, HighlightPrevious, Choose, Dismiss] +); + +/// The key context the combobox's wrapper declares. +pub const COMBOBOX_CONTEXT: &str = "Combobox"; + +/// Binds the combobox's four keys: down, up, enter, and escape. +pub fn bind_combobox_keys(cx: &mut App) { + let under_input = Some("Combobox > Input"); + cx.bind_keys([ + KeyBinding::new("down", HighlightNext, under_input), + KeyBinding::new("up", HighlightPrevious, under_input), + KeyBinding::new("enter", Choose, under_input), + KeyBinding::new("escape", Dismiss, under_input), + KeyBinding::new("down", HighlightNext, Some(COMBOBOX_CONTEXT)), + KeyBinding::new("up", HighlightPrevious, Some(COMBOBOX_CONTEXT)), + KeyBinding::new("enter", Choose, Some(COMBOBOX_CONTEXT)), + KeyBinding::new("escape", Dismiss, Some(COMBOBOX_CONTEXT)), + ]); +} + +/// What happens to text that matches no option when the field loses focus. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum UnmatchedText { + /// Restore the chosen option's label, or empty the field. + #[default] + Revert, + /// Leave the text and leave the value cleared. + Keep, + /// Hand the text to the `on_create` handler. + Create, +} + +/// An option for a [`Combobox`]: a value and the label the filter matches on. +pub type ComboboxOption = (T, SharedString); +/// Reports the value whenever it changes — including when typing clears it. +pub type ComboboxHandler = Rc, &mut Window, &mut App)>; +/// Receives unmatched text when the field loses focus in create mode. +pub type ComboboxCreateHandler = Rc; +/// A filter over an option: the query, the value, the label. +type Filter = Rc bool>; + +/// Builder for a combobox. Pass it to [`ComboboxState::new`]. +pub struct Combobox +where + T: Clone + PartialEq + 'static, +{ + id: ElementId, + name: SharedString, + options: Vec>, + selected: Option, + placeholder: SharedString, + on_change: Option>, + on_create: Option, + unmatched: UnmatchedText, + size: Size, +} + +impl Combobox +where + T: Clone + PartialEq + 'static, +{ + pub fn new(id: I, name: N, options: Vec>) -> Self + where + I: Into, + N: Into, + { + Self { + id: id.into(), + name: name.into(), + options, + selected: None, + placeholder: SharedString::from("Select..."), + on_change: None, + on_create: None, + unmatched: UnmatchedText::default(), + size: Size::default(), + } + } + + /// Sets the initially selected value; its label seeds the field. + pub fn selected(mut self, selected: impl Into>) -> Self { + self.selected = selected.into(); + self + } + + /// Sets the text shown while the field is empty. + pub fn placeholder(mut self, placeholder: L) -> Self + where + L: Into, + { + self.placeholder = placeholder.into(); + self + } + + /// Reports the value whenever it changes. + 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 + } + + /// Hands unmatched text to `handler` when the field loses focus. + pub fn on_create(mut self, handler: F) -> Self + where + F: Fn(SharedString, &mut Window, &mut App) + 'static, + { + self.on_create = Some(Rc::new(handler)); + self.unmatched = UnmatchedText::Create; + self + } + + /// Leaves unmatched text in the field on blur. + pub fn keep_unmatched_text(mut self) -> Self { + self.unmatched = UnmatchedText::Keep; + self + } + + /// Overrides the accessibility label. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.name = label.into(); + self + } +} + +/// Emitted when the combobox's value changes. +pub struct ComboboxChanged; + +/// The stateful combobox. +pub struct ComboboxState +where + T: Clone + PartialEq + 'static, +{ + id: ElementId, + name: SharedString, + placeholder: SharedString, + options: Vec>, + /// The chosen value. `None` while the text names nothing. + selected: Option, + query: Entity, + /// Indices into `options` that survived the filter, in popup row order. + visible: Vec, + /// The highlighted popup *row*, when the popup is open. + highlight: Option, + on_change: Option>, + on_create: Option, + filter: Filter, + unmatched: UnmatchedText, + size: Size, + /// Keeps the query-event subscription tied to the state's lifetime. + _subscription: Subscription, +} + +impl EventEmitter for ComboboxState where T: Clone + PartialEq + 'static {} + +impl Sizable for ComboboxState +where + T: Clone + PartialEq + 'static, +{ + fn apply_size(&mut self, size: Size) { + self.size = size; + } +} + +impl ComboboxState +where + T: Clone + PartialEq + 'static, +{ + /// Builds the state, its query field, and the subscription tying them. + pub fn new(combobox: Combobox, window: &mut Window, cx: &mut Context) -> Self { + let query = InputState::new(cx); + + // The field starts showing the label of whatever was chosen, + // so text and value agree from the first frame. + if let Some(selected) = &combobox.selected + && let Some((_, label)) = combobox.options.iter().find(|(value, _)| value == selected) + { + let label = label.to_string(); + query.update(cx, |query, cx| query.set_value_silent(label, window, cx)); + } + + let subscription = + cx.subscribe_in(&query, window, |state, _, event, window, cx| match event { + InputStateEvent::TextChanged => state.text_changed(window, cx), + InputStateEvent::Blurred => state.blurred(window, cx), + _ => {} + }); + + let visible = (0..combobox.options.len()).collect(); + + Self { + id: combobox.id, + name: combobox.name, + placeholder: combobox.placeholder, + options: combobox.options, + selected: combobox.selected, + query, + visible, + highlight: None, + on_change: combobox.on_change, + on_create: combobox.on_create, + filter: Rc::new(|query, _value, label| { + label.to_lowercase().contains(&query.to_lowercase()) + }), + unmatched: combobox.unmatched, + size: combobox.size, + _subscription: subscription, + } + } + + /// The text currently in the field. + pub fn text(&self, cx: &App) -> String { + self.query.read(cx).value().to_string() + } + + /// Focuses the query field. + pub fn focus(&self, window: &mut Window, cx: &mut App) { + window.focus(&self.query.read(cx).focus_handle(), cx); + } + + /// Whether the option list is showing. + pub fn is_open(&self) -> bool { + self.highlight.is_some() + } + + /// The chosen value. + pub fn selected(&self) -> Option<&T> { + self.selected.as_ref() + } + + /// Replaces the value; reports it when it actually changed. + pub fn set_selected(&mut self, value: Option, window: &mut Window, cx: &mut Context) { + self.set_value(value, window, cx); + } + + /// Replaces the choice list and re-runs the current filter. + pub fn set_options(&mut self, options: Vec>, cx: &mut Context) { + self.options = options; + let query = self.query.read(cx).value().to_string(); + self.refilter(&query); + cx.notify(); + } + + /// A copy of the current choice list. + pub fn options_snapshot(&self) -> Vec> { + self.options.clone() + } + + /// The label of the chosen option, if there is one. + fn selected_label(&self) -> Option { + let selected = self.selected.as_ref()?; + self.options + .iter() + .find(|(value, _)| value == selected) + .map(|(_, label)| label.clone()) + } + + /// Recomputes `visible` for `query` and hands the survivors' labels back. + fn refilter(&mut self, query: &str) -> Vec { + let filter = self.filter.clone(); + self.visible = self + .options + .iter() + .enumerate() + .filter(|(_, (value, label))| filter(query, value, label)) + .map(|(index, _)| index) + .collect(); + self.visible + .iter() + .map(|index| self.options[*index].1.clone()) + .collect() + } + + fn query_text(&self, cx: &Context) -> String { + self.query.read(cx).value().trim().to_string() + } + + /// Sets the value and reports it, including when it is `None`. + fn set_value(&mut self, value: Option, window: &mut Window, cx: &mut Context) { + if self.selected == value { + return; + } + self.selected = value.clone(); + if let Some(on_change) = self.on_change.clone() { + on_change(value, window, cx); + } + cx.emit(ComboboxChanged); + cx.notify(); + } + + /// Commits the option at popup *row*. + fn choose_row(&mut self, row: usize, window: &mut Window, cx: &mut Context) { + let Some(option_index) = self.visible.get(row).copied() else { + return; + }; + + let Some((value, label)) = self.options.get(option_index).cloned() else { + return; + }; + + self.query.update(cx, |query, cx| { + query.set_value_silent(label.to_string(), window, cx) + }); + self.highlight = None; + self.refilter(""); + self.set_value(Some(value), window, cx); + } + + /// Typing: re-filter, open, reset the highlight, and clear the value. + fn text_changed(&mut self, window: &mut Window, cx: &mut Context) { + let query = self.query_text(cx); + self.refilter(&query); + self.highlight = (!self.visible.is_empty()).then_some(0usize); + self.set_value(None, window, cx); + cx.notify(); + } + + /// Losing focus. [`UnmatchedText`] decides what the text becomes. + fn blurred(&mut self, window: &mut Window, cx: &mut Context) { + self.highlight = None; + let text = self.query_text(cx); + + if self.selected_label().as_deref() == Some(text.as_str()) { + cx.notify(); + return; + } + + match self.unmatched { + UnmatchedText::Revert => { + let restored = self.selected_label().unwrap_or_default().to_string(); + self.query + .update(cx, |query, cx| query.set_value_silent(restored, window, cx)); + } + UnmatchedText::Keep => {} + UnmatchedText::Create => { + if !text.is_empty() + && let Some(on_create) = self.on_create.clone() + { + on_create(SharedString::from(text), window, cx); + } + } + } + cx.notify(); + } + + /// Down and Up over the visible rows. + fn move_highlight(&mut self, delta: i32, cx: &mut Context) { + if self.visible.is_empty() { + return; + } + let current = self + .highlight + .and_then(|row| self.visible.get(row).copied()) + .and_then(|option_ix| self.visible.iter().position(|&row| row == option_ix)); + let next = match current { + Some(row) => { + let row = (row as i32 + delta).rem_euclid(self.visible.len() as i32); + row as usize + } + None => { + if delta > 0 { + 0 + } else { + self.visible.len() - 1 + } + } + }; + self.highlight = Some(next); + cx.notify(); + } + + /// Enter. Commits the highlighted row. + fn choose(&mut self, window: &mut Window, cx: &mut Context) { + let Some(row) = self.highlight else { + cx.propagate(); + return; + }; + self.choose_row(row, window, cx); + } + + /// Escape. Closes the picker. + fn dismiss(&mut self, _window: &mut Window, cx: &mut Context) { + if self.highlight.is_none() { + cx.propagate(); + return; + } + self.highlight = None; + cx.notify(); + } +} + +impl Render for ComboboxState +where + T: Clone + PartialEq + 'static, +{ + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { + let (metrics, colors, radius) = { + let theme = cx.theme(); + let metrics = theme.sizes.resolve(self.size); + (metrics, theme.colors, theme.radius) + }; + let icon_size = metrics.dense_icon_size; + let id = self.id.clone(); + let state = cx.entity(); + let open = self.highlight.is_some(); + + let chevron = h_flex() + .id((id.clone(), "chevron")) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation()) + .on_click({ + let state = state.clone(); + move |_, window, cx| { + let focus = state.read(cx).query.read(cx).focus_handle(); + window.focus(&focus, cx); + state.update(cx, |state, cx| { + if state.highlight.is_some() { + state.highlight = None; + } else { + let query = state.query.read(cx).value().to_string(); + state.refilter(&query); + state.highlight = (!state.visible.is_empty()).then_some(0usize); + } + cx.notify(); + }); + } + }) + .child( + icon(IconRole::ChevronDown) + .size(icon_size) + .color(colors.muted_foreground), + ); + + let wrapper_bounds: Rc>>> = Rc::new(RefCell::new(None)); + + let mut wrapper = div() + .id(id.clone()) + .key_context(COMBOBOX_CONTEXT) + .relative() + .w_full() + .role(Role::ComboBox) + .aria_expanded(open) + .aria_label(self.name.clone()) + .on_action({ + let state = state.clone(); + move |_: &HighlightNext, _window, cx| { + state.update(cx, |state, cx| state.move_highlight(1, cx)); + } + }) + .on_action({ + let state = state.clone(); + move |_: &HighlightPrevious, _window, cx| { + state.update(cx, |state, cx| state.move_highlight(-1, cx)); + } + }) + .on_action({ + let state = state.clone(); + move |_: &Choose, window, cx| { + state.update(cx, |state, cx| state.choose(window, cx)); + } + }) + .on_action({ + let state = state.clone(); + move |_: &Dismiss, _window, cx| { + state.update(cx, |state, cx| state.dismiss(_window, cx)); + } + }) + .child( + canvas( + { + let cell = wrapper_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + |_, _, _window, _cx| {}, + ) + .absolute() + .inset_0(), + ) + .child( + Input::new((id.clone(), "query"), self.query.clone()) + .placeholder(self.placeholder.clone()) + .with_size(self.size), + ) + .child( + h_flex() + .absolute() + .right(metrics.dense_padding_x) + .top((metrics.input_height - icon_size) / 2.) + .child(chevron), + ); + + if open { + let rows: Vec = self + .visible + .iter() + .enumerate() + .map(|(row, option_ix)| { + let (value, label) = &self.options[*option_ix]; + let highlighted = self.highlight == Some(row); + let checked = self.selected.as_ref() == Some(value); + let state = state.clone(); + let row_ix = row; + + div() + .id(("option", row)) + .debug_selector({ + let name = format!("{id}-option-{row}"); + move || name + }) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(radius.sm) + .when(highlighted, |row| row.bg(colors.muted)) + .when(!highlighted, |row| row.hover(|row| row.bg(colors.muted))) + .on_mouse_move({ + let state = state.clone(); + move |_, _window, cx| { + if state.read(cx).highlight != Some(row_ix) { + state.update(cx, |state, cx| { + state.highlight = Some(row_ix); + cx.notify(); + }); + } + } + }) + .on_click({ + let state = state.clone(); + move |_, window, cx| { + state.update(cx, |state, cx| state.choose_row(row_ix, window, cx)); + } + }) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_ellipsis() + .child(label.clone()), + ) + .when(checked, |row| { + row.child( + icon(IconRole::Check) + .size(icon_size) + .color(colors.foreground), + ) + }) + .into_any_element() + }) + .collect(); + + let rows: gpui::AnyElement = if rows.is_empty() { + div() + .px_2() + .py_1() + .text_size(metrics.caption_size) + .text_color(colors.muted_foreground) + .child("No matches") + .into_any_element() + } else { + let scroll = window + .use_keyed_state((id.clone(), "rows-scroll"), cx, |_, _| ScrollHandle::new()); + + ScrollArea::new((id.clone(), "rows")) + .handle(scroll.read(cx).clone()) + .axes(crate::scroll_area::ScrollAxes::Vertical) + .max_h(px(320.)) + .children(rows) + .into_any_element() + }; + + let panel_bounds: Rc>>> = Rc::new(RefCell::new(None)); + + let panel = v_flex() + .id((id.clone(), "panel")) + .debug_selector({ + let name = format!("{id}-panel"); + move || name + }) + .mt_1() + .w_full() + .p_1() + .bg(colors.surface) + .text_color(colors.foreground) + .rounded(radius.overlay) + .border_1() + .border_color(colors.border) + .overflow_hidden() + .child(rows) + .child( + canvas( + { + let cell = panel_bounds.clone(); + move |bounds, _window, _cx| *cell.borrow_mut() = Some(bounds) + }, + { + let state = state.clone(); + let panel_bounds = panel_bounds.clone(); + let wrapper_bounds = wrapper_bounds.clone(); + move |_, _, window, _cx| { + window.on_mouse_event({ + let state = state.clone(); + let panel_bounds = panel_bounds.clone(); + let wrapper_bounds = wrapper_bounds.clone(); + move |event: &MouseDownEvent, phase, _window, cx| { + if phase != gpui::DispatchPhase::Bubble { + return; + } + let on_panel = panel_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + let on_wrapper = wrapper_bounds + .borrow() + .is_some_and(|bounds| bounds.contains(&event.position)); + if on_panel || on_wrapper { + return; + } + state.update(cx, |state, cx| { + state.highlight = None; + cx.notify(); + }); + } + }); + } + }, + ) + .absolute() + .inset_0(), + ); + + wrapper = wrapper.child(deferred(panel)); + } + + wrapper + } +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 4606a75..a6ad497 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -75,6 +75,9 @@ pub mod field; #[cfg(feature = "status_dot")] pub mod status_dot; +#[cfg(feature = "combobox")] +pub mod combobox; + #[cfg(feature = "toast")] pub mod toast; @@ -86,4 +89,8 @@ pub fn init(cx: &mut App) { gpui_native_theme::Theme::init(cx); #[cfg(feature = "input")] input::bind_input_keys(cx, None); + // Load-bearing order: the combobox's bindings must win the depth tie + // with the input's, and gpui breaks ties by later registration. + #[cfg(feature = "combobox")] + combobox::bind_combobox_keys(cx); } diff --git a/crates/core/tests/combobox.rs b/crates/core/tests/combobox.rs new file mode 100644 index 0000000..1f2ec27 --- /dev/null +++ b/crates/core/tests/combobox.rs @@ -0,0 +1,227 @@ +use std::cell::RefCell; +use std::rc::Rc; + +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, TestAppContext, VisualTestContext, Window, div, +}; +use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; +use gpui_native_core::init; + +fn option(label: &'static str) -> ComboboxOption { + (SharedString::from(label), SharedString::from(label)) +} + +struct Host { + combobox: Entity>, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().p_4().child(self.combobox.clone()) + } +} + +type Recorder = Rc>>>; +type CreatedRecorder = Rc>>; + +struct ComboHost { + cx: TestAppContext, + state: Entity>, + changes: Recorder, + created: CreatedRecorder, + visual: VisualTestContext, +} + +fn combo_host(keep_unmatched: bool, create: bool) -> ComboHost { + let cx = TestAppContext::single(); + // `init` registers the input bindings and then the combobox's, in the + // load-bearing order. + cx.update(init); + let changes: Recorder = Rc::new(RefCell::new(Vec::new())); + let created: Rc>> = Rc::new(RefCell::new(Vec::new())); + let state_cell: Rc>>>> = + Rc::new(RefCell::new(None)); + let window = cx.update(|cx| { + let changes_for_builder = changes.clone(); + let created_for_builder = created.clone(); + let cell = state_cell.clone(); + cx.open_window(gpui::WindowOptions::default(), |window, cx| { + let mut builder = Combobox::new( + "test-combo", + "Fruit", + vec![option("main"), option("maintain"), option("release")], + ) + .on_change(move |value, _, _| changes_for_builder.borrow_mut().push(value)); + if keep_unmatched { + builder = builder.keep_unmatched_text(); + } + if create { + builder = builder + .on_create(move |text, _, _| created_for_builder.borrow_mut().push(text)); + } + let combobox = cx.new(|cx| ComboboxState::new(builder, window, cx)); + cell.borrow_mut().replace(combobox.clone()); + cx.new(|_| Host { combobox }) + }) + .unwrap() + }); + let state = state_cell.borrow().clone().expect("state captured"); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + draw(&mut visual); + ComboHost { + cx, + state, + changes, + created, + visual, + } +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +fn focus(host: &mut ComboHost) { + let state = host.state.clone(); + host.visual.update(|window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + }); +} + +fn type_text(host: &mut ComboHost, text: &str) { + focus(host); + for letter in text.chars() { + host.visual.simulate_keystrokes(&letter.to_string()); + } + draw(&mut host.visual); +} + +#[test] +fn typing_opens_the_picker_and_filters_the_options() { + let mut host = combo_host(false, false); + type_text(&mut host, "mai"); + + assert!(host.visual.debug_bounds("test-combo-option-0").is_some()); + assert!(host.visual.debug_bounds("test-combo-option-1").is_some()); + assert!( + host.visual.debug_bounds("test-combo-option-2").is_none(), + "release must not match \"mai\"" + ); +} + +#[test] +fn enter_commits_the_highlighted_option() { + let mut host = combo_host(false, false); + type_text(&mut host, "mai"); + + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + + assert_eq!( + *host.changes.borrow(), + vec![Some(SharedString::from("main"))], + "the first surviving option starts highlighted" + ); + assert!(host.visual.debug_bounds("test-combo-panel").is_none()); + let text = host.cx.update(|cx| host.state.read(cx).text(cx)); + assert_eq!(text, "main"); +} + +#[test] +fn typing_clears_the_value() { + let mut host = combo_host(false, false); + type_text(&mut host, "main"); + host.visual.simulate_keystrokes("enter"); + draw(&mut host.visual); + assert_eq!( + *host.changes.borrow(), + vec![Some(SharedString::from("main"))] + ); + + // Typing again first clears the value it no longer names. + host.visual.simulate_keystrokes("r"); + draw(&mut host.visual); + assert_eq!( + *host.changes.borrow(), + vec![Some(SharedString::from("main")), None] + ); +} + +#[test] +fn escape_closes_without_committing() { + let mut host = combo_host(false, false); + type_text(&mut host, "mai"); + + host.visual.simulate_keystrokes("escape"); + draw(&mut host.visual); + + assert!(host.visual.debug_bounds("test-combo-panel").is_none()); + assert!(host.changes.borrow().is_empty(), "escape must not commit"); +} + +#[test] +fn blur_reverts_unmatched_text_to_the_empty_selection() { + let mut host = combo_host(false, false); + type_text(&mut host, "nonsense"); + assert!(host.changes.borrow().is_empty()); + + host.visual.update(|window, cx| window.blur(cx)); + draw(&mut host.visual); + + assert!(host.changes.borrow().is_empty()); + let text = host.cx.update(|cx| host.state.read(cx).text(cx)); + assert_eq!(text, "", "revert with no value empties the field"); +} + +#[test] +fn keep_unmatched_leaves_the_text() { + let mut host = combo_host(true, false); + type_text(&mut host, "nonsense"); + + host.visual.update(|window, cx| window.blur(cx)); + draw(&mut host.visual); + + let text = host.cx.update(|cx| host.state.read(cx).text(cx)); + assert_eq!(text, "nonsense", "keep leaves the text in the field"); + // The picker started with no value, so typing cleared nothing and there + // was nothing to report. + assert!(host.changes.borrow().is_empty()); +} + +#[test] +fn create_hands_unmatched_text_to_the_handler() { + let mut host = combo_host(false, true); + type_text(&mut host, "feature"); + + host.visual.update(|window, cx| window.blur(cx)); + draw(&mut host.visual); + + assert_eq!( + *host.created.borrow(), + vec![SharedString::from("feature")], + "create hands the text over on blur" + ); +} + +#[test] +fn clicking_a_row_commits_it() { + let mut host = combo_host(false, false); + type_text(&mut host, "rel"); + + let row = host + .visual + .debug_bounds("test-combo-option-0") + .expect("release row missing"); + host.visual + .simulate_click(row.center(), gpui::Modifiers::default()); + draw(&mut host.visual); + + assert_eq!( + *host.changes.borrow(), + vec![Some(SharedString::from("release"))] + ); + assert!(host.visual.debug_bounds("test-combo-panel").is_none()); +} diff --git a/crates/core/tests/combobox_bench.rs b/crates/core/tests/combobox_bench.rs new file mode 100644 index 0000000..85fa53c --- /dev/null +++ b/crates/core/tests/combobox_bench.rs @@ -0,0 +1,88 @@ +use std::cell::RefCell; +use std::rc::Rc; +use std::time::Instant; + +use gpui::{ + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, TestAppContext, Window, div, +}; +use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; +use gpui_native_core::init; + +fn option(label: &'static str) -> ComboboxOption { + (SharedString::from(label), SharedString::from(label)) +} + +struct Host { + combobox: Entity>, + renders: Rc>, +} + +impl Render for Host { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + *self.renders.borrow_mut() += 1; + div().size_full().p_4().child(self.combobox.clone()) + } +} + +#[test] +fn real_keystrokes_are_not_pathologically_slow() { + let cx = TestAppContext::single(); + cx.update(init); + let (state, renders, window) = cx.update(|cx| { + let renders = Rc::new(RefCell::new(0)); + let state_cell: Rc>>>> = + Rc::new(RefCell::new(None)); + let renders_for_host = renders.clone(); + let cell = state_cell.clone(); + let window = cx + .open_window(gpui::WindowOptions::default(), |window, cx| { + let state = cx.new(|cx| { + ComboboxState::new( + Combobox::new( + "bench-combo", + "Fruit", + vec![ + option("main"), + option("maintain"), + option("release"), + option("banana"), + ], + ), + window, + cx, + ) + }); + let state_for_host = state.clone(); + cell.borrow_mut().replace(state.clone()); + cx.new(|_| Host { + combobox: state_for_host, + renders: renders_for_host, + }) + }) + .unwrap(); + let state = state_cell.borrow().clone().expect("state captured"); + (state, renders, window) + }); + let mut visual = gpui::VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + state.update(cx, |state, cx| state.focus(window, cx)); + window.draw(cx).clear(cx); + }); + + let started = Instant::now(); + for letter in "abcdefghijklmnopqrstuvwxyz".chars() { + visual.simulate_keystrokes(&letter.to_string()); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + } + let elapsed = started.elapsed(); + + let total_renders = *renders.borrow(); + println!( + "26 real keystrokes: {elapsed:?}, host renders: {total_renders} ({} per keystroke)", + total_renders as f32 / 26. + ); + assert!(elapsed.as_millis() < 2000, "typing took {elapsed:?}"); +} diff --git a/docs/CHECKLIST.md b/docs/CHECKLIST.md index 7d836c5..7c9ca27 100644 --- a/docs/CHECKLIST.md +++ b/docs/CHECKLIST.md @@ -9,7 +9,8 @@ is the first downstream consumer; its screens drive the order. ## Next -- [ ] **Status dot** (M10) — sync-drift beads on rows. +- [ ] **Combobox** (M11) — type-to-filter pickers; adopt gpuikit's + `on_create` / `keep_unmatched` blur modes. - [x] API sketch - [x] Implementation (theme-baked, motion inline per AGENTS.md) - [x] Tests @@ -18,8 +19,6 @@ is the first downstream consumer; its screens drive the order. ## Queue — to follow -- [ ] **Combobox** (M11) — type-to-filter pickers; adopt gpuikit's - `on_create` / `keep_unmatched` blur modes. - [ ] **Command palette** (M12) — filtered actions; the matcher stays caller-owned. - [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 9332b2f..d75ebf0 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -11,6 +11,7 @@ use gpui_native_core::avatar::Avatar; use gpui_native_core::badge::{Badge, BadgeVariant}; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::checkbox::Checkbox; +use gpui_native_core::combobox::{Combobox, ComboboxOption, ComboboxState}; use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex}; use gpui_native_core::context_menu::ContextMenu; use gpui_native_core::dialog::Dialog; @@ -54,6 +55,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[ ("button", "Button"), ("input", "Input"), ("field", "Field"), + ("combobox", "Combobox"), ("status-dot", "Status dot"), ("checkbox", "Checkbox"), ("radio", "Radio"), @@ -390,6 +392,12 @@ impl Gallery { PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()), cx, ); + dock.register_panel( + PanelDescriptor::new("combobox", "Combobox", |window, cx| { + cx.new(|cx| ComboboxDemo::new(window, cx)).into() + }), + cx, + ); dock.register_panel( PanelDescriptor::new("status-dot", "Status dot", |_, cx| { cx.new(|_| StatusDotDemo).into() @@ -1020,6 +1028,128 @@ impl Render for StatusDotDemo { } } +fn combo_option(label: &'static str) -> ComboboxOption { + (SharedString::from(label), SharedString::from(label)) +} + +struct ComboboxDemo { + branch: Entity>, + label: Entity>, + label_created: Option, +} + +impl ComboboxDemo { + fn new(window: &mut Window, cx: &mut Context) -> Self { + let view = cx.entity(); + let branch = cx.new(|cx| { + ComboboxState::new( + Combobox::new( + "combo-demo-branch", + "Branch", + vec![ + combo_option("main"), + combo_option("next"), + combo_option("release/1.0"), + combo_option("release/2.0"), + ], + ) + .selected(SharedString::from("main")) + .placeholder("Switch branch…") + .keep_unmatched_text(), + window, + cx, + ) + }); + let label_builder = Combobox::new( + "combo-demo-label", + "Label", + vec![ + combo_option("bug"), + combo_option("docs"), + combo_option("enhancement"), + ], + ) + .placeholder("Pick or type a label…") + .on_create(move |text: SharedString, window, cx| { + // Push the typed text as a real option and choose it: the picker + // never mutates its own list behind the caller's back, so the + // demo does it here. + view.update(cx, |demo, cx| { + demo.label_created = Some(text.clone()); + demo.label.update(cx, |label, cx| { + let mut options = label.options_snapshot(); + if !options.iter().any(|(value, _)| value == &text) { + options.push((text.clone(), text.clone())); + label.set_options(options, cx); + } + label.set_selected(Some(text), window, cx); + }); + }); + }); + let label = cx.new(|cx| ComboboxState::new(label_builder, window, cx)); + Self { + branch, + label, + label_created: None, + } + } +} + +impl Render for ComboboxDemo { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let colors = cx.theme().colors; + + let branch_value = self + .branch + .read(cx) + .selected() + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()); + let label_value = self + .label_created + .clone() + .unwrap_or_else(|| SharedString::from("none")); + + ScrollArea::new("combobox-demo") + .size_full() + .p_4() + .child(demo_section( + "keep unmatched", + colors, + v_flex() + .gap_2() + .child( + Field::new("combo-demo-branch-field", "Branch") + .description("Type a name that is not in the list to keep it.") + .child(self.branch.clone()), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!("branch: {branch_value}")), + ), + )) + .child(demo_section( + "create on close", + colors, + v_flex() + .gap_2() + .child( + Field::new("combo-demo-label-field", "Label") + .description("Typing a new label creates it when the field blurs.") + .child(self.label.clone()), + ) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!("label: {label_value}")), + ), + )) + } +} + struct CheckboxDemo { checked: bool, }