1use gpui_base::TestSupportExt as _;
2use std::rc::Rc;
3
4use gpui::{
5 AbsoluteLength, AnyElement, App, AppContext as _, AvailableSpace, Context, Entity, FocusHandle,
6 Focusable, FontFallbacks, FontFeatures, FontStyle, FontWeight, InteractiveElement, IntoElement,
7 KeyBinding, Keystroke, ListSizingBehavior, ParentElement, Pixels, Render, Role, ScrollStrategy,
8 SharedString, Size, StatefulInteractiveElement as _, StyleRefinement, Styled, Subscription,
9 TextOverflow, WhiteSpace, Window, div, prelude::FluentBuilder as _, px, size,
10};
11use rust_i18n::t;
12
13use crate::{
14 ActiveTheme as _, ElementExt as _, Icon, IconName, IndexPath, StyledExt as _,
15 VirtualListScrollHandle,
16 actions::{Cancel, Confirm, SelectDown, SelectUp},
17 command::{
18 command::CommandOptions,
19 item::{CommandEntry, CommandItem},
20 },
21 h_flex,
22 input::{Input, InputEvent, InputState},
23 kbd::Kbd,
24 scroll::Scrollbar,
25 v_flex, v_virtual_list,
26};
27
28pub(crate) const CONTEXT: &str = "Command";
29
30const SEPARATOR_ROW_HEIGHT: f32 = 9.;
33
34pub(crate) type OnQuery = dyn Fn(&str, &mut Window, &mut App);
35pub(crate) type OnIndex = dyn Fn(IndexPath, &mut Window, &mut App);
36pub(crate) type OnCancel = dyn Fn(&mut Window, &mut App);
37
38pub(crate) struct CommandModel {
39 pub(crate) entries: Vec<CommandEntry>,
40 pub(crate) searchable: bool,
41 pub(crate) filterable: bool,
42 pub(crate) on_query: Option<Rc<OnQuery>>,
43 pub(crate) on_select: Option<Rc<OnIndex>>,
44 pub(crate) on_confirm: Option<Rc<OnIndex>>,
45 pub(crate) on_cancel: Option<Rc<OnCancel>>,
46}
47
48impl Default for CommandModel {
49 fn default() -> Self {
50 Self {
51 entries: Vec::new(),
52 searchable: true,
53 filterable: true,
54 on_query: None,
55 on_select: None,
56 on_confirm: None,
57 on_cancel: None,
58 }
59 }
60}
61
62pub(crate) fn init(cx: &mut App) {
63 let context: Option<&str> = Some(CONTEXT);
64 cx.bind_keys([
65 KeyBinding::new("escape", Cancel, context),
66 KeyBinding::new("enter", Confirm { secondary: false }, context),
67 KeyBinding::new("up", SelectUp, context),
68 KeyBinding::new("down", SelectDown, context),
69 ]);
70}
71
72#[derive(Clone, PartialEq)]
77enum CommandRow {
78 Heading(SharedString),
79 Item(usize),
81 Separator,
82}
83
84#[derive(Clone, PartialEq)]
85struct TextShapeKey {
86 font_family: SharedString,
87 font_features: FontFeatures,
88 font_fallbacks: Option<FontFallbacks>,
89 font_size: AbsoluteLength,
90 font_weight: FontWeight,
91 font_style: FontStyle,
92 white_space: WhiteSpace,
93 text_overflow: Option<TextOverflow>,
94 line_clamp: Option<usize>,
95}
96
97#[derive(Clone, PartialEq)]
98struct ListMeasurementKey {
99 content_width: Pixels,
100 rem_size: Pixels,
101 line_height: Pixels,
102 text_shape: TextShapeKey,
103}
104
105#[derive(Clone)]
107struct MatchedItem {
108 entry_ix: usize,
109 item_ix: usize,
110 index_path: IndexPath,
111 row_ix: usize,
112 disabled: bool,
113}
114
115pub struct CommandState {
118 focus_handle: FocusHandle,
119 query_input: Entity<InputState>,
120 scroll_handle: VirtualListScrollHandle,
121 model: CommandModel,
122 rows: Vec<CommandRow>,
123 row_sizes: Rc<Vec<Size<Pixels>>>,
124 list_measurement_key: Option<ListMeasurementKey>,
125 needs_measure: bool,
126 measured_bindings: Vec<Option<Keystroke>>,
130 matched: Vec<MatchedItem>,
131 selected_index: Option<usize>,
132 preserve_no_selection: bool,
133 loading: bool,
134 pending_scroll: Option<usize>,
135 applied_placeholder: SharedString,
139 applied_query: SharedString,
140 pub(crate) options: CommandOptions,
141 _subscriptions: Vec<Subscription>,
142}
143
144impl CommandState {
145 pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
147 let query_input = cx.new(|cx| InputState::new(window, cx));
148
149 let _subscriptions =
150 vec![cx.subscribe_in(&query_input, window, Self::on_query_input_event)];
151
152 Self {
153 focus_handle: cx.focus_handle(),
154 query_input,
155 scroll_handle: VirtualListScrollHandle::new(),
156 model: CommandModel::default(),
157 rows: Vec::new(),
158 row_sizes: Rc::new(Vec::new()),
159 list_measurement_key: None,
160 needs_measure: true,
161 measured_bindings: Vec::new(),
162 matched: Vec::new(),
163 selected_index: None,
164 preserve_no_selection: false,
165 loading: false,
166 pending_scroll: None,
167 applied_placeholder: SharedString::default(),
168 applied_query: SharedString::default(),
169 options: CommandOptions::default(),
170 _subscriptions,
171 }
172 }
173
174 pub(crate) fn set_options(&mut self, options: CommandOptions) {
176 if self.options.style.text != options.style.text {
177 self.needs_measure = true;
178 }
179 self.options = options;
180 }
181
182 pub(crate) fn install_model(&mut self, model: CommandModel, cx: &mut Context<Self>) {
183 let selected_index_path = self.selected_index();
184 let same_layout = self.model.searchable == model.searchable
188 && self.model.filterable == model.filterable
189 && self.model.entries.len() == model.entries.len()
190 && self
191 .model
192 .entries
193 .iter()
194 .zip(&model.entries)
195 .all(|(entry, other)| entry.same_layout(other));
196 self.model = model;
197 if !same_layout {
198 self.update_matches(cx);
199 }
200
201 let preserved_selection = selected_index_path.and_then(|selected_index_path| {
202 self.matched
203 .iter()
204 .enumerate()
205 .find_map(|(matched_ix, matched)| {
206 (!matched.disabled && matched.index_path == selected_index_path)
207 .then_some(matched_ix)
208 })
209 });
210
211 if let Some(matched_ix) = preserved_selection {
212 self.selected_index = Some(matched_ix);
216 self.preserve_no_selection = false;
217 } else if self.preserve_no_selection {
218 self.selected_index = None;
219 self.pending_scroll = None;
220 } else {
221 self.reset_selection();
222 }
223 }
224
225 pub fn query(&self, cx: &App) -> SharedString {
227 self.query_input.read(cx).value()
228 }
229
230 pub fn set_query(
235 &mut self,
236 query: impl Into<SharedString>,
237 window: &mut Window,
238 cx: &mut Context<Self>,
239 ) {
240 let query = query.into();
241 if self.query(cx) == query {
242 return;
243 }
244
245 self.query_input
246 .update(cx, |input, cx| input.set_value(query, window, cx));
247 self.on_query_changed(window, cx);
248 }
249
250 pub fn selected_index(&self) -> Option<IndexPath> {
257 self.selected_index
258 .and_then(|selected_index| self.matched.get(selected_index))
259 .filter(|matched| !matched.disabled)
260 .map(|matched| matched.index_path)
261 }
262
263 pub fn set_selected_index(
269 &mut self,
270 index: Option<IndexPath>,
271 window: &mut Window,
272 cx: &mut Context<Self>,
273 ) {
274 let matched_ix = index.and_then(|index| {
275 self.matched
276 .iter()
277 .position(|matched| matched.index_path == index && !matched.disabled)
278 });
279
280 let preserve_no_selection = matched_ix.is_none();
281 if self.selected_index == matched_ix {
282 self.preserve_no_selection = preserve_no_selection;
283 return;
284 }
285
286 let previous_index = self.selected_index();
287 self.selected_index = matched_ix;
288 self.preserve_no_selection = preserve_no_selection;
289 self.pending_scroll = matched_ix
290 .and_then(|matched_ix| self.matched.get(matched_ix))
291 .map(|matched| matched.row_ix);
292
293 if let Some((on_select, index)) = self.on_select_if_changed(previous_index) {
294 window.defer(cx, move |window, cx| on_select(index, window, cx));
295 }
296
297 cx.notify();
298 }
299
300 pub fn matched_count(&self) -> usize {
302 self.matched.len()
303 }
304
305 pub fn focus(&self, window: &mut Window, cx: &mut App) {
307 if self.model.searchable {
308 self.query_input.focus_handle(cx).focus(window, cx);
309 } else {
310 self.focus_handle.focus(window, cx);
311 }
312 }
313
314 pub fn set_loading(&mut self, loading: bool, window: &mut Window, cx: &mut Context<Self>) {
319 self.loading = loading;
320 self.query_input
321 .update(cx, |input, cx| input.set_loading(loading, window, cx));
322 cx.notify();
323 }
324
325 pub fn is_loading(&self) -> bool {
327 self.loading
328 }
329
330 fn item_matches(&self, item: &CommandItem, query: &str) -> bool {
333 if !self.model.searchable || !self.model.filterable || query.is_empty() {
334 true
335 } else {
336 item.matches(query)
337 }
338 }
339
340 fn item_at(&self, matched_ix: usize) -> Option<&CommandItem> {
341 let matched = self.matched.get(matched_ix)?;
342
343 match self.model.entries.get(matched.entry_ix)? {
344 CommandEntry::Item(item) => Some(item),
345 CommandEntry::Group(group) => group.items.get(matched.item_ix),
346 CommandEntry::Separator => None,
347 }
348 }
349
350 fn update_matches(&mut self, cx: &App) {
353 let query = self.query(cx);
354 let query = query.trim();
355
356 let mut rows: Vec<CommandRow> = Vec::new();
357 let mut matched: Vec<MatchedItem> = Vec::new();
358 let has_ungrouped_items = self
359 .model
360 .entries
361 .iter()
362 .any(|entry| matches!(entry, CommandEntry::Item(_)));
363 let mut ungrouped_item_ix = 0;
364 let mut group_ix = 0;
365 let mut pending_separator = false;
368
369 for (entry_ix, entry) in self.model.entries.iter().enumerate() {
370 match entry {
371 CommandEntry::Separator => pending_separator = !rows.is_empty(),
372 CommandEntry::Item(item) => {
373 let item_ix = ungrouped_item_ix;
374 ungrouped_item_ix += 1;
375 if !self.item_matches(item, query) {
376 continue;
377 }
378
379 if pending_separator {
380 rows.push(CommandRow::Separator);
381 pending_separator = false;
382 }
383
384 let index_path = IndexPath::new(item_ix).section(0);
385 matched.push(MatchedItem {
386 entry_ix,
387 item_ix: 0,
388 index_path,
389 row_ix: rows.len(),
390 disabled: item.is_disabled(),
391 });
392 rows.push(CommandRow::Item(matched.len() - 1));
393 }
394 CommandEntry::Group(group) => {
395 let section_ix = group_ix + usize::from(has_ungrouped_items);
396 group_ix += 1;
397 let visible = group
398 .items
399 .iter()
400 .enumerate()
401 .filter(|(_, item)| self.item_matches(item, query))
402 .map(|(item_ix, item)| (item_ix, item.is_disabled()))
403 .collect::<Vec<_>>();
404
405 if visible.is_empty() {
406 continue;
407 }
408
409 if pending_separator {
410 rows.push(CommandRow::Separator);
411 pending_separator = false;
412 }
413
414 if let Some(heading) = group.heading() {
415 rows.push(CommandRow::Heading(heading.clone()));
416 }
417
418 for (item_ix, disabled) in visible {
419 let index_path = IndexPath::new(item_ix).section(section_ix);
420 matched.push(MatchedItem {
421 entry_ix,
422 item_ix,
423 index_path,
424 row_ix: rows.len(),
425 disabled,
426 });
427 rows.push(CommandRow::Item(matched.len() - 1));
428 }
429 }
430 }
431 }
432
433 self.rows = rows;
434 self.matched = matched;
435 self.needs_measure = true;
436 self.selected_index = self.selected_index.and_then(|selected_index| {
437 (selected_index < self.matched.len()).then_some(selected_index)
438 });
439 }
440
441 fn reset_selection(&mut self) {
443 self.selected_index = self.matched.iter().position(|matched| !matched.disabled);
444 self.preserve_no_selection = false;
445 self.pending_scroll = self
446 .selected_index
447 .and_then(|selected_index| self.matched.get(selected_index))
448 .map(|matched| matched.row_ix)
449 .or(Some(0));
450 }
451
452 fn on_query_input_event(
453 &mut self,
454 _: &Entity<InputState>,
455 event: &InputEvent,
456 window: &mut Window,
457 cx: &mut Context<Self>,
458 ) {
459 if !matches!(event, InputEvent::Change) {
460 return;
461 }
462
463 self.on_query_changed(window, cx);
464 }
465
466 fn on_query_changed(&mut self, window: &mut Window, cx: &mut Context<Self>) {
468 let query = self.query(cx);
469 if query == self.applied_query {
470 return;
471 }
472
473 let previous_selection = self.selected_index();
474 self.applied_query = query.clone();
475 self.update_matches(cx);
476 self.reset_selection();
477 let selection_callback = self.on_select_if_changed(previous_selection);
478 let query_callback = self
479 .model
480 .searchable
481 .then(|| self.model.on_query.clone())
482 .flatten();
483
484 if selection_callback.is_some() || query_callback.is_some() {
485 window.defer(cx, move |window, cx| {
486 if let Some((on_select, index)) = selection_callback {
487 on_select(index, window, cx);
488 }
489 if let Some(on_query) = query_callback {
490 on_query(query.as_ref(), window, cx);
491 }
492 });
493 }
494
495 cx.notify();
496 }
497
498 fn set_list_measurement_key(
499 &mut self,
500 measurement_key: ListMeasurementKey,
501 window: &mut Window,
502 cx: &mut Context<Self>,
503 ) {
504 if self.list_measurement_key.as_ref() == Some(&measurement_key) {
505 return;
506 }
507
508 self.list_measurement_key = Some(measurement_key);
509 self.measure_on_next_frame(window, cx);
510 }
511
512 fn measure_on_next_frame(&mut self, window: &mut Window, cx: &mut Context<Self>) {
515 self.needs_measure = true;
516 let entity_id = cx.entity_id();
517 window.on_next_frame(move |_, cx| cx.notify(entity_id));
518 }
519
520 fn on_select_if_changed(
523 &self,
524 previous_index: Option<IndexPath>,
525 ) -> Option<(Rc<OnIndex>, IndexPath)> {
526 let index = self.selected_index();
527 if index == previous_index {
528 return None;
529 }
530
531 self.model.on_select.clone().zip(index)
532 }
533
534 fn select(&mut self, matched_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
538 if self.selected_index == Some(matched_ix) {
539 return;
540 }
541
542 let previous_index = self.selected_index();
543 self.selected_index = Some(matched_ix);
544 self.preserve_no_selection = false;
545
546 if let Some((on_select, index)) = self.on_select_if_changed(previous_index) {
547 window.defer(cx, move |window, cx| on_select(index, window, cx));
548 }
549
550 cx.notify();
551 }
552
553 fn select_by(&mut self, step: isize, window: &mut Window, cx: &mut Context<Self>) {
556 let len = self.matched.len();
557 if len == 0 {
558 return;
559 }
560
561 let mut next = self
562 .selected_index
563 .unwrap_or_else(|| if step >= 0 { len.saturating_sub(1) } else { 0 });
564 let mut enabled = None;
565 for _ in 0..len {
566 next = (next as isize + step).rem_euclid(len as isize) as usize;
567 if !self.matched[next].disabled {
568 enabled = Some(next);
569 break;
570 }
571 }
572
573 if let Some(next) = enabled
574 && self.selected_index != Some(next)
575 {
576 self.pending_scroll = self.matched.get(next).map(|matched| matched.row_ix);
577 self.select(next, window, cx);
578 }
579 }
580
581 fn on_action_select_up(&mut self, _: &SelectUp, window: &mut Window, cx: &mut Context<Self>) {
582 self.select_by(-1, window, cx);
583 }
584
585 fn on_action_select_down(
586 &mut self,
587 _: &SelectDown,
588 window: &mut Window,
589 cx: &mut Context<Self>,
590 ) {
591 self.select_by(1, window, cx);
592 }
593
594 fn on_action_confirm(&mut self, _: &Confirm, window: &mut Window, cx: &mut Context<Self>) {
595 if let Some(selected_index) = self.selected_index {
596 self.confirm(selected_index, window, cx);
597 }
598 }
599
600 fn on_action_cancel(&mut self, _: &Cancel, window: &mut Window, cx: &mut Context<Self>) {
603 if self.model.searchable && !self.query(cx).is_empty() {
604 self.set_query("", window, cx);
605 return;
606 }
607
608 if let Some(on_cancel) = self.model.on_cancel.clone() {
611 on_cancel(window, cx);
612 }
613
614 cx.propagate();
615 }
616
617 fn confirm(&mut self, matched_ix: usize, window: &mut Window, cx: &mut Context<Self>) {
618 let Some(item) = self.item_at(matched_ix) else {
619 return;
620 };
621 if item.is_disabled() {
622 return;
623 }
624
625 let index_path = self.matched[matched_ix].index_path;
626 let action = item.action.as_ref().map(|action| action.boxed_clone());
627 let on_confirm = self.model.on_confirm.clone();
628
629 if let Some(action) = action {
630 window.dispatch_action(action, cx);
631 }
632 if let Some(on_confirm) = on_confirm {
633 window.defer(cx, move |window, cx| {
634 on_confirm(index_path, window, cx);
635 });
636 }
637 }
638
639 fn measure_rows(&mut self, window: &mut Window, cx: &mut Context<Self>) {
644 let available = size(
645 self.list_measurement_key
646 .as_ref()
647 .map_or(AvailableSpace::MinContent, |key| {
648 AvailableSpace::Definite(key.content_width)
649 }),
650 AvailableSpace::MinContent,
651 );
652 let mut text_style = StyleRefinement::default();
653 text_style.text = self.options.style.text.clone();
654 let mut bindings = vec![None; self.matched.len()];
655
656 let row_sizes = self
657 .rows
658 .iter()
659 .enumerate()
660 .map(|(row_ix, row)| {
661 let row = match row {
662 CommandRow::Separator => return size(px(0.), px(SEPARATOR_ROW_HEIGHT)),
663 CommandRow::Heading(_) => self.render_row(row_ix, window, cx),
664 CommandRow::Item(matched_ix) => {
665 let binding = self.item_binding(*matched_ix, window, cx);
666 bindings[*matched_ix] = binding.as_ref().map(|kbd| kbd.keystroke().clone());
667 self.render_item_with_binding(*matched_ix, binding, window, cx)
668 }
669 };
670 let row_size = div()
671 .refine_style(&text_style)
672 .child(row)
673 .into_any_element()
674 .layout_as_root(available, window, cx);
675 size(px(0.), row_size.height)
676 })
677 .collect::<Vec<_>>();
678
679 self.row_sizes = Rc::new(row_sizes);
680 self.measured_bindings = bindings;
681 }
682
683 fn sync_placeholder(&mut self, window: &mut Window, cx: &mut Context<Self>) {
686 let placeholder = self
687 .options
688 .placeholder
689 .as_ref()
690 .cloned()
691 .unwrap_or_else(|| t!("Command.placeholder").to_string().into());
692
693 if self.applied_placeholder == placeholder {
694 return;
695 }
696
697 self.applied_placeholder = placeholder.clone();
698 self.query_input.update(cx, |input, cx| {
699 input.set_placeholder(placeholder, window, cx)
700 });
701 }
702
703 fn item_radius(&self, window: &Window, cx: &App) -> Pixels {
708 let theme = cx.theme();
709 let rem_size = window.rem_size();
710 let own_radius = self.options.style.corner_radii.top_left;
711 let (outer, border) = match (self.options.bordered, own_radius) {
712 (true, radius) => (
713 radius.map_or(theme.radius_lg, |r| r.to_pixels(rem_size)),
714 px(1.),
715 ),
716 (false, Some(radius)) => (radius.to_pixels(rem_size), px(0.)),
717 (false, None) => return theme.radius,
718 };
719 (outer - rem_size * 0.25 - border).max(px(0.))
720 }
721
722 fn item_row(&self, selected: bool, window: &Window, cx: &App) -> gpui::Div {
725 div()
726 .flex()
727 .flex_row()
728 .items_center()
729 .w_full()
730 .gap_2()
731 .px_2()
732 .py_1p5()
733 .text_sm()
734 .rounded(self.item_radius(window, cx))
735 .when(selected, |this| {
736 this.bg(cx.theme().accent)
737 .text_color(cx.theme().accent_foreground)
738 })
739 }
740
741 fn heading_row(&self, heading: SharedString, cx: &App) -> gpui::Div {
742 div()
743 .w_full()
744 .px_2()
745 .py_1p5()
746 .text_xs()
747 .font_medium()
748 .text_color(cx.theme().muted_foreground)
749 .child(heading)
750 }
751
752 fn render_row(&self, row_ix: usize, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
753 match self.rows.get(row_ix) {
754 None => div().into_any_element(),
755 Some(CommandRow::Separator) => div()
756 .w_full()
757 .py(px(4.))
758 .child(div().h(px(1.)).w_full().bg(cx.theme().border))
759 .into_any_element(),
760 Some(CommandRow::Heading(heading)) => {
761 self.heading_row(heading.clone(), cx).into_any_element()
762 }
763 Some(CommandRow::Item(matched_ix)) => self.render_item(*matched_ix, window, cx),
764 }
765 }
766
767 fn render_visible_row(
770 &mut self,
771 row_ix: usize,
772 window: &mut Window,
773 cx: &mut Context<Self>,
774 ) -> AnyElement {
775 let Some(&CommandRow::Item(matched_ix)) = self.rows.get(row_ix) else {
776 return self.render_row(row_ix, window, cx);
777 };
778
779 let binding = self.item_binding(matched_ix, window, cx);
780 let measured = self
781 .measured_bindings
782 .get(matched_ix)
783 .and_then(Option::as_ref);
784 if !self.needs_measure && measured != binding.as_ref().map(Kbd::keystroke) {
785 self.measure_on_next_frame(window, cx);
786 }
787
788 self.render_item_with_binding(matched_ix, binding, window, cx)
789 }
790
791 fn item_binding(&self, matched_ix: usize, window: &Window, cx: &App) -> Option<Kbd> {
794 let item = self.item_at(matched_ix)?;
795 if item.content.is_some() {
796 return None;
797 }
798
799 let action = item.action.as_ref()?;
800 Kbd::binding_for_action_in(action.as_ref(), &self.focus_handle(cx), window)
801 .or_else(|| Kbd::binding_for_action(action.as_ref(), None, window))
802 }
803
804 fn render_item(
805 &self,
806 matched_ix: usize,
807 window: &mut Window,
808 cx: &mut Context<Self>,
809 ) -> AnyElement {
810 let binding = self.item_binding(matched_ix, window, cx);
811 self.render_item_with_binding(matched_ix, binding, window, cx)
812 }
813
814 fn render_item_with_binding(
815 &self,
816 matched_ix: usize,
817 binding: Option<Kbd>,
818 window: &mut Window,
819 cx: &mut Context<Self>,
820 ) -> AnyElement {
821 let Some(item) = self.item_at(matched_ix) else {
822 return div().into_any_element();
823 };
824
825 let disabled = item.is_disabled();
826 let selected = self.selected_index == Some(matched_ix) && !disabled;
827 let muted_foreground = cx.theme().muted_foreground;
828 let icon_color = if selected {
829 cx.theme().accent_foreground
830 } else {
831 muted_foreground
832 };
833 let content = match &item.content {
834 Some(render) => render(window, cx),
835 None => h_flex()
836 .flex_1()
837 .gap_2()
838 .items_center()
839 .when_some(item.icon.clone(), |this, icon| {
840 this.child(icon.size_4().text_color(icon_color))
841 })
842 .when_some(item.label_text().cloned(), |this, label| this.child(label))
843 .into_any_element(),
844 };
845
846 self.item_row(selected, window, cx)
847 .id(self.matched[matched_ix].index_path)
848 .test_support()
849 .role(Role::ListBoxOption)
850 .aria_selected(selected)
851 .when(disabled, |this| this.text_color(muted_foreground))
852 .when(!disabled, |this| {
853 this.cursor_default()
854 .on_hover(cx.listener(move |this, hovered: &bool, window, cx| {
855 if *hovered {
856 this.select(matched_ix, window, cx);
857 }
858 }))
859 .on_click(cx.listener(move |this, _, window, cx| {
860 this.confirm(matched_ix, window, cx);
861 }))
862 })
863 .child(content)
864 .map(|this| match binding {
865 Some(binding) => this.child(binding.ml_auto()),
866 None => this.when(item.checked, |this| {
869 this.child(crate::Sizable::xsmall(Icon::new(IconName::Check).ml_auto()))
870 }),
871 })
872 .into_any_element()
873 }
874
875 fn render_empty(&self, window: &mut Window, cx: &mut App) -> AnyElement {
876 if let Some(empty) = self.options.empty.as_ref() {
877 return empty(self, window, cx);
878 }
879
880 let message: SharedString = t!("Command.empty").to_string().into();
881
882 div()
883 .py_6()
884 .w_full()
885 .text_center()
886 .text_sm()
887 .text_color(cx.theme().muted_foreground)
888 .child(message)
889 .into_any_element()
890 }
891}
892
893impl Focusable for CommandState {
894 fn focus_handle(&self, cx: &App) -> FocusHandle {
895 if self.model.searchable {
896 self.query_input.focus_handle(cx)
897 } else {
898 self.focus_handle.clone()
899 }
900 }
901}
902
903impl Render for CommandState {
904 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
905 self.sync_placeholder(window, cx);
906
907 if self.needs_measure {
908 self.needs_measure = false;
909 self.measure_rows(window, cx);
910 }
911
912 if let Some(row_ix) = self.pending_scroll.take() {
913 self.scroll_handle
914 .scroll_to_item(row_ix, ScrollStrategy::Nearest);
915 }
916
917 let rows_count = self.rows.len();
918 let row_sizes = self.row_sizes.clone();
919 let command_state = cx.entity();
920
921 v_flex()
922 .id("command")
923 .test_support()
924 .key_context(CONTEXT)
925 .track_focus(&self.focus_handle)
926 .on_action(cx.listener(Self::on_action_select_up))
927 .on_action(cx.listener(Self::on_action_select_down))
928 .on_action(cx.listener(Self::on_action_confirm))
929 .on_action(cx.listener(Self::on_action_cancel))
930 .w_full()
931 .overflow_hidden()
932 .bg(cx.theme().popover)
933 .text_color(cx.theme().popover_foreground)
934 .when(self.options.bordered, |this| {
935 this.rounded(cx.theme().radius_lg)
936 .border_1()
937 .border_color(cx.theme().border)
938 })
939 .refine_style(&self.options.style)
940 .when_some(self.options.header.as_ref(), |this, header| {
941 this.child(header(self, window, cx))
942 })
943 .when(self.model.searchable, |this| {
944 this.child(
945 div()
946 .flex_none()
947 .px_3()
948 .border_b_1()
949 .border_color(cx.theme().border)
950 .child(
951 Input::new(&self.query_input)
952 .prefix(
953 Icon::new(IconName::Search)
954 .text_color(cx.theme().muted_foreground),
955 )
956 .appearance(false)
957 .p_0(),
958 ),
959 )
960 })
961 .child(
962 v_flex()
963 .id("command-list-container")
964 .role(Role::ListBox)
965 .relative()
966 .flex_1()
967 .when(rows_count == 0, |this| this.p_1())
972 .on_prepaint({
973 let measure_state = command_state.clone();
974 move |bounds, window, cx| {
975 measure_state.update(cx, |state, cx| {
976 let text_style = window.text_style();
981 state.set_list_measurement_key(
982 ListMeasurementKey {
983 content_width: (bounds.size.width
984 - window.rem_size() * 0.5)
985 .max(px(0.)),
986 rem_size: window.rem_size(),
987 line_height: window.line_height(),
988 text_shape: TextShapeKey {
989 font_family: text_style.font_family,
990 font_features: text_style.font_features,
991 font_fallbacks: text_style.font_fallbacks,
992 font_size: text_style.font_size,
993 font_weight: text_style.font_weight,
994 font_style: text_style.font_style,
995 white_space: text_style.white_space,
996 text_overflow: text_style.text_overflow,
997 line_clamp: text_style.line_clamp,
998 },
999 },
1000 window,
1001 cx,
1002 )
1003 })
1004 }
1005 })
1006 .max_h(self.options.max_h)
1007 .overflow_hidden()
1008 .when(rows_count == 0 && !self.loading, |this| {
1011 this.child(self.render_empty(window, cx))
1012 })
1013 .when(rows_count > 0, |this| {
1014 this.child(
1015 v_virtual_list(
1016 command_state.clone(),
1017 "command-list",
1018 row_sizes,
1019 move |this, visible_range, window, cx| {
1020 visible_range
1021 .map(|row_ix| this.render_visible_row(row_ix, window, cx))
1022 .collect::<Vec<_>>()
1023 },
1024 )
1025 .p_1()
1030 .with_sizing_behavior(ListSizingBehavior::Infer)
1031 .track_scroll(&self.scroll_handle)
1032 .map(|list| {
1033 gpui_base::ScrollBounce::new(
1034 "command-scroll-bounce",
1035 &self.scroll_handle,
1036 list,
1037 )
1038 .when_some(self.options.scroll_bounce, |bounce, on| {
1039 bounce.enabled(on)
1040 })
1041 }),
1042 )
1043 .child(Scrollbar::vertical(&self.scroll_handle))
1044 }),
1045 )
1046 .when_some(self.options.footer.as_ref(), |this, footer| {
1047 this.child(footer(self, window, cx))
1048 })
1049 }
1050}
1051
1052#[cfg(test)]
1055mod tests {
1056 use std::{
1057 cell::{Cell, RefCell},
1058 rc::Rc,
1059 };
1060
1061 use gpui::{
1062 AppContext as _, AvailableSpace, Entity, InteractiveElement as _, IntoElement, KeyBinding,
1063 Modifiers, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window,
1064 actions, div, point, prelude::FluentBuilder as _, px,
1065 };
1066
1067 use super::{CONTEXT, CommandModel, CommandRow, CommandState, SEPARATOR_ROW_HEIGHT};
1068 use crate::{
1069 Disableable as _, Icon, IconName, IndexPath,
1070 actions::{Cancel, Confirm, SelectDown},
1071 command::{Command, CommandEntry, CommandGroup, CommandItem},
1072 };
1073
1074 actions!(
1075 command_test,
1076 [GlobalTestItem, OpenTestItem, RemovePaletteTestItem]
1077 );
1078
1079 struct CommandActionsHarness {
1080 state: Entity<CommandState>,
1081 events: Rc<RefCell<Vec<String>>>,
1082 }
1083
1084 impl Render for CommandActionsHarness {
1085 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1086 let action_events = self.events.clone();
1087 let propagated_cancel_events = self.events.clone();
1088 let query_events = self.events.clone();
1089 let select_events = self.events.clone();
1090 let confirm_events = self.events.clone();
1091 let cancel_events = self.events.clone();
1092
1093 div()
1094 .size_full()
1095 .on_action(move |_: &OpenTestItem, _, _| {
1096 action_events.borrow_mut().push("action".into());
1097 })
1098 .on_action(move |_: &Cancel, _, _| {
1099 propagated_cancel_events
1100 .borrow_mut()
1101 .push("propagated_cancel".into());
1102 })
1103 .child(
1104 Command::new(&self.state)
1105 .item(
1106 CommandItem::new()
1107 .label("Item")
1108 .keywords(["needle"])
1109 .action(Box::new(OpenTestItem)),
1110 )
1111 .item(CommandItem::new().label("Item"))
1112 .item(
1113 CommandItem::new()
1114 .label("Item")
1115 .action(Box::new(GlobalTestItem)),
1116 )
1117 .on_query(move |query, _, _| {
1118 query_events.borrow_mut().push(format!("query:{query}"));
1119 })
1120 .on_select(move |index, _, _| {
1121 select_events
1122 .borrow_mut()
1123 .push(format!("select:{}:{}", index.section, index.row));
1124 })
1125 .on_confirm(move |index, _, _| {
1126 confirm_events
1127 .borrow_mut()
1128 .push(format!("confirm:{}:{}", index.section, index.row));
1129 })
1130 .on_cancel(move |_, _| {
1131 cancel_events.borrow_mut().push("cancel".into());
1132 }),
1133 )
1134 }
1135 }
1136
1137 struct ReentrantCallbackHarness {
1138 state: Entity<CommandState>,
1139 events: Vec<String>,
1140 }
1141
1142 impl Render for ReentrantCallbackHarness {
1143 fn render(&mut self, _: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
1144 let select_owner = cx.weak_entity();
1145 let query_owner = cx.weak_entity();
1146 let confirm_owner = cx.weak_entity();
1147
1148 Command::new(&self.state)
1149 .item(CommandItem::new().label("alpha"))
1150 .item(CommandItem::new().label("beta"))
1151 .on_select(move |index, _, cx| {
1152 _ = select_owner.update(cx, |harness, cx| {
1153 assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1154 harness
1155 .events
1156 .push(format!("select:{}:{}", index.section, index.row));
1157 });
1158 })
1159 .on_query(move |query, _, cx| {
1160 _ = query_owner.update(cx, |harness, cx| {
1161 assert_eq!(harness.state.read(cx).query(cx).as_ref(), query);
1162 harness.events.push(format!("query:{query}"));
1163 });
1164 })
1165 .on_confirm(move |index, _, cx| {
1166 _ = confirm_owner.update(cx, |harness, cx| {
1167 assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1168 harness
1169 .events
1170 .push(format!("confirm:{}:{}", index.section, index.row));
1171 });
1172 })
1173 }
1174 }
1175
1176 #[gpui::test]
1177 fn query_and_selection_callbacks_run_after_the_state_lease_in_defined_order(
1178 cx: &mut TestAppContext,
1179 ) {
1180 cx.update(crate::init);
1181 let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1182 state: cx.new(|cx| CommandState::new(window, cx)),
1183 events: Vec::new(),
1184 });
1185 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1186
1187 cx.run_until_parked();
1188 cx.update(|window, cx| {
1189 _ = window.draw(cx);
1190 state.update(cx, |state, cx| {
1191 state.selected_index = Some(1);
1192 state.set_query("alpha", window, cx);
1193 });
1194 });
1195
1196 assert_eq!(
1197 harness.read_with(cx, |harness, _| harness.events.clone()),
1198 ["select:0:0", "query:alpha"]
1199 );
1200 }
1201
1202 #[gpui::test]
1203 fn actionless_confirm_callback_runs_after_the_state_lease(cx: &mut TestAppContext) {
1204 cx.update(crate::init);
1205 let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1206 state: cx.new(|cx| CommandState::new(window, cx)),
1207 events: Vec::new(),
1208 });
1209 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1210
1211 cx.run_until_parked();
1212 cx.update(|window, cx| {
1213 _ = window.draw(cx);
1214 state.update(cx, |state, cx| state.confirm(0, window, cx));
1215 });
1216
1217 assert_eq!(
1218 harness.read_with(cx, |harness, _| harness.events.clone()),
1219 ["confirm:0:0"]
1220 );
1221 }
1222
1223 struct CommandItemWidthHarness {
1224 state: Entity<CommandState>,
1225 matched_ix: usize,
1226 width: Rc<Cell<Option<Pixels>>>,
1227 }
1228
1229 impl Render for CommandItemWidthHarness {
1230 fn render(
1231 &mut self,
1232 window: &mut Window,
1233 cx: &mut gpui::Context<Self>,
1234 ) -> impl IntoElement {
1235 let width = self.width.clone();
1236 let item = self.state.update(cx, |state, cx| {
1237 state.render_item(self.matched_ix, window, cx)
1238 });
1239
1240 div()
1241 .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
1242 .child(item)
1243 }
1244 }
1245
1246 #[gpui::test]
1247 fn action_that_removes_command_state_still_confirms_after_dispatch(cx: &mut TestAppContext) {
1248 cx.update(crate::init);
1249 let events = Rc::new(RefCell::new(Vec::new()));
1250 let state_owner: Rc<RefCell<Option<Entity<CommandState>>>> = Rc::new(RefCell::new(None));
1251 let action_events = events.clone();
1252 let action_state_owner = state_owner.clone();
1253 cx.update(|cx| {
1254 cx.on_action(move |_: &RemovePaletteTestItem, _| {
1255 action_events.borrow_mut().push("action".into());
1256 action_state_owner.borrow_mut().take();
1257 });
1258 });
1259 let cx = cx.add_empty_window();
1260 cx.update(|window, cx| {
1261 let confirm_events = events.clone();
1262 let state = cx.new(|cx| {
1263 let mut state = CommandState::new(window, cx);
1264 state.install_model(
1265 CommandModel {
1266 entries: vec![CommandEntry::Item(
1267 CommandItem::new()
1268 .label("removed")
1269 .action(Box::new(RemovePaletteTestItem)),
1270 )],
1271 searchable: false,
1272 on_confirm: Some(Rc::new(move |index, _, _| {
1273 confirm_events
1274 .borrow_mut()
1275 .push(format!("confirm:{}:{}", index.section, index.row));
1276 })),
1277 ..CommandModel::default()
1278 },
1279 cx,
1280 );
1281 state
1282 });
1283 *state_owner.borrow_mut() = Some(state.clone());
1284 state.update(cx, |state, cx| state.confirm(0, window, cx));
1285 });
1286 cx.run_until_parked();
1287
1288 assert!(state_owner.borrow().is_none());
1289 assert_eq!(events.borrow().as_slice(), ["action", "confirm:0:0"]);
1290 }
1291
1292 #[gpui::test]
1293 fn command_actions_and_callbacks_follow_defined_order(cx: &mut TestAppContext) {
1294 cx.update(|cx| {
1295 crate::init(cx);
1296 cx.bind_keys([
1297 KeyBinding::new("ctrl-o", OpenTestItem, Some(CONTEXT)),
1298 KeyBinding::new("ctrl-g", GlobalTestItem, None),
1299 ]);
1300 });
1301 let events = Rc::new(RefCell::new(Vec::new()));
1302 let (harness, cx) = cx.add_window_view(|window, cx| CommandActionsHarness {
1303 state: cx.new(|cx| CommandState::new(window, cx)),
1304 events: events.clone(),
1305 });
1306 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1307
1308 cx.run_until_parked();
1309 cx.update(|window, cx| {
1310 _ = window.draw(cx);
1311 state.update(cx, |state, cx| state.focus(window, cx));
1312 _ = window.draw(cx);
1313 });
1314
1315 let action_width = Rc::new(Cell::new(None));
1316 let plain_width = Rc::new(Cell::new(None));
1317 let global_width = Rc::new(Cell::new(None));
1318 let (action_probe, plain_probe, global_probe) = cx.update(|_, cx| {
1319 (
1320 cx.new(|_| CommandItemWidthHarness {
1321 state: state.clone(),
1322 matched_ix: 0,
1323 width: action_width.clone(),
1324 }),
1325 cx.new(|_| CommandItemWidthHarness {
1326 state: state.clone(),
1327 matched_ix: 1,
1328 width: plain_width.clone(),
1329 }),
1330 cx.new(|_| CommandItemWidthHarness {
1331 state: state.clone(),
1332 matched_ix: 2,
1333 width: global_width.clone(),
1334 }),
1335 )
1336 });
1337 cx.draw(
1338 point(px(0.), px(0.)),
1339 AvailableSpace::min_size(),
1340 move |_, _| action_probe.into_any_element(),
1341 );
1342 cx.draw(
1343 point(px(0.), px(0.)),
1344 AvailableSpace::min_size(),
1345 move |_, _| plain_probe.into_any_element(),
1346 );
1347 cx.draw(
1348 point(px(0.), px(0.)),
1349 AvailableSpace::min_size(),
1350 move |_, _| global_probe.into_any_element(),
1351 );
1352 let action_width = action_width.get().unwrap();
1353 let plain_width = plain_width.get().unwrap();
1354 let global_width = global_width.get().unwrap();
1355 assert!(
1356 action_width > plain_width,
1357 "the scoped Action binding should add a visible Kbd ({action_width:?} vs {plain_width:?})",
1358 );
1359 assert!(
1360 global_width > plain_width,
1361 "the app-level fallback binding should add a visible Kbd ({global_width:?} vs {plain_width:?})",
1362 );
1363
1364 cx.update(|window, cx| {
1365 state.update(cx, |state, cx| {
1366 state.set_query("needle", window, cx);
1367 state.set_query("needle", window, cx);
1368 state.set_query("", window, cx);
1369 });
1370 window.dispatch_action(Box::new(SelectDown), cx);
1371 window.dispatch_action(Box::new(crate::actions::SelectUp), cx);
1372 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1373 });
1374 cx.run_until_parked();
1375
1376 assert_eq!(
1377 events.borrow().as_slice(),
1378 [
1379 "query:needle",
1380 "query:",
1381 "select:0:1",
1382 "select:0:0",
1383 "action",
1384 "confirm:0:0",
1385 ]
1386 );
1387
1388 cx.simulate_click(point(px(20.), px(52.)), Modifiers::default());
1389 cx.run_until_parked();
1390 cx.update(|window, cx| window.dispatch_action(Box::new(Cancel), cx));
1391 cx.run_until_parked();
1392
1393 assert_eq!(
1394 events.borrow().as_slice(),
1395 [
1396 "query:needle",
1397 "query:",
1398 "select:0:1",
1399 "select:0:0",
1400 "action",
1401 "confirm:0:0",
1402 "action",
1403 "confirm:0:0",
1404 "cancel",
1405 "propagated_cancel",
1406 ]
1407 );
1408 }
1409
1410 struct CommandOwnedEntriesHarness {
1411 state: Entity<CommandState>,
1412 }
1413
1414 impl Render for CommandOwnedEntriesHarness {
1415 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1416 Command::new(&self.state)
1417 .searchable(false)
1418 .item(CommandItem::new().label("alpha"))
1419 .group(
1420 CommandGroup::new()
1421 .label("Settings")
1422 .item(CommandItem::new().label("beta")),
1423 )
1424 .separator()
1425 .item(
1426 CommandItem::new()
1427 .label("custom")
1428 .child(|_, _| div().h(px(72.)).child("Custom")),
1429 )
1430 }
1431 }
1432
1433 #[gpui::test]
1434 fn command_owns_entries_and_lazy_item_content(cx: &mut TestAppContext) {
1435 cx.update(crate::init);
1436 let (harness, cx) = cx.add_window_view(|window, cx| CommandOwnedEntriesHarness {
1437 state: cx.new(|cx| CommandState::new(window, cx)),
1438 });
1439
1440 cx.run_until_parked();
1441 cx.update(|window, cx| _ = window.draw(cx));
1442
1443 let (labels, rows, row_sizes) = cx.update(|_, cx| {
1444 let state = harness.read(cx).state.read(cx);
1445 (
1446 (0..state.matched_count())
1447 .map(|matched_ix| {
1448 state
1449 .item_at(matched_ix)
1450 .unwrap()
1451 .label_text()
1452 .unwrap()
1453 .clone()
1454 })
1455 .collect::<Vec<_>>(),
1456 state.rows.clone(),
1457 state.row_sizes.clone(),
1458 )
1459 });
1460
1461 assert_eq!(labels, ["alpha", "beta", "custom"]);
1462 assert!(matches!(
1463 rows.as_slice(),
1464 [
1465 CommandRow::Item(_),
1466 CommandRow::Heading(heading),
1467 CommandRow::Item(_),
1468 CommandRow::Separator,
1469 CommandRow::Item(_),
1470 ] if heading == "Settings"
1471 ));
1472 assert_eq!(row_sizes[4].height, px(84.));
1473 }
1474
1475 fn command_with_entries(
1476 state: &Entity<CommandState>,
1477 entries: impl IntoIterator<Item = CommandEntry>,
1478 ) -> Command {
1479 entries
1480 .into_iter()
1481 .fold(Command::new(state), |command, entry| match entry {
1482 CommandEntry::Item(item) => command.item(item),
1483 CommandEntry::Group(group) => command.group(group),
1484 CommandEntry::Separator => command.separator(),
1485 })
1486 }
1487
1488 fn command_state(
1489 window: &mut Window,
1490 cx: &mut gpui::Context<CommandState>,
1491 entries: impl IntoIterator<Item = CommandEntry>,
1492 ) -> CommandState {
1493 let mut state = CommandState::new(window, cx);
1494 state.install_model(
1495 CommandModel {
1496 entries: entries.into_iter().collect(),
1497 ..CommandModel::default()
1498 },
1499 cx,
1500 );
1501 state
1502 }
1503
1504 fn command_state_with_options(
1505 window: &mut Window,
1506 cx: &mut gpui::Context<CommandState>,
1507 entries: impl IntoIterator<Item = CommandEntry>,
1508 searchable: bool,
1509 ) -> CommandState {
1510 let mut state = CommandState::new(window, cx);
1511 state.install_model(
1512 CommandModel {
1513 entries: entries.into_iter().collect(),
1514 searchable,
1515 ..CommandModel::default()
1516 },
1517 cx,
1518 );
1519 state
1520 }
1521
1522 fn suggestion_entries() -> Vec<CommandEntry> {
1523 vec![
1524 CommandGroup::new()
1525 .label("Suggestions")
1526 .item(CommandItem::new().label("Calendar"))
1527 .item(CommandItem::new().label("Search Emoji"))
1528 .item(CommandItem::new().label("Calculator").disabled(true))
1529 .into(),
1530 CommandEntry::Separator,
1531 CommandGroup::new()
1532 .label("Settings")
1533 .item(CommandItem::new().label("Profile"))
1534 .item(CommandItem::new().label("Billing"))
1535 .into(),
1536 ]
1537 }
1538
1539 #[gpui::test]
1540 fn query_hides_the_groups_that_have_no_match(cx: &mut TestAppContext) {
1541 cx.update(crate::init);
1542 let cx = cx.add_empty_window();
1543
1544 cx.update(|window, cx| {
1545 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1546
1547 state.update(cx, |state, cx| {
1548 state.update_matches(cx);
1549 assert_eq!(state.matched_count(), 5);
1550 assert_eq!(
1551 state
1552 .rows
1553 .iter()
1554 .filter(|row| matches!(row, CommandRow::Heading(_)))
1555 .count(),
1556 2,
1557 );
1558 assert_eq!(
1559 state
1560 .rows
1561 .iter()
1562 .filter(|row| matches!(row, CommandRow::Separator))
1563 .count(),
1564 1,
1565 );
1566
1567 state.set_query("Bil", window, cx);
1570 state.update_matches(cx);
1571
1572 assert_eq!(state.matched_count(), 1);
1573 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1574 assert_eq!(
1575 state
1576 .rows
1577 .iter()
1578 .filter(|row| matches!(row, CommandRow::Separator))
1579 .count(),
1580 0,
1581 );
1582 assert!(matches!(state.rows.first(), Some(CommandRow::Heading(_))));
1583 });
1584 });
1585 }
1586
1587 fn custom_entries() -> Vec<CommandEntry> {
1588 vec![CommandEntry::Item(
1589 CommandItem::new().label("Custom").child(|_, _| div()),
1590 )]
1591 }
1592
1593 #[gpui::test]
1594 fn reinstalling_an_unchanged_model_keeps_the_measured_rows(cx: &mut TestAppContext) {
1595 cx.update(crate::init);
1596 let cx = cx.add_empty_window();
1597
1598 cx.update(|window, cx| {
1599 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1600
1601 state.update(cx, |state, cx| {
1602 state.needs_measure = false;
1604 state.install_model(
1605 CommandModel {
1606 entries: suggestion_entries(),
1607 ..CommandModel::default()
1608 },
1609 cx,
1610 );
1611 assert!(!state.needs_measure);
1612 assert_eq!(state.matched_count(), 5);
1613
1614 state.install_model(
1617 CommandModel {
1618 entries: custom_entries(),
1619 ..CommandModel::default()
1620 },
1621 cx,
1622 );
1623 assert!(state.needs_measure);
1624 assert_eq!(state.matched_count(), 1);
1625
1626 state.needs_measure = false;
1629 let entries = state.model.entries.clone();
1630 state.install_model(
1631 CommandModel {
1632 entries,
1633 ..CommandModel::default()
1634 },
1635 cx,
1636 );
1637 assert!(state.needs_measure);
1638
1639 let item = || CommandItem::new().label("Renamed");
1642 let icon = || Icon::new(IconName::Check);
1643 for entries in [
1644 vec![CommandEntry::Item(item())],
1645 vec![CommandEntry::Item(item().icon(icon()))],
1646 vec![CommandEntry::Item(item().icon(icon().ml_2()))],
1647 vec![CommandEntry::Item(
1648 item().icon(icon().ml_2()).disabled(true),
1649 )],
1650 ] {
1651 state.needs_measure = false;
1652 state.install_model(
1653 CommandModel {
1654 entries,
1655 ..CommandModel::default()
1656 },
1657 cx,
1658 );
1659 assert!(state.needs_measure);
1660 }
1661 assert_eq!(state.selected_index(), None);
1662 });
1663 });
1664 }
1665
1666 #[gpui::test]
1667 fn a_query_that_matches_nothing_leaves_no_rows(cx: &mut TestAppContext) {
1668 cx.update(crate::init);
1669 let cx = cx.add_empty_window();
1670
1671 cx.update(|window, cx| {
1672 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1673
1674 state.update(cx, |state, cx| {
1675 state.set_query("zzz", window, cx);
1676 state.update_matches(cx);
1677
1678 assert_eq!(state.matched_count(), 0);
1679 assert!(state.rows.is_empty());
1680 assert_eq!(state.selected_index(), None);
1681 });
1682 });
1683 }
1684
1685 #[gpui::test]
1686 fn filterable_off_keeps_every_item_and_resets_the_highlight(cx: &mut TestAppContext) {
1687 cx.update(crate::init);
1688 let cx = cx.add_empty_window();
1689
1690 cx.update(|window, cx| {
1691 let state = cx.new(|cx| {
1692 let mut state = CommandState::new(window, cx);
1693 state.install_model(
1694 CommandModel {
1695 entries: suggestion_entries(),
1696 filterable: false,
1697 ..CommandModel::default()
1698 },
1699 cx,
1700 );
1701 state
1702 });
1703
1704 state.update(cx, |state, cx| {
1705 state.set_selected_index(Some(IndexPath::new(1).section(1)), window, cx);
1706
1707 state.set_query("Bil", window, cx);
1711
1712 assert_eq!(state.matched_count(), 5);
1713 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1714 });
1715 });
1716 }
1717
1718 #[gpui::test]
1719 fn keywords_match_when_the_label_does_not(cx: &mut TestAppContext) {
1720 cx.update(crate::init);
1721 let cx = cx.add_empty_window();
1722
1723 cx.update(|window, cx| {
1724 let state = cx.new(|cx| {
1725 command_state(
1726 window,
1727 cx,
1728 [CommandEntry::Item(
1729 CommandItem::new().label("Profile").keywords(["account"]),
1730 )],
1731 )
1732 });
1733
1734 state.update(cx, |state, cx| {
1735 state.set_query("account", window, cx);
1736 state.update_matches(cx);
1737
1738 assert_eq!(state.matched_count(), 1);
1739 });
1740 });
1741 }
1742
1743 #[gpui::test]
1744 fn non_searchable_command_keeps_every_item(cx: &mut TestAppContext) {
1745 cx.update(crate::init);
1746 let cx = cx.add_empty_window();
1747 cx.update(|window, cx| {
1748 let state = cx.new(|cx| {
1749 command_state_with_options(
1750 window,
1751 cx,
1752 [
1753 CommandEntry::Item(CommandItem::new().label("alpha")),
1754 CommandEntry::Item(CommandItem::new().label("beta")),
1755 ],
1756 false,
1757 )
1758 });
1759 state.update(cx, |state, cx| {
1760 state.set_query("missing", window, cx);
1761 assert_eq!(state.matched_count(), 2);
1762 });
1763 });
1764 }
1765
1766 #[gpui::test]
1767 fn non_searchable_command_uses_frame_focus(cx: &mut TestAppContext) {
1768 cx.update(crate::init);
1769 let confirmed = Rc::new(RefCell::new(None));
1770 let confirmed_for_render = confirmed.clone();
1771 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1772 state: cx.new(|cx| CommandState::new(window, cx)),
1773 command: Rc::new(move |state| {
1774 let confirmed = confirmed_for_render.clone();
1775 Command::new(state)
1776 .searchable(false)
1777 .item(CommandItem::new().label("alpha"))
1778 .item(CommandItem::new().label("beta"))
1779 .on_confirm(move |index_path, _, _| {
1780 *confirmed.borrow_mut() = Some(index_path);
1781 })
1782 }),
1783 });
1784 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1785
1786 cx.run_until_parked();
1787 cx.update(|window, cx| _ = window.draw(cx));
1788 cx.update(|window, cx| {
1789 state.update(cx, |state, cx| state.focus(window, cx));
1790 assert!(state.read(cx).focus_handle.is_focused(window));
1791 window.dispatch_action(Box::new(SelectDown), cx);
1792 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1793 });
1794
1795 assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1796 }
1797
1798 #[gpui::test]
1799 fn filtered_ungrouped_item_keeps_its_input_row(cx: &mut TestAppContext) {
1800 cx.update(crate::init);
1801 let confirmed = Rc::new(RefCell::new(None));
1802 let confirmed_for_render = confirmed.clone();
1803 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1804 state: cx.new(|cx| CommandState::new(window, cx)),
1805 command: Rc::new(move |state| {
1806 let confirmed = confirmed_for_render.clone();
1807 Command::new(state)
1808 .items([
1809 CommandItem::new().label("alpha"),
1810 CommandItem::new().label("beta"),
1811 CommandItem::new().label("gamma"),
1812 ])
1813 .on_confirm(move |index_path, _, _| {
1814 *confirmed.borrow_mut() = Some(index_path);
1815 })
1816 }),
1817 });
1818 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1819
1820 cx.run_until_parked();
1821 cx.update(|window, cx| {
1822 state.update(cx, |state, cx| {
1823 state.set_query("gamma", window, cx);
1824 state.focus(window, cx);
1825 });
1826 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1827 });
1828
1829 assert_eq!(*confirmed.borrow(), Some(IndexPath::new(2).section(0)));
1830 }
1831
1832 #[gpui::test]
1833 fn initially_rendered_disabled_first_item_selects_and_confirms_the_first_enabled_item(
1834 cx: &mut TestAppContext,
1835 ) {
1836 cx.update(crate::init);
1837 let confirmed = Rc::new(RefCell::new(None));
1838 let confirmed_for_render = confirmed.clone();
1839 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1840 state: cx.new(|cx| CommandState::new(window, cx)),
1841 command: Rc::new(move |state| {
1842 let confirmed = confirmed_for_render.clone();
1843 Command::new(state)
1844 .item(CommandItem::new().label("disabled").disabled(true))
1845 .item(CommandItem::new().label("enabled"))
1846 .on_confirm(move |index_path, _, _| {
1847 *confirmed.borrow_mut() = Some(index_path);
1848 })
1849 }),
1850 });
1851 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1852
1853 cx.run_until_parked();
1854 cx.update(|window, cx| _ = window.draw(cx));
1855 cx.update(|window, cx| {
1856 state.update(cx, |state, cx| state.focus(window, cx));
1857 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1858 });
1859
1860 assert_eq!(
1861 state.read_with(cx, |state, _| state.selected_index()),
1862 Some(IndexPath::new(1).section(0))
1863 );
1864 assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1865 }
1866
1867 #[gpui::test]
1868 fn initially_rendered_all_disabled_items_have_no_selected_index_and_ignore_enter(
1869 cx: &mut TestAppContext,
1870 ) {
1871 cx.update(crate::init);
1872 let confirmed = Rc::new(RefCell::new(None));
1873 let confirmed_for_render = confirmed.clone();
1874 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1875 state: cx.new(|cx| CommandState::new(window, cx)),
1876 command: Rc::new(move |state| {
1877 let confirmed = confirmed_for_render.clone();
1878 Command::new(state)
1879 .item(CommandItem::new().label("one").disabled(true))
1880 .item(CommandItem::new().label("two").disabled(true))
1881 .on_confirm(move |index_path, _, _| {
1882 *confirmed.borrow_mut() = Some(index_path);
1883 })
1884 }),
1885 });
1886 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1887
1888 cx.run_until_parked();
1889 cx.update(|window, cx| _ = window.draw(cx));
1890 cx.update(|window, cx| {
1891 state.update(cx, |state, cx| state.focus(window, cx));
1892 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1893 });
1894
1895 assert_eq!(state.read_with(cx, |state, _| state.selected_index()), None);
1896 assert_eq!(*confirmed.borrow(), None);
1897 }
1898
1899 #[gpui::test]
1900 fn non_searchable_command_cancels_without_clearing_a_hidden_query(cx: &mut TestAppContext) {
1901 cx.update(crate::init);
1902 let cancelled = Rc::new(Cell::new(false));
1903 let cancelled_for_render = cancelled.clone();
1904 let query_calls = Rc::new(Cell::new(0));
1905 let query_calls_for_render = query_calls.clone();
1906 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1907 state: cx.new(|cx| CommandState::new(window, cx)),
1908 command: Rc::new(move |state| {
1909 let cancelled = cancelled_for_render.clone();
1910 let query_calls = query_calls_for_render.clone();
1911 Command::new(state)
1912 .searchable(false)
1913 .item(CommandItem::new().label("alpha"))
1914 .on_query(move |_, _, _| query_calls.set(query_calls.get() + 1))
1915 .on_cancel(move |_, _| cancelled.set(true))
1916 }),
1917 });
1918 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1919
1920 cx.run_until_parked();
1921 cx.update(|window, cx| _ = window.draw(cx));
1922 cx.update(|window, cx| {
1923 state.update(cx, |state, cx| {
1924 state.set_query("hidden query", window, cx);
1925 state.focus(window, cx);
1926 });
1927 window.dispatch_action(Box::new(Cancel), cx);
1928 });
1929
1930 assert!(cancelled.get());
1931 assert_eq!(query_calls.get(), 0);
1932 assert_eq!(
1933 state.read_with(cx, |state, cx| state.query(cx)),
1934 "hidden query"
1935 );
1936 }
1937
1938 #[gpui::test]
1939 fn moving_the_highlight_skips_disabled_items_and_wraps(cx: &mut TestAppContext) {
1940 cx.update(crate::init);
1941 let cx = cx.add_empty_window();
1942
1943 cx.update(|window, cx| {
1944 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1945
1946 state.update(cx, |state, cx| {
1947 state.update_matches(cx);
1948 state.reset_selection();
1949 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1950
1951 state.select_by(1, window, cx);
1952 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1953
1954 state.select_by(1, window, cx);
1956 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(1)));
1957
1958 state.select_by(-1, window, cx);
1959 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1960
1961 state.select_by(-1, window, cx);
1963 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1964 state.select_by(-1, window, cx);
1965 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1966 });
1967 });
1968 }
1969
1970 #[gpui::test]
1971 fn owner_can_set_and_clear_selection_by_original_index_path(cx: &mut TestAppContext) {
1972 cx.update(crate::init);
1973 let cx = cx.add_empty_window();
1974
1975 cx.update(|window, cx| {
1976 let initially_empty = cx.new(|cx| CommandState::new(window, cx));
1977 initially_empty.update(cx, |state, cx| {
1978 state.set_selected_index(None, window, cx);
1979 state.install_model(
1980 CommandModel {
1981 entries: suggestion_entries().into_iter().collect(),
1982 ..CommandModel::default()
1983 },
1984 cx,
1985 );
1986 assert_eq!(state.selected_index(), None);
1987 });
1988
1989 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1990
1991 state.update(cx, |state, cx| {
1992 let target = IndexPath::new(1).section(1);
1993 state.set_selected_index(Some(target), window, cx);
1994 assert_eq!(state.selected_index(), Some(target));
1995
1996 state.set_selected_index(None, window, cx);
1997 assert_eq!(state.selected_index(), None);
1998
1999 state.install_model(
2000 CommandModel {
2001 entries: suggestion_entries().into_iter().collect(),
2002 ..CommandModel::default()
2003 },
2004 cx,
2005 );
2006 assert_eq!(state.selected_index(), None);
2007
2008 state.set_query("calendar", window, cx);
2009 state.set_selected_index(Some(target), window, cx);
2010 assert_eq!(state.selected_index(), None);
2011 });
2012 });
2013 }
2014
2015 #[gpui::test]
2016 fn confirming_a_disabled_item_does_nothing(cx: &mut TestAppContext) {
2017 cx.update(crate::init);
2018 let cx = cx.add_empty_window();
2019
2020 cx.update(|window, cx| {
2021 let state = cx.new(|cx| {
2022 command_state(
2023 window,
2024 cx,
2025 [
2026 CommandEntry::Item(CommandItem::new().label("enabled")),
2027 CommandEntry::Item(CommandItem::new().label("disabled").disabled(true)),
2028 ],
2029 )
2030 });
2031
2032 state.update(cx, |state, cx| {
2033 state.update_matches(cx);
2034
2035 assert_eq!(state.matched_count(), 2);
2036 state.confirm(1, window, cx);
2039 assert_eq!(state.selected_index, Some(0));
2040 });
2041 });
2042 }
2043
2044 #[gpui::test]
2045 fn a_checked_item_uses_an_xsmall_trailing_check_icon(cx: &mut TestAppContext) {
2046 cx.update(crate::init);
2047 let cx = cx.add_empty_window();
2048 let unchecked_width = Rc::new(Cell::new(None));
2049 let checked_width = Rc::new(Cell::new(None));
2050 let (unchecked, checked) = cx.update(|window, cx| {
2051 let unchecked_state = cx.new(|cx| {
2052 command_state(
2053 window,
2054 cx,
2055 [CommandEntry::Item(CommandItem::new().label("theme"))],
2056 )
2057 });
2058 let checked_state = cx.new(|cx| {
2059 command_state(
2060 window,
2061 cx,
2062 [CommandEntry::Item(
2063 CommandItem::new().label("theme").checked(true),
2064 )],
2065 )
2066 });
2067 let unchecked_width = unchecked_width.clone();
2068 let checked_width = checked_width.clone();
2069 (
2070 cx.new(|_| CheckIconWidthHarness {
2071 state: unchecked_state,
2072 width: unchecked_width,
2073 }),
2074 cx.new(|_| CheckIconWidthHarness {
2075 state: checked_state,
2076 width: checked_width,
2077 }),
2078 )
2079 });
2080
2081 cx.draw(
2082 gpui::point(px(0.), px(0.)),
2083 gpui::AvailableSpace::min_size(),
2084 move |_, _| unchecked.into_any_element(),
2085 );
2086
2087 cx.draw(
2088 gpui::point(px(0.), px(0.)),
2089 gpui::AvailableSpace::min_size(),
2090 move |_, _| checked.into_any_element(),
2091 );
2092
2093 assert_eq!(
2094 checked_width.get().unwrap() - unchecked_width.get().unwrap(),
2095 px(20.)
2096 );
2097 }
2098
2099 struct CheckIconWidthHarness {
2100 state: Entity<CommandState>,
2101 width: Rc<Cell<Option<gpui::Pixels>>>,
2102 }
2103
2104 impl Render for CheckIconWidthHarness {
2105 fn render(
2106 &mut self,
2107 window: &mut Window,
2108 cx: &mut gpui::Context<Self>,
2109 ) -> impl IntoElement {
2110 let width = self.width.clone();
2111 let item = self.state.update(cx, |state, cx| {
2112 state.update_matches(cx);
2113 state.render_item(0, window, cx)
2114 });
2115
2116 div()
2117 .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
2118 .child(item)
2119 }
2120 }
2121
2122 struct Harness {
2123 state: Entity<CommandState>,
2124 command: Rc<dyn Fn(&Entity<CommandState>) -> Command>,
2125 }
2126
2127 impl Render for Harness {
2128 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2129 div()
2130 .size_full()
2131 .child((self.command)(&self.state).max_h(px(200.)))
2132 }
2133 }
2134
2135 #[gpui::test]
2136 fn header_and_footer_render_with_current_state(cx: &mut TestAppContext) {
2137 cx.update(crate::init);
2138 let header_calls = Rc::new(Cell::new(0));
2139 let footer_calls = Rc::new(Cell::new(0));
2140 let header_matched_count = Rc::new(Cell::new(None));
2141 let footer_matched_count = Rc::new(Cell::new(None));
2142
2143 let (harness, cx) = cx.add_window_view(|window, cx| HeaderFooterHarness {
2144 state: cx.new(|cx| CommandState::new(window, cx)),
2145 header_calls,
2146 footer_calls,
2147 header_matched_count,
2148 footer_matched_count,
2149 });
2150
2151 cx.run_until_parked();
2152 cx.update(|window, cx| _ = window.draw(cx));
2153
2154 let (header_calls, footer_calls, header_matched_count, footer_matched_count) =
2155 cx.update(|_, cx| {
2156 let harness = harness.read(cx);
2157 (
2158 harness.header_calls.get(),
2159 harness.footer_calls.get(),
2160 harness.header_matched_count.get(),
2161 harness.footer_matched_count.get(),
2162 )
2163 });
2164 assert!(header_calls > 0);
2165 assert!(footer_calls > 0);
2166 assert_eq!(header_matched_count, Some(2));
2167 assert_eq!(footer_matched_count, Some(2));
2168 }
2169
2170 #[gpui::test]
2171 fn custom_empty_slot_renders_with_current_state(cx: &mut TestAppContext) {
2172 cx.update(crate::init);
2173 let empty_calls = Rc::new(Cell::new(0));
2174 let empty_matched_count = Rc::new(Cell::new(None));
2175 let calls = empty_calls.clone();
2176 let matched_count = empty_matched_count.clone();
2177 let (_harness, cx) = cx.add_window_view(move |window, cx| Harness {
2178 state: cx.new(|cx| CommandState::new(window, cx)),
2179 command: Rc::new(move |state| {
2180 let calls = calls.clone();
2181 let matched_count = matched_count.clone();
2182 Command::new(state).empty(
2183 move |state: &CommandState, _: &mut Window, _: &mut gpui::App| {
2184 calls.set(calls.get() + 1);
2185 matched_count.set(Some(state.matched_count()));
2186 div().child("Custom empty")
2187 },
2188 )
2189 }),
2190 });
2191
2192 cx.run_until_parked();
2193 cx.update(|window, cx| _ = window.draw(cx));
2194
2195 assert!(empty_calls.get() > 0);
2196 assert_eq!(empty_matched_count.get(), Some(0));
2197 }
2198
2199 fn entries_with_late_first_enabled_item() -> Vec<CommandEntry> {
2200 vec![
2201 CommandGroup::new()
2202 .label("Disabled")
2203 .items((0..30).map(|ix| {
2204 CommandItem::new()
2205 .label(format!("disabled-{ix}"))
2206 .keywords(["match"])
2207 .disabled(true)
2208 }))
2209 .into(),
2210 CommandEntry::Separator,
2211 CommandGroup::new()
2212 .label("Enabled")
2213 .item(CommandItem::new().label("enabled").keywords(["match"]))
2214 .into(),
2215 ]
2216 }
2217
2218 fn assert_first_enabled_row_is_scrolled_into_view(
2219 state: &Entity<CommandState>,
2220 cx: &mut TestAppContext,
2221 ) {
2222 let (selected_row, offset) = state.read_with(cx, |state, _| {
2223 (
2224 state.matched[state.selected_index.unwrap()].row_ix,
2225 state.scroll_handle.base_handle().offset().y,
2226 )
2227 });
2228
2229 assert!(selected_row > 30);
2230 assert!(
2231 offset < px(-900.),
2232 "the list should scroll to the selected row, not row zero ({offset:?})",
2233 );
2234 }
2235
2236 #[gpui::test]
2237 fn first_enabled_selection_resets_scroll_to_its_late_row(cx: &mut TestAppContext) {
2238 cx.update(crate::init);
2239 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2240 state: cx.new(|cx| CommandState::new(window, cx)),
2241 command: Rc::new(|state| {
2242 command_with_entries(state, entries_with_late_first_enabled_item())
2243 }),
2244 });
2245 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2246
2247 cx.run_until_parked();
2248 cx.update(|window, cx| _ = window.draw(cx));
2249 assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2250
2251 cx.update(|window, cx| {
2252 state.update(cx, |state, cx| state.set_query("match", window, cx));
2253 _ = window.draw(cx);
2254 });
2255 assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2256
2257 cx.update(|window, cx| {
2258 harness.update(cx, |_, cx| {
2259 cx.notify();
2260 });
2261 _ = window.draw(cx);
2262 });
2263 assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2264 }
2265
2266 struct HeaderFooterHarness {
2267 state: Entity<CommandState>,
2268 header_calls: Rc<Cell<usize>>,
2269 footer_calls: Rc<Cell<usize>>,
2270 header_matched_count: Rc<Cell<Option<usize>>>,
2271 footer_matched_count: Rc<Cell<Option<usize>>>,
2272 }
2273
2274 impl Render for HeaderFooterHarness {
2275 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2276 let header_calls = self.header_calls.clone();
2277 let header_matched_count = self.header_matched_count.clone();
2278 let footer_calls = self.footer_calls.clone();
2279 let footer_matched_count = self.footer_matched_count.clone();
2280
2281 div().size_full().child(
2282 Command::new(&self.state)
2283 .items([
2284 CommandItem::new().label("Calendar"),
2285 CommandItem::new().label("Calculator"),
2286 ])
2287 .max_h(px(200.))
2288 .header(move |state, _, _| {
2289 header_calls.set(header_calls.get() + 1);
2290 header_matched_count.set(Some(state.matched_count()));
2291 div()
2292 })
2293 .footer(move |state, _, _| {
2294 footer_calls.set(footer_calls.get() + 1);
2295 footer_matched_count.set(Some(state.matched_count()));
2296 div()
2297 }),
2298 )
2299 }
2300 }
2301
2302 struct PaddedHarness {
2303 state: Entity<CommandState>,
2304 }
2305
2306 impl Render for PaddedHarness {
2307 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2308 div().size_full().child(
2309 Command::new(&self.state)
2310 .item(
2311 CommandItem::new()
2312 .label("fixed")
2313 .child(|_, _| div().h(px(32.))),
2314 )
2315 .max_h(px(200.))
2316 .p_4(),
2317 )
2318 }
2319 }
2320
2321 struct WrappingHarness {
2322 state: Entity<CommandState>,
2323 width: Pixels,
2324 no_wrap: bool,
2325 }
2326
2327 impl Render for WrappingHarness {
2328 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2329 div().size_full().child(
2330 div().w(self.width).child(
2331 Command::new(&self.state)
2332 .item(CommandItem::new().label("wrapped").child(|_, _| {
2333 div()
2334 .w_full()
2335 .child("A command row whose content wraps at narrow list widths")
2336 }))
2337 .max_h(px(200.))
2338 .when(self.no_wrap, |this| this.whitespace_nowrap()),
2339 ),
2340 )
2341 }
2342 }
2343
2344 #[gpui::test]
2345 fn wrapping_rows_remeasure_for_the_list_content_width(cx: &mut TestAppContext) {
2346 cx.update(crate::init);
2347
2348 let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2349 state: cx.new(|cx| CommandState::new(window, cx)),
2350 width: px(360.),
2351 no_wrap: false,
2352 });
2353
2354 cx.run_until_parked();
2355 cx.update(|window, cx| _ = window.draw(cx));
2356 cx.run_until_parked();
2357 cx.update(|window, cx| _ = window.draw(cx));
2358
2359 let wide = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2360
2361 cx.update(|_, cx| {
2362 harness.update(cx, |harness, cx| {
2363 harness.width = px(120.);
2364 cx.notify();
2365 })
2366 });
2367 cx.run_until_parked();
2368 cx.update(|window, cx| _ = window.draw(cx));
2369 cx.run_until_parked();
2370 cx.update(|window, cx| _ = window.draw(cx));
2371 let narrow = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2372
2373 assert!(
2374 narrow > wide,
2375 "the narrow list should cache a taller wrapped row ({narrow:?} vs {wide:?})",
2376 );
2377 }
2378
2379 #[gpui::test]
2380 fn wrapping_rows_remeasure_when_rem_size_changes(cx: &mut TestAppContext) {
2381 cx.update(crate::init);
2382
2383 let (harness, cx) = cx.add_window_view(|window, cx| {
2384 window.set_rem_size(px(20.));
2385 WrappingHarness {
2386 state: cx.new(|cx| CommandState::new(window, cx)),
2387 width: px(160.),
2388 no_wrap: false,
2389 }
2390 });
2391
2392 cx.run_until_parked();
2393 cx.update(|window, cx| _ = window.draw(cx));
2394 cx.run_until_parked();
2395 cx.update(|window, cx| _ = window.draw(cx));
2396 let smaller_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2397
2398 cx.update(|window, cx| {
2399 window.set_rem_size(px(28.));
2400 _ = window.draw(cx);
2401 });
2402 cx.run_until_parked();
2403 cx.update(|window, cx| _ = window.draw(cx));
2404 let larger_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2405
2406 assert!(
2407 larger_rem > smaller_rem,
2408 "a larger rem should remeasure the fixed-width wrapped row ({larger_rem:?} vs {smaller_rem:?})",
2409 );
2410 }
2411
2412 #[gpui::test]
2413 fn wrapping_rows_remeasure_when_inherited_typography_changes(cx: &mut TestAppContext) {
2414 cx.update(crate::init);
2415
2416 let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2417 state: cx.new(|cx| CommandState::new(window, cx)),
2418 width: px(160.),
2419 no_wrap: false,
2420 });
2421
2422 cx.run_until_parked();
2423 cx.update(|window, cx| _ = window.draw(cx));
2424 cx.run_until_parked();
2425 cx.update(|window, cx| _ = window.draw(cx));
2426 let wrapped_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2427
2428 cx.update(|window, cx| {
2429 harness.update(cx, |harness, cx| {
2430 harness.no_wrap = true;
2431 cx.notify();
2432 });
2433 _ = window.draw(cx);
2434 });
2435 cx.run_until_parked();
2436 cx.update(|window, cx| _ = window.draw(cx));
2437 let no_wrap_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2438 assert!(
2439 no_wrap_height < wrapped_height,
2440 "a changed inherited typography should remeasure the fixed-width row ({no_wrap_height:?} vs {wrapped_height:?})",
2441 );
2442 }
2443
2444 #[gpui::test]
2445 fn outer_command_padding_does_not_inflate_measured_row_heights(cx: &mut TestAppContext) {
2446 cx.update(crate::init);
2447
2448 let (harness, cx) = cx.add_window_view(|window, cx| PaddedHarness {
2449 state: cx.new(|cx| CommandState::new(window, cx)),
2450 });
2451
2452 cx.run_until_parked();
2453 cx.update(|window, cx| _ = window.draw(cx));
2454 cx.run_until_parked();
2455 cx.update(|window, cx| _ = window.draw(cx));
2456 let height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2457
2458 assert_eq!(height, px(44.));
2459 }
2460
2461 #[gpui::test]
2462 fn custom_rows_keep_independent_heights(cx: &mut TestAppContext) {
2463 cx.update(crate::init);
2464
2465 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2466 state: cx.new(|cx| CommandState::new(window, cx)),
2467 command: Rc::new(|state| {
2468 Command::new(state)
2469 .group(
2470 CommandGroup::new().label("Short").item(
2471 CommandItem::new()
2472 .label("short")
2473 .child(|_, _| div().h(px(32.))),
2474 ),
2475 )
2476 .separator()
2477 .group(
2478 CommandGroup::new().label("Tall").item(
2479 CommandItem::new()
2480 .label("tall")
2481 .child(|_, _| div().h(px(72.))),
2482 ),
2483 )
2484 }),
2485 });
2486
2487 cx.run_until_parked();
2488 cx.update(|window, cx| _ = window.draw(cx));
2489 let row_sizes = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes.clone());
2490
2491 assert_eq!(row_sizes.len(), 5);
2492 assert!(row_sizes[0].height > px(0.));
2493 assert_eq!(row_sizes[1].height, px(44.));
2494 assert_eq!(row_sizes[2].height, px(SEPARATOR_ROW_HEIGHT));
2495 assert!(row_sizes[3].height > px(0.));
2496 assert_eq!(row_sizes[4].height, px(84.));
2497 }
2498
2499 #[gpui::test]
2500 fn reinstalling_a_model_preserves_selection_by_index_path_and_remeasures_rows(
2501 cx: &mut TestAppContext,
2502 ) {
2503 cx.update(crate::init);
2504 let reversed = Rc::new(Cell::new(false));
2505 let reversed_for_render = reversed.clone();
2506 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2507 state: cx.new(|cx| CommandState::new(window, cx)),
2508 command: Rc::new(move |state| {
2509 if reversed_for_render.get() {
2510 Command::new(state)
2511 .item(
2512 CommandItem::new()
2513 .label("beta")
2514 .child(|_, _| div().h(px(72.))),
2515 )
2516 .item(
2517 CommandItem::new()
2518 .label("alpha")
2519 .child(|_, _| div().h(px(32.))),
2520 )
2521 } else {
2522 Command::new(state)
2523 .item(
2524 CommandItem::new()
2525 .label("alpha")
2526 .child(|_, _| div().h(px(32.))),
2527 )
2528 .item(
2529 CommandItem::new()
2530 .label("beta")
2531 .child(|_, _| div().h(px(72.))),
2532 )
2533 }
2534 }),
2535 });
2536 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2537
2538 cx.run_until_parked();
2539 cx.update(|window, cx| _ = window.draw(cx));
2540 cx.update(|window, cx| {
2541 state.update(cx, |state, cx| state.select_by(1, window, cx));
2542 });
2543 assert_eq!(
2544 state.read_with(cx, |state, _| state.selected_index()),
2545 Some(IndexPath::new(1).section(0)),
2546 );
2547
2548 reversed.set(true);
2549 cx.update(|window, cx| {
2550 harness.update(cx, |_, cx| cx.notify());
2551 _ = window.draw(cx);
2552 });
2553
2554 let (selected_matched_index, selected_index, row_sizes) =
2555 state.read_with(cx, |state, _| {
2556 (
2557 state.selected_index,
2558 state.selected_index(),
2559 state.row_sizes.clone(),
2560 )
2561 });
2562 assert_eq!(selected_matched_index, Some(1));
2563 assert_eq!(selected_index, Some(IndexPath::new(1).section(0)));
2564 assert_eq!(row_sizes[0].height, px(84.));
2565 assert_eq!(row_sizes[1].height, px(44.));
2566 }
2567
2568 #[gpui::test]
2569 fn a_state_redraw_reuses_the_installed_custom_row_measurement(cx: &mut TestAppContext) {
2570 cx.update(crate::init);
2571 let renders = Rc::new(Cell::new(0));
2572 let count = renders.clone();
2573 let cx = cx.add_empty_window();
2574 let state = cx.update(|window, cx| {
2575 cx.new(|cx| {
2576 command_state(
2577 window,
2578 cx,
2579 [CommandEntry::Item(
2580 CommandItem::new().label("custom").child(move |_, _| {
2581 count.set(count.get() + 1);
2582 div().child("Custom")
2583 }),
2584 )],
2585 )
2586 })
2587 });
2588
2589 let first_state = state.clone();
2590 cx.draw(
2591 gpui::point(px(0.), px(0.)),
2592 gpui::AvailableSpace::min_size(),
2593 move |_, _| first_state.into_any_element(),
2594 );
2595 let settled_state = state.clone();
2596 cx.draw(
2597 gpui::point(px(0.), px(0.)),
2598 gpui::AvailableSpace::min_size(),
2599 move |_, _| settled_state.into_any_element(),
2600 );
2601 let after_first_draw = renders.get();
2602 cx.draw(
2603 gpui::point(px(0.), px(0.)),
2604 gpui::AvailableSpace::min_size(),
2605 move |_, _| state.into_any_element(),
2606 );
2607
2608 assert_eq!(renders.get() - after_first_draw, 2);
2609 }
2610
2611 #[gpui::test]
2612 fn moving_past_the_visible_rows_scrolls_the_list(cx: &mut TestAppContext) {
2613 cx.update(crate::init);
2614
2615 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2616 state: cx.new(|cx| CommandState::new(window, cx)),
2617 command: Rc::new(|state| {
2618 Command::new(state)
2619 .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2620 }),
2621 });
2622
2623 cx.run_until_parked();
2624 cx.update(|window, cx| _ = window.draw(cx));
2625
2626 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2627 assert_eq!(
2628 state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2629 px(0.),
2630 );
2631
2632 cx.update(|window, cx| {
2635 state.update(cx, |state, cx| {
2636 for _ in 0..49 {
2637 state.select_by(1, window, cx);
2638 }
2639 })
2640 });
2641 cx.update(|window, cx| _ = window.draw(cx));
2642
2643 assert_eq!(
2644 state.read_with(cx, |state, _| state.selected_index()),
2645 Some(IndexPath::new(49).section(0))
2646 );
2647 assert!(
2648 state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y) < px(0.),
2649 "selecting the last row should have scrolled the list",
2650 );
2651 }
2652
2653 #[gpui::test]
2654 fn a_reinstalled_model_does_not_scroll_a_preserved_selection(cx: &mut TestAppContext) {
2655 cx.update(crate::init);
2656
2657 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2658 state: cx.new(|cx| CommandState::new(window, cx)),
2659 command: Rc::new(|state| {
2660 Command::new(state)
2661 .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2662 }),
2663 });
2664
2665 cx.run_until_parked();
2666 cx.update(|window, cx| _ = window.draw(cx));
2667
2668 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2669
2670 cx.update(|window, cx| {
2675 state.update(cx, |state, cx| state.select(10, window, cx));
2676 });
2677 cx.update(|window, cx| _ = window.draw(cx));
2678
2679 assert_eq!(
2680 state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2681 px(0.),
2682 "reinstalling the model must keep the scroll position",
2683 );
2684 }
2685}