//! 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 _, StyleRefinement, Styled, Subscription, Task, Window, actions, anchored, canvas, deferred, div, point, px, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, BORDER_WIDTH, Size}; use crate::common::{Sizable, StyledExt as _, 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; pub type ComboboxHandler = Rc, &mut Window, &mut App)>; /// Receives unmatched text when the field loses focus in create mode. pub type ComboboxCreateHandler = Rc; /// Decides which options survive a query: the query, the value, the label. pub type ComboboxFilter = 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, filter: Option>, unmatched: UnmatchedText, size: Size, style: StyleRefinement, } 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, filter: None, unmatched: UnmatchedText::default(), size: Size::default(), style: StyleRefinement::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 } /// Sets the matcher that decides which options survive a query. pub fn filter(mut self, filter: F) -> Self where F: Fn(&str, &T, &SharedString) -> bool + 'static, { self.filter = Some(Rc::new(filter)); 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 } } /// Style refinements set the combobox's width. impl Styled for Combobox where T: Clone + PartialEq + 'static, { fn style(&mut self) -> &mut StyleRefinement { &mut self.style } } /// 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: ComboboxFilter, unmatched: UnmatchedText, size: Size, /// Width refinements from the builder, applied to the wrapper each render. style: StyleRefinement, /// Handler reports in flight; held so they die with the state. tasks: Vec>, /// 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::Focused => state.query_focused(window, cx), 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: combobox.filter.unwrap_or_else(|| { Rc::new(|query, _value, label| label.to_lowercase().contains(&query.to_lowercase())) }), unmatched: combobox.unmatched, size: combobox.size, style: combobox.style, tasks: Vec::new(), _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() { let value = value.clone(); let task = window.spawn(cx, async move |cx| { cx.update(|window, cx| on_change(value, window, cx)).ok(); }); self.tasks.push(task); } 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(); } /// Gaining focus. fn query_focused(&mut self, _window: &mut Window, cx: &mut Context) { if self.selected.is_some() { return; } self.refilter(""); self.highlight = (!self.visible.is_empty()).then_some(0usize); 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() { let text = SharedString::from(text); let task = window.spawn(cx, async move |cx| { cx.update(|window, cx| on_create(text, window, cx)).ok(); }); self.tasks.push(task); } } } 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, panel_border, spacing_unit, shadows) = { let theme = cx.theme(); let metrics = theme.sizes.resolve(self.size); let panel_border = if theme.borders.overlays { theme.colors.border } else { theme.colors.surface }; ( metrics, theme.colors, theme.radius, panel_border, theme.spacing.unit, theme.elevations.popover.shadows(), ) }; 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(window.rem_size() * 12.0) .min_w(window.rem_size() * 6.0) .refine_style(&self.style) .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()) .placeholder_when_focused(true) .with_size(self.size), ) .child( h_flex() .absolute() .right(metrics.dense_padding_x) .top((metrics.input_height + BORDER_WIDTH * 2.0 - 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 popup_width = wrapper_bounds .borrow() .map(|bounds| bounds.size.width) .unwrap_or_else(|| window.rem_size() * 12.0); let panel = v_flex() .id((id.clone(), "panel")) .debug_selector({ let name = format!("{id}-panel"); move || name }) .w(popup_width) .p_1() .bg(colors.surface) .text_color(colors.foreground) .rounded(radius.overlay) .border_1() .border_color(panel_border) .shadow(shadows) .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( anchored().offset(point(px(0.), spacing_unit)).child(panel), )); } wrapper } }