1use std::cell::Cell;
9use std::rc::Rc;
10
11use gpui::{
12 AnyElement, App, AppContext as _, Bounds, Context, Entity, EventEmitter, FocusHandle,
13 Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Pixels,
14 Render, ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, Window,
15 div, prelude::FluentBuilder, px,
16};
17use gpui_kit_assets::{Icon, icon};
18use gpui_kit_semantics::{NodeSpec, Role, Semantic};
19use gpui_kit_theme::{ActiveTheme, ControlSize, Space, TypeScale};
20
21use crate::controls::field::{FieldState, field_shell};
22use crate::controls::input::{LineEnd, LineStart, TextInput, TextInputEvent};
23use crate::controls::select::SelectOption;
24use crate::display::empty::{EmptyKind, EmptyState};
25use crate::foundation::{
26 Disableable, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
27};
28use crate::layout::measure;
29use crate::motion;
30use crate::overlay::Placement;
31use crate::overlay::popover::{self, MenuKey};
32use crate::strings::{ActiveStrings, StringKey};
33
34const MENU_MIN_WIDTH: f32 = 200.0;
37const MENU_MAX_HEIGHT: f32 = 320.0;
38
39#[derive(Debug, Clone, PartialEq, Eq)]
41pub enum ComboboxEvent {
42 QueryChanged(SharedString),
43 Selected(SharedString),
45 Custom(SharedString),
48 Opened,
49 Closed,
50}
51
52impl EventEmitter<ComboboxEvent> for Combobox {}
53
54pub struct Combobox {
59 ident: Ident,
60 query: Entity<TextInput>,
61 options: Vec<SelectOption>,
62 selected: Option<SharedString>,
63 name: SharedString,
64 placeholder: Option<SharedString>,
65 size: ControlSize,
66 disabled: bool,
67 invalid: bool,
68 open: bool,
69 active: Option<SharedString>,
72 allow_custom: bool,
73 seeded: bool,
76 scroll: ScrollHandle,
77 trigger_bounds: Rc<Cell<Bounds<Pixels>>>,
78 reveal_active: bool,
79 menu_geometry: Option<popover::MenuGeometry>,
80 _subscriptions: Vec<Subscription>,
82}
83
84impl std::fmt::Debug for Combobox {
85 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
86 formatter
87 .debug_struct("Combobox")
88 .field("ident", &self.ident)
89 .field("options", &self.options.len())
90 .field("selected", &self.selected)
91 .field("open", &self.open)
92 .field("allow_custom", &self.allow_custom)
93 .finish()
94 }
95}
96
97impl Combobox {
98 pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
99 let ident = ident.into();
100 let query = cx.new(|cx| TextInput::new(ident.child("query"), window, cx).bare(true));
101 let subscription = cx.subscribe(&query, |combobox, _query, event, cx| match event {
102 TextInputEvent::Change(text) => combobox.on_query(text.clone(), cx),
103 TextInputEvent::Submit => combobox.commit(cx),
104 TextInputEvent::Cancel => combobox.revert(cx),
105 _ => {}
106 });
107
108 Self {
109 ident,
110 query,
111 options: Vec::new(),
112 selected: None,
113 name: SharedString::default(),
114 placeholder: None,
115 size: ControlSize::Md,
116 disabled: false,
117 invalid: false,
118 open: false,
119 active: None,
120 allow_custom: false,
121 seeded: false,
122 scroll: ScrollHandle::new(),
123 trigger_bounds: Rc::default(),
124 reveal_active: false,
125 menu_geometry: None,
126 _subscriptions: vec![subscription],
127 }
128 }
129
130 pub fn options(mut self, options: impl IntoIterator<Item = SelectOption>) -> Self {
131 self.options = options.into_iter().collect();
132 self
133 }
134
135 pub fn selected(mut self, id: impl Into<SharedString>) -> Self {
136 self.selected = Some(id.into());
137 self
138 }
139
140 pub fn name(mut self, name: impl Into<SharedString>) -> Self {
142 self.name = name.into();
143 self
144 }
145
146 pub fn set_name(&mut self, name: impl Into<SharedString>, cx: &mut Context<Self>) {
147 self.name = name.into();
148 self.query
149 .update(cx, |query, cx| query.set_name(self.name.clone(), cx));
150 cx.notify();
151 }
152
153 pub fn placeholder(mut self, placeholder: impl Into<SharedString>) -> Self {
154 self.placeholder = Some(placeholder.into());
155 self
156 }
157
158 pub fn invalid(mut self, invalid: bool) -> Self {
159 self.invalid = invalid;
160 self
161 }
162
163 pub fn allow_custom(mut self, allow_custom: bool) -> Self {
169 self.allow_custom = allow_custom;
170 self
171 }
172
173 pub fn set_options(&mut self, options: Vec<SelectOption>, cx: &mut Context<Self>) {
174 let still_offered = self
175 .selected
176 .as_ref()
177 .is_some_and(|id| options.iter().any(|option| &option.id == id));
178 if !still_offered {
179 self.selected = None;
180 }
181 self.options = options;
182 self.active = None;
183 self.reveal_active = true;
184 cx.notify();
185 }
186
187 pub fn set_selected(&mut self, id: Option<SharedString>, cx: &mut Context<Self>) {
190 self.selected = id;
191 self.seeded = true;
192 if self.open {
193 self.active = self.selected.as_ref().and_then(|id| {
194 self.options
195 .iter()
196 .find(|option| &option.id == id && !option.disabled)
197 .map(|option| option.id.clone())
198 });
199 }
200 self.reveal_active = true;
201 let label = self.selected_label().unwrap_or_default();
202 self.query
203 .update(cx, |query, cx| query.set_text_quietly(label, cx));
204 cx.notify();
205 }
206
207 pub fn set_query(&mut self, text: impl Into<SharedString>, cx: &mut Context<Self>) {
208 self.seeded = true;
211 self.query.update(cx, |query, cx| query.set_value(text, cx));
212 }
213
214 pub fn selected_id(&self) -> Option<&SharedString> {
215 self.selected.as_ref()
216 }
217
218 pub fn selected_option(&self) -> Option<&SelectOption> {
219 let id = self.selected.as_ref()?;
220 self.options.iter().find(|option| &option.id == id)
221 }
222
223 fn selected_label(&self) -> Option<SharedString> {
224 self.selected_option().map(|option| option.label.clone())
225 }
226
227 pub fn is_open(&self) -> bool {
228 self.open
229 }
230
231 pub fn query_text(&self, cx: &App) -> SharedString {
232 self.query.read(cx).value().clone()
233 }
234
235 pub fn query_input(&self) -> &Entity<TextInput> {
236 &self.query
237 }
238
239 pub fn set_disabled(&mut self, disabled: bool, cx: &mut Context<Self>) {
240 self.disabled = disabled;
241 self.query
242 .update(cx, |query, cx| query.set_disabled(disabled, cx));
243 if disabled {
244 self.open = false;
245 }
246 cx.notify();
247 }
248
249 fn filter(&self, cx: &App) -> SharedString {
255 let text = self.query.read(cx).value().clone();
256 match self.selected_label() {
257 Some(label) if label == text => SharedString::default(),
258 _ => text,
259 }
260 }
261
262 fn matches(&self, cx: &App) -> Vec<usize> {
264 let labels: Vec<&str> = self
265 .options
266 .iter()
267 .map(|option| option.label.as_ref())
268 .collect();
269 popover::filter_indices(self.filter(cx).as_ref(), &labels)
270 }
271
272 fn resolved(&self, matches: &[usize]) -> Option<usize> {
275 if let Some(active) = &self.active
276 && let Some(index) = matches
277 .iter()
278 .copied()
279 .find(|index| &self.options[*index].id == active)
280 && !self.options[index].disabled
281 {
282 return Some(index);
283 }
284 matches
285 .iter()
286 .copied()
287 .find(|index| !self.options[*index].disabled)
288 }
289
290 pub fn open(&mut self, cx: &mut Context<Self>) {
291 if self.disabled || self.open {
292 return;
293 }
294 if self.filter(cx).is_empty() {
295 self.active = self.selected.as_ref().and_then(|id| {
296 self.options
297 .iter()
298 .find(|option| &option.id == id && !option.disabled)
299 .map(|option| option.id.clone())
300 });
301 }
302 self.open = true;
303 self.reveal_active = true;
304 cx.emit(ComboboxEvent::Opened);
305 cx.notify();
306 }
307
308 fn close(&mut self, cx: &mut Context<Self>) {
309 if !self.open {
310 return;
311 }
312 self.open = false;
313 self.active = None;
314 cx.emit(ComboboxEvent::Closed);
315 cx.notify();
316 }
317
318 pub fn toggle(&mut self, window: &mut Window, cx: &mut Context<Self>) {
319 if self.open {
320 self.close(cx);
321 } else {
322 self.open(cx);
323 self.query.read(cx).focus_handle(cx).focus(window, cx);
324 }
325 }
326
327 fn on_query(&mut self, text: SharedString, cx: &mut Context<Self>) {
328 self.active = None;
331 self.reveal_active = true;
332 self.open(cx);
333 cx.emit(ComboboxEvent::QueryChanged(text));
334 cx.notify();
335 }
336
337 fn revert(&mut self, cx: &mut Context<Self>) {
340 let label = self.selected_label().unwrap_or_default();
341 self.query
342 .update(cx, |query, cx| query.set_text_quietly(label, cx));
343 self.close(cx);
344 }
345
346 fn commit(&mut self, cx: &mut Context<Self>) {
347 let matches = self.matches(cx);
348 if let Some(index) = self.resolved(&matches) {
349 self.take(index, cx);
350 return;
351 }
352 let typed = self.query.read(cx).value().clone();
353 if self.allow_custom && !typed.trim().is_empty() {
354 cx.emit(ComboboxEvent::Custom(typed));
355 self.close(cx);
356 }
357 }
358
359 fn take(&mut self, index: usize, cx: &mut Context<Self>) {
362 let Some(option) = self.options.get(index) else {
363 return;
364 };
365 if option.disabled {
366 return;
367 }
368 let id = option.id.clone();
369 let label = self.selected_label().unwrap_or_default();
370 self.query
371 .update(cx, |query, cx| query.set_text_quietly(label, cx));
372 cx.emit(ComboboxEvent::Selected(id));
373 self.close(cx);
374 }
375
376 fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
377 let matches = self.matches(cx);
378 let choosable: Vec<usize> = matches
379 .iter()
380 .copied()
381 .filter(|index| !self.options[*index].disabled)
382 .collect();
383 if choosable.is_empty() {
384 return;
385 }
386 let current = self
387 .resolved(&matches)
388 .and_then(|index| choosable.iter().position(|choice| *choice == index));
389 let Some(next) = popover::step(current, choosable.len(), delta) else {
390 return;
391 };
392 self.active = Some(self.options[choosable[next]].id.clone());
393 self.reveal_active = true;
394 cx.notify();
395 }
396
397 fn edge(&mut self, from_end: bool, cx: &mut Context<Self>) {
398 let matches = self.matches(cx);
399 let index = if from_end {
400 matches
401 .iter()
402 .rev()
403 .copied()
404 .find(|index| !self.options[*index].disabled)
405 } else {
406 matches
407 .iter()
408 .copied()
409 .find(|index| !self.options[*index].disabled)
410 };
411 let next = index.map(|index| self.options[index].id.clone());
412 if next == self.active {
413 return;
414 }
415 self.active = next;
416 self.reveal_active = true;
417 cx.notify();
418 }
419
420 fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
421 if self.disabled {
422 return;
423 }
424 let raw = event.keystroke.key.as_str();
425 let key = popover::classify_key(
426 raw,
427 event.keystroke.modifiers.platform,
428 event.keystroke.modifiers.control,
429 );
430 match key {
431 MenuKey::Down => {
432 self.open(cx);
433 self.step(1, cx);
434 cx.stop_propagation();
435 }
436 MenuKey::Up => {
437 self.open(cx);
438 self.step(-1, cx);
439 cx.stop_propagation();
440 }
441 _ if self.open && raw == "home" => {
442 self.edge(false, cx);
443 cx.stop_propagation();
444 }
445 _ if self.open && raw == "end" => {
446 self.edge(true, cx);
447 cx.stop_propagation();
448 }
449 _ => {}
450 }
451 }
452
453 fn on_line_start(&mut self, _: &LineStart, _: &mut Window, cx: &mut Context<Self>) {
454 if self.open && !self.disabled {
455 self.edge(false, cx);
456 cx.stop_propagation();
457 }
458 }
459
460 fn on_line_end(&mut self, _: &LineEnd, _: &mut Window, cx: &mut Context<Self>) {
461 if self.open && !self.disabled {
462 self.edge(true, cx);
463 cx.stop_propagation();
464 }
465 }
466
467 fn resolved_placeholder(&self, cx: &App) -> SharedString {
470 self.placeholder
471 .clone()
472 .unwrap_or_else(|| cx.strings().text(StringKey::SelectPlaceholder))
473 }
474
475 fn menu(&mut self, geometry: popover::MenuGeometry, cx: &mut Context<Self>) -> AnyElement {
476 let theme = cx.theme().clone();
477 let matches = self.matches(cx);
478 let highlighted = self.resolved(&matches);
479 let highlighted_position = highlighted
480 .and_then(|highlighted| matches.iter().position(|index| *index == highlighted));
481 let menu_ident = self.ident.child("menu");
482
483 let rows = if matches.is_empty() {
484 let query = self.filter(cx);
485 vec![
486 EmptyState::new(
487 self.ident.child("empty"),
488 cx.strings()
489 .format(StringKey::ComboboxNoMatch, &[query.as_ref()]),
490 )
491 .kind(EmptyKind::Empty)
492 .detail(cx.strings().text(if self.allow_custom {
493 StringKey::ComboboxCreateHint
494 } else {
495 StringKey::ComboboxClosedHint
496 }))
497 .into_any_element(),
498 ]
499 } else {
500 matches
501 .iter()
502 .enumerate()
503 .map(|(position, index)| self.row(*index, highlighted, position, matches.len(), cx))
504 .collect()
505 };
506
507 if self.menu_geometry != Some(geometry) {
508 self.menu_geometry = Some(geometry);
509 self.reveal_active = true;
510 }
511 if self.reveal_active {
512 if let Some(position) = highlighted_position {
513 self.scroll.scroll_to_item(position);
514 }
515 self.reveal_active = false;
516 }
517
518 let viewport = div()
519 .p(px(theme.space(Space::Xs)))
520 .column()
521 .max_h(px(geometry.max_height))
522 .id(self.ident.child("menu.scroll").element_id())
523 .overflow_y_scroll()
524 .track_scroll(&self.scroll)
525 .children(rows);
526 let list = popover::card_flush(&theme)
527 .w(px(geometry.width))
528 .max_h(px(geometry.max_height))
529 .id(menu_ident.element_id())
530 .child(viewport)
531 .semantic_in(
532 cx,
533 NodeSpec::new(menu_ident.semantic_id(), Role::Menu)
534 .parent(self.ident.semantic_id()),
535 )
536 .into_any_element();
537
538 popover::menu_overlay(
539 &self.ident.child("menu.anchor"),
540 &theme,
541 geometry.placement,
542 list,
543 )
544 }
545
546 fn row(
547 &self,
548 index: usize,
549 highlighted: Option<usize>,
550 position: usize,
551 count: usize,
552 cx: &mut Context<Self>,
553 ) -> AnyElement {
554 let theme = cx.theme().clone();
555 let option = &self.options[index];
556 let selected = self.selected.as_ref() == Some(&option.id);
557 let active = highlighted == Some(index);
558 let ident = self.ident.child(option.id.as_ref());
559 let hover_group = ident.child("hover").semantic_id();
560
561 let row = popover::menu_row(&theme, selected, active)
562 .id(ident.element_id())
563 .group(hover_group.clone())
564 .when(!option.disabled, |element| {
565 element.cursor_pointer().pressable(cx)
566 })
567 .when(option.disabled, |element| {
568 element.opacity(theme.opacity.disabled)
569 })
570 .child(
571 div()
572 .column()
573 .flex_1()
574 .min_w_0()
575 .gap(px(2.0))
576 .child(popover::menu_label(
577 &theme,
578 option.label.clone(),
579 selected,
580 active,
581 hover_group,
582 ))
583 .when_some(option.description.clone(), |element, description| {
584 element.child(
585 foundation_text(&theme, TypeScale::Caption, description)
586 .text_tone(&theme, gpui_kit_theme::TextTone::Muted),
587 )
588 }),
589 )
590 .when(selected, |element| {
591 element.child(
592 div().ml_auto().child(
593 icon(Icon::Check)
594 .size(px(14.0))
595 .text_color(theme.colors.text),
596 ),
597 )
598 })
599 .when(!option.disabled, |element| {
600 element.on_mouse_down(
601 MouseButton::Left,
602 cx.listener(move |combobox, _, _, cx| combobox.take(index, cx)),
603 )
604 })
605 .semantic_in(
606 cx,
607 NodeSpec::new(ident.semantic_id(), Role::Option)
608 .parent(self.ident.child("menu").semantic_id())
609 .checked(selected)
610 .disabled(option.disabled)
611 .hovered(active)
612 .text(option.label.clone()),
613 );
614
615 motion::row_in(ident.child("in").element_id(), &theme, position, count, row)
616 .into_any_element()
617 }
618}
619
620impl Disableable for Combobox {
621 fn disabled(mut self, disabled: bool) -> Self {
622 self.disabled = disabled;
623 self
624 }
625}
626
627impl Sizable for Combobox {
628 fn control_size(mut self, size: ControlSize) -> Self {
629 self.size = size;
630 self
631 }
632}
633
634impl Focusable for Combobox {
635 fn focus_handle(&self, cx: &App) -> FocusHandle {
636 self.query.read(cx).focus_handle(cx)
637 }
638}
639
640impl Render for Combobox {
641 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
642 let theme = cx.theme().clone();
643 if !self.seeded {
644 self.seeded = true;
645 if let Some(label) = self.selected_label() {
646 self.query
647 .update(cx, |query, cx| query.set_text_quietly(label, cx));
648 }
649 }
650 if self.query.read(cx).placeholder_text().is_empty() {
651 let placeholder = self.resolved_placeholder(cx);
652 self.query
653 .update(cx, |query, cx| query.set_placeholder(placeholder, cx));
654 }
655 if self.query.read(cx).accessible_name() != &self.name {
656 let name = self.name.clone();
657 self.query.update(cx, |query, cx| query.set_name(name, cx));
658 }
659 if self.disabled != self.query.read(cx).is_disabled() {
660 let disabled = self.disabled;
661 self.query
662 .update(cx, |query, cx| query.set_disabled(disabled, cx));
663 }
664
665 let focused = self.query.read(cx).focus_handle(cx).is_focused(window);
666 let geometry = self.open.then(|| {
667 popover::menu_geometry(
668 window,
669 self.trigger_bounds.get(),
670 &theme,
671 MENU_MAX_HEIGHT,
672 MENU_MIN_WIDTH,
673 )
674 });
675 let placement = geometry.map_or(Placement::Below, |geometry| geometry.placement);
676 let menu = geometry.map(|geometry| self.menu(geometry, cx));
677 let mut spec = NodeSpec::new(self.ident.semantic_id(), Role::Combobox)
678 .disabled(self.disabled)
679 .invalid(self.invalid)
680 .expanded(self.open)
681 .text(self.name.clone())
682 .placeholder(self.resolved_placeholder(cx));
683 if !self.disabled {
684 spec = spec.focus(&self.query.read(cx).focus_handle(cx));
685 }
686 if let Some(label) = self.selected_label() {
687 spec = spec.value(label);
688 }
689
690 let shell = field_shell(
691 &theme,
692 self.size,
693 FieldState::default()
694 .focused(focused)
695 .invalid(self.invalid)
696 .disabled(self.disabled),
697 )
698 .id(self.ident.child("shell").element_id())
699 .when(!self.disabled, |element| {
700 element.on_mouse_down(
701 MouseButton::Left,
702 cx.listener(|combobox, _, window, cx| {
703 if !combobox.open {
704 combobox.toggle(window, cx);
705 }
706 }),
707 )
708 })
709 .child(div().flex_1().child(self.query.clone()))
710 .child(
711 icon(Icon::AltArrowDown)
712 .size(px(theme.control.get(self.size).icon_size * 0.9))
713 .text_color(theme.colors.text_muted),
714 );
715 let measured = Rc::clone(&self.trigger_bounds);
716 let trigger = div()
717 .w_full()
718 .on_children_prepainted(move |bounds, window, _| {
719 if let Some(trigger) = bounds.first() {
720 measure::record(&measured, *trigger, window);
721 }
722 })
723 .child(shell)
724 .into_any_element();
725
726 div()
727 .id(self.ident.element_id())
728 .column()
729 .w_full()
730 .capture_action(cx.listener(Self::on_line_start))
734 .capture_action(cx.listener(Self::on_line_end))
735 .capture_key_down(cx.listener(Self::on_key_down))
736 .child(popover::anchored_slot(placement, trigger, menu))
737 .semantic_in(cx, spec)
738 }
739}