feat: add combobox component
This commit is contained in:
@@ -35,6 +35,7 @@ full = [
|
|||||||
"spinner",
|
"spinner",
|
||||||
"field",
|
"field",
|
||||||
"status_dot",
|
"status_dot",
|
||||||
|
"combobox",
|
||||||
"tooltip",
|
"tooltip",
|
||||||
"menu",
|
"menu",
|
||||||
"select",
|
"select",
|
||||||
@@ -61,6 +62,7 @@ segmented = []
|
|||||||
spinner = []
|
spinner = []
|
||||||
field = []
|
field = []
|
||||||
status_dot = []
|
status_dot = []
|
||||||
|
combobox = ["input"]
|
||||||
tooltip = []
|
tooltip = []
|
||||||
menu = ["scroll_area"]
|
menu = ["scroll_area"]
|
||||||
select = ["menu"]
|
select = ["menu"]
|
||||||
|
|||||||
@@ -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> = (T, SharedString);
|
||||||
|
/// Reports the value whenever it changes — including when typing clears it.
|
||||||
|
pub type ComboboxHandler<T> = Rc<dyn Fn(Option<T>, &mut Window, &mut App)>;
|
||||||
|
/// Receives unmatched text when the field loses focus in create mode.
|
||||||
|
pub type ComboboxCreateHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
|
||||||
|
/// A filter over an option: the query, the value, the label.
|
||||||
|
type Filter<T> = Rc<dyn Fn(&str, &T, &SharedString) -> bool>;
|
||||||
|
|
||||||
|
/// Builder for a combobox. Pass it to [`ComboboxState::new`].
|
||||||
|
pub struct Combobox<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
id: ElementId,
|
||||||
|
name: SharedString,
|
||||||
|
options: Vec<ComboboxOption<T>>,
|
||||||
|
selected: Option<T>,
|
||||||
|
placeholder: SharedString,
|
||||||
|
on_change: Option<ComboboxHandler<T>>,
|
||||||
|
on_create: Option<ComboboxCreateHandler>,
|
||||||
|
unmatched: UnmatchedText,
|
||||||
|
size: Size,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Combobox<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
pub fn new<I, N>(id: I, name: N, options: Vec<ComboboxOption<T>>) -> Self
|
||||||
|
where
|
||||||
|
I: Into<ElementId>,
|
||||||
|
N: Into<SharedString>,
|
||||||
|
{
|
||||||
|
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<Option<T>>) -> Self {
|
||||||
|
self.selected = selected.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sets the text shown while the field is empty.
|
||||||
|
pub fn placeholder<L>(mut self, placeholder: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.placeholder = placeholder.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Reports the value whenever it changes.
|
||||||
|
pub fn on_change<F>(mut self, handler: F) -> Self
|
||||||
|
where
|
||||||
|
F: Fn(Option<T>, &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<F>(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<L>(mut self, label: L) -> Self
|
||||||
|
where
|
||||||
|
L: Into<SharedString>,
|
||||||
|
{
|
||||||
|
self.name = label.into();
|
||||||
|
self
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Emitted when the combobox's value changes.
|
||||||
|
pub struct ComboboxChanged;
|
||||||
|
|
||||||
|
/// The stateful combobox.
|
||||||
|
pub struct ComboboxState<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
id: ElementId,
|
||||||
|
name: SharedString,
|
||||||
|
placeholder: SharedString,
|
||||||
|
options: Vec<ComboboxOption<T>>,
|
||||||
|
/// The chosen value. `None` while the text names nothing.
|
||||||
|
selected: Option<T>,
|
||||||
|
query: Entity<InputState>,
|
||||||
|
/// Indices into `options` that survived the filter, in popup row order.
|
||||||
|
visible: Vec<usize>,
|
||||||
|
/// The highlighted popup *row*, when the popup is open.
|
||||||
|
highlight: Option<usize>,
|
||||||
|
on_change: Option<ComboboxHandler<T>>,
|
||||||
|
on_create: Option<ComboboxCreateHandler>,
|
||||||
|
filter: Filter<T>,
|
||||||
|
unmatched: UnmatchedText,
|
||||||
|
size: Size,
|
||||||
|
/// Keeps the query-event subscription tied to the state's lifetime.
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> EventEmitter<ComboboxChanged> for ComboboxState<T> where T: Clone + PartialEq + 'static {}
|
||||||
|
|
||||||
|
impl<T> Sizable for ComboboxState<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
fn apply_size(&mut self, size: Size) {
|
||||||
|
self.size = size;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> ComboboxState<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
/// Builds the state, its query field, and the subscription tying them.
|
||||||
|
pub fn new(combobox: Combobox<T>, window: &mut Window, cx: &mut Context<Self>) -> 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<T>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.set_value(value, window, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Replaces the choice list and re-runs the current filter.
|
||||||
|
pub fn set_options(&mut self, options: Vec<ComboboxOption<T>>, cx: &mut Context<Self>) {
|
||||||
|
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<ComboboxOption<T>> {
|
||||||
|
self.options.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The label of the chosen option, if there is one.
|
||||||
|
fn selected_label(&self) -> Option<SharedString> {
|
||||||
|
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<SharedString> {
|
||||||
|
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<Self>) -> 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<T>, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
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<Self>) {
|
||||||
|
if self.highlight.is_none() {
|
||||||
|
cx.propagate();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.highlight = None;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<T> Render for ComboboxState<T>
|
||||||
|
where
|
||||||
|
T: Clone + PartialEq + 'static,
|
||||||
|
{
|
||||||
|
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> 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<RefCell<Option<Bounds<Pixels>>>> = 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<gpui::AnyElement> = 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<RefCell<Option<Bounds<Pixels>>>> = 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,6 +75,9 @@ pub mod field;
|
|||||||
#[cfg(feature = "status_dot")]
|
#[cfg(feature = "status_dot")]
|
||||||
pub mod status_dot;
|
pub mod status_dot;
|
||||||
|
|
||||||
|
#[cfg(feature = "combobox")]
|
||||||
|
pub mod combobox;
|
||||||
|
|
||||||
#[cfg(feature = "toast")]
|
#[cfg(feature = "toast")]
|
||||||
pub mod toast;
|
pub mod toast;
|
||||||
|
|
||||||
@@ -86,4 +89,8 @@ pub fn init(cx: &mut App) {
|
|||||||
gpui_native_theme::Theme::init(cx);
|
gpui_native_theme::Theme::init(cx);
|
||||||
#[cfg(feature = "input")]
|
#[cfg(feature = "input")]
|
||||||
input::bind_input_keys(cx, None);
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
(SharedString::from(label), SharedString::from(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Host {
|
||||||
|
combobox: Entity<ComboboxState<SharedString>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Host {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().size_full().p_4().child(self.combobox.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type Recorder = Rc<RefCell<Vec<Option<SharedString>>>>;
|
||||||
|
type CreatedRecorder = Rc<RefCell<Vec<SharedString>>>;
|
||||||
|
|
||||||
|
struct ComboHost {
|
||||||
|
cx: TestAppContext,
|
||||||
|
state: Entity<ComboboxState<SharedString>>,
|
||||||
|
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<RefCell<Vec<SharedString>>> = Rc::new(RefCell::new(Vec::new()));
|
||||||
|
let state_cell: Rc<RefCell<Option<Entity<ComboboxState<SharedString>>>>> =
|
||||||
|
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());
|
||||||
|
}
|
||||||
@@ -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> {
|
||||||
|
(SharedString::from(label), SharedString::from(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Host {
|
||||||
|
combobox: Entity<ComboboxState<SharedString>>,
|
||||||
|
renders: Rc<RefCell<usize>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for Host {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> 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<RefCell<Option<Entity<ComboboxState<SharedString>>>>> =
|
||||||
|
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:?}");
|
||||||
|
}
|
||||||
+2
-3
@@ -9,7 +9,8 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Next
|
## 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] API sketch
|
||||||
- [x] Implementation (theme-baked, motion inline per AGENTS.md)
|
- [x] Implementation (theme-baked, motion inline per AGENTS.md)
|
||||||
- [x] Tests
|
- [x] Tests
|
||||||
@@ -18,8 +19,6 @@ is the first downstream consumer; its screens drive the order.
|
|||||||
|
|
||||||
## Queue — to follow
|
## 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
|
- [ ] **Command palette** (M12) — filtered actions; the matcher stays
|
||||||
caller-owned.
|
caller-owned.
|
||||||
- [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit
|
- [ ] **VirtualList** (M13, `data`) — thin `uniform_list` binding for commit
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ use gpui_native_core::avatar::Avatar;
|
|||||||
use gpui_native_core::badge::{Badge, BadgeVariant};
|
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::combobox::{Combobox, ComboboxOption, ComboboxState};
|
||||||
use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex};
|
use gpui_native_core::common::{Sizable as _, Size, h_flex, v_flex};
|
||||||
use gpui_native_core::context_menu::ContextMenu;
|
use gpui_native_core::context_menu::ContextMenu;
|
||||||
use gpui_native_core::dialog::Dialog;
|
use gpui_native_core::dialog::Dialog;
|
||||||
@@ -54,6 +55,7 @@ const CATALOG: &[(&str, &[(&str, &str)])] = &[
|
|||||||
("button", "Button"),
|
("button", "Button"),
|
||||||
("input", "Input"),
|
("input", "Input"),
|
||||||
("field", "Field"),
|
("field", "Field"),
|
||||||
|
("combobox", "Combobox"),
|
||||||
("status-dot", "Status dot"),
|
("status-dot", "Status dot"),
|
||||||
("checkbox", "Checkbox"),
|
("checkbox", "Checkbox"),
|
||||||
("radio", "Radio"),
|
("radio", "Radio"),
|
||||||
@@ -390,6 +392,12 @@ impl Gallery {
|
|||||||
PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()),
|
PanelDescriptor::new("field", "Field", |_, cx| cx.new(FieldDemo::new).into()),
|
||||||
cx,
|
cx,
|
||||||
);
|
);
|
||||||
|
dock.register_panel(
|
||||||
|
PanelDescriptor::new("combobox", "Combobox", |window, cx| {
|
||||||
|
cx.new(|cx| ComboboxDemo::new(window, cx)).into()
|
||||||
|
}),
|
||||||
|
cx,
|
||||||
|
);
|
||||||
dock.register_panel(
|
dock.register_panel(
|
||||||
PanelDescriptor::new("status-dot", "Status dot", |_, cx| {
|
PanelDescriptor::new("status-dot", "Status dot", |_, cx| {
|
||||||
cx.new(|_| StatusDotDemo).into()
|
cx.new(|_| StatusDotDemo).into()
|
||||||
@@ -1020,6 +1028,128 @@ impl Render for StatusDotDemo {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn combo_option(label: &'static str) -> ComboboxOption<SharedString> {
|
||||||
|
(SharedString::from(label), SharedString::from(label))
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ComboboxDemo {
|
||||||
|
branch: Entity<ComboboxState<SharedString>>,
|
||||||
|
label: Entity<ComboboxState<SharedString>>,
|
||||||
|
label_created: Option<SharedString>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ComboboxDemo {
|
||||||
|
fn new(window: &mut Window, cx: &mut Context<Self>) -> 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<Self>) -> 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 {
|
struct CheckboxDemo {
|
||||||
checked: bool,
|
checked: bool,
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user