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 && !self.loading, |this| this.p_1())
973 .on_prepaint({
974 let measure_state = command_state.clone();
975 move |bounds, window, cx| {
976 measure_state.update(cx, |state, cx| {
977 let text_style = window.text_style();
982 state.set_list_measurement_key(
983 ListMeasurementKey {
984 content_width: (bounds.size.width
985 - window.rem_size() * 0.5)
986 .max(px(0.)),
987 rem_size: window.rem_size(),
988 line_height: window.line_height(),
989 text_shape: TextShapeKey {
990 font_family: text_style.font_family,
991 font_features: text_style.font_features,
992 font_fallbacks: text_style.font_fallbacks,
993 font_size: text_style.font_size,
994 font_weight: text_style.font_weight,
995 font_style: text_style.font_style,
996 white_space: text_style.white_space,
997 text_overflow: text_style.text_overflow,
998 line_clamp: text_style.line_clamp,
999 },
1000 },
1001 window,
1002 cx,
1003 )
1004 })
1005 }
1006 })
1007 .max_h(self.options.max_h)
1008 .overflow_hidden()
1009 .when(rows_count == 0 && !self.loading, |this| {
1012 this.child(self.render_empty(window, cx))
1013 })
1014 .when(rows_count > 0, |this| {
1015 this.child(
1016 v_virtual_list(
1017 command_state.clone(),
1018 "command-list",
1019 row_sizes,
1020 move |this, visible_range, window, cx| {
1021 visible_range
1022 .map(|row_ix| this.render_visible_row(row_ix, window, cx))
1023 .collect::<Vec<_>>()
1024 },
1025 )
1026 .p_1()
1031 .with_sizing_behavior(ListSizingBehavior::Infer)
1032 .track_scroll(&self.scroll_handle)
1033 .map(|list| {
1034 gpui_base::ScrollBounce::new(
1035 "command-scroll-bounce",
1036 &self.scroll_handle,
1037 list,
1038 )
1039 .when_some(self.options.scroll_bounce, |bounce, on| {
1040 bounce.enabled(on)
1041 })
1042 }),
1043 )
1044 .child(Scrollbar::vertical(&self.scroll_handle))
1045 }),
1046 )
1047 .when_some(self.options.footer.as_ref(), |this, footer| {
1048 this.child(footer(self, window, cx))
1049 })
1050 }
1051}
1052
1053#[cfg(test)]
1056mod tests {
1057 use std::{
1058 cell::{Cell, RefCell},
1059 rc::Rc,
1060 };
1061
1062 use gpui::{
1063 AppContext as _, AvailableSpace, Entity, InteractiveElement as _, IntoElement, KeyBinding,
1064 Modifiers, ParentElement as _, Pixels, Render, Styled as _, TestAppContext, Window,
1065 actions, div, point, prelude::FluentBuilder as _, px,
1066 };
1067
1068 use super::{CONTEXT, CommandModel, CommandRow, CommandState, SEPARATOR_ROW_HEIGHT};
1069 use crate::{
1070 Disableable as _, Icon, IconName, IndexPath,
1071 actions::{Cancel, Confirm, SelectDown},
1072 command::{Command, CommandEntry, CommandGroup, CommandItem},
1073 };
1074
1075 actions!(
1076 command_test,
1077 [GlobalTestItem, OpenTestItem, RemovePaletteTestItem]
1078 );
1079
1080 struct CommandActionsHarness {
1081 state: Entity<CommandState>,
1082 events: Rc<RefCell<Vec<String>>>,
1083 }
1084
1085 impl Render for CommandActionsHarness {
1086 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1087 let action_events = self.events.clone();
1088 let propagated_cancel_events = self.events.clone();
1089 let query_events = self.events.clone();
1090 let select_events = self.events.clone();
1091 let confirm_events = self.events.clone();
1092 let cancel_events = self.events.clone();
1093
1094 div()
1095 .size_full()
1096 .on_action(move |_: &OpenTestItem, _, _| {
1097 action_events.borrow_mut().push("action".into());
1098 })
1099 .on_action(move |_: &Cancel, _, _| {
1100 propagated_cancel_events
1101 .borrow_mut()
1102 .push("propagated_cancel".into());
1103 })
1104 .child(
1105 Command::new(&self.state)
1106 .item(
1107 CommandItem::new()
1108 .label("Item")
1109 .keywords(["needle"])
1110 .action(Box::new(OpenTestItem)),
1111 )
1112 .item(CommandItem::new().label("Item"))
1113 .item(
1114 CommandItem::new()
1115 .label("Item")
1116 .action(Box::new(GlobalTestItem)),
1117 )
1118 .on_query(move |query, _, _| {
1119 query_events.borrow_mut().push(format!("query:{query}"));
1120 })
1121 .on_select(move |index, _, _| {
1122 select_events
1123 .borrow_mut()
1124 .push(format!("select:{}:{}", index.section, index.row));
1125 })
1126 .on_confirm(move |index, _, _| {
1127 confirm_events
1128 .borrow_mut()
1129 .push(format!("confirm:{}:{}", index.section, index.row));
1130 })
1131 .on_cancel(move |_, _| {
1132 cancel_events.borrow_mut().push("cancel".into());
1133 }),
1134 )
1135 }
1136 }
1137
1138 struct ReentrantCallbackHarness {
1139 state: Entity<CommandState>,
1140 events: Vec<String>,
1141 }
1142
1143 impl Render for ReentrantCallbackHarness {
1144 fn render(&mut self, _: &mut Window, cx: &mut gpui::Context<Self>) -> impl IntoElement {
1145 let select_owner = cx.weak_entity();
1146 let query_owner = cx.weak_entity();
1147 let confirm_owner = cx.weak_entity();
1148
1149 Command::new(&self.state)
1150 .item(CommandItem::new().label("alpha"))
1151 .item(CommandItem::new().label("beta"))
1152 .on_select(move |index, _, cx| {
1153 _ = select_owner.update(cx, |harness, cx| {
1154 assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1155 harness
1156 .events
1157 .push(format!("select:{}:{}", index.section, index.row));
1158 });
1159 })
1160 .on_query(move |query, _, cx| {
1161 _ = query_owner.update(cx, |harness, cx| {
1162 assert_eq!(harness.state.read(cx).query(cx).as_ref(), query);
1163 harness.events.push(format!("query:{query}"));
1164 });
1165 })
1166 .on_confirm(move |index, _, cx| {
1167 _ = confirm_owner.update(cx, |harness, cx| {
1168 assert_eq!(harness.state.read(cx).selected_index(), Some(index));
1169 harness
1170 .events
1171 .push(format!("confirm:{}:{}", index.section, index.row));
1172 });
1173 })
1174 }
1175 }
1176
1177 #[gpui::test]
1178 fn query_and_selection_callbacks_run_after_the_state_lease_in_defined_order(
1179 cx: &mut TestAppContext,
1180 ) {
1181 cx.update(crate::init);
1182 let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1183 state: cx.new(|cx| CommandState::new(window, cx)),
1184 events: Vec::new(),
1185 });
1186 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1187
1188 cx.run_until_parked();
1189 cx.update(|window, cx| {
1190 _ = window.draw(cx);
1191 state.update(cx, |state, cx| {
1192 state.selected_index = Some(1);
1193 state.set_query("alpha", window, cx);
1194 });
1195 });
1196
1197 assert_eq!(
1198 harness.read_with(cx, |harness, _| harness.events.clone()),
1199 ["select:0:0", "query:alpha"]
1200 );
1201 }
1202
1203 #[gpui::test]
1204 fn actionless_confirm_callback_runs_after_the_state_lease(cx: &mut TestAppContext) {
1205 cx.update(crate::init);
1206 let (harness, cx) = cx.add_window_view(|window, cx| ReentrantCallbackHarness {
1207 state: cx.new(|cx| CommandState::new(window, cx)),
1208 events: Vec::new(),
1209 });
1210 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1211
1212 cx.run_until_parked();
1213 cx.update(|window, cx| {
1214 _ = window.draw(cx);
1215 state.update(cx, |state, cx| state.confirm(0, window, cx));
1216 });
1217
1218 assert_eq!(
1219 harness.read_with(cx, |harness, _| harness.events.clone()),
1220 ["confirm:0:0"]
1221 );
1222 }
1223
1224 struct CommandItemWidthHarness {
1225 state: Entity<CommandState>,
1226 matched_ix: usize,
1227 width: Rc<Cell<Option<Pixels>>>,
1228 }
1229
1230 impl Render for CommandItemWidthHarness {
1231 fn render(
1232 &mut self,
1233 window: &mut Window,
1234 cx: &mut gpui::Context<Self>,
1235 ) -> impl IntoElement {
1236 let width = self.width.clone();
1237 let item = self.state.update(cx, |state, cx| {
1238 state.render_item(self.matched_ix, window, cx)
1239 });
1240
1241 div()
1242 .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
1243 .child(item)
1244 }
1245 }
1246
1247 #[gpui::test]
1248 fn action_that_removes_command_state_still_confirms_after_dispatch(cx: &mut TestAppContext) {
1249 cx.update(crate::init);
1250 let events = Rc::new(RefCell::new(Vec::new()));
1251 let state_owner: Rc<RefCell<Option<Entity<CommandState>>>> = Rc::new(RefCell::new(None));
1252 let action_events = events.clone();
1253 let action_state_owner = state_owner.clone();
1254 cx.update(|cx| {
1255 cx.on_action(move |_: &RemovePaletteTestItem, _| {
1256 action_events.borrow_mut().push("action".into());
1257 action_state_owner.borrow_mut().take();
1258 });
1259 });
1260 let cx = cx.add_empty_window();
1261 cx.update(|window, cx| {
1262 let confirm_events = events.clone();
1263 let state = cx.new(|cx| {
1264 let mut state = CommandState::new(window, cx);
1265 state.install_model(
1266 CommandModel {
1267 entries: vec![CommandEntry::Item(
1268 CommandItem::new()
1269 .label("removed")
1270 .action(Box::new(RemovePaletteTestItem)),
1271 )],
1272 searchable: false,
1273 on_confirm: Some(Rc::new(move |index, _, _| {
1274 confirm_events
1275 .borrow_mut()
1276 .push(format!("confirm:{}:{}", index.section, index.row));
1277 })),
1278 ..CommandModel::default()
1279 },
1280 cx,
1281 );
1282 state
1283 });
1284 *state_owner.borrow_mut() = Some(state.clone());
1285 state.update(cx, |state, cx| state.confirm(0, window, cx));
1286 });
1287 cx.run_until_parked();
1288
1289 assert!(state_owner.borrow().is_none());
1290 assert_eq!(events.borrow().as_slice(), ["action", "confirm:0:0"]);
1291 }
1292
1293 #[gpui::test]
1294 fn command_actions_and_callbacks_follow_defined_order(cx: &mut TestAppContext) {
1295 cx.update(|cx| {
1296 crate::init(cx);
1297 cx.bind_keys([
1298 KeyBinding::new("ctrl-o", OpenTestItem, Some(CONTEXT)),
1299 KeyBinding::new("ctrl-g", GlobalTestItem, None),
1300 ]);
1301 });
1302 let events = Rc::new(RefCell::new(Vec::new()));
1303 let (harness, cx) = cx.add_window_view(|window, cx| CommandActionsHarness {
1304 state: cx.new(|cx| CommandState::new(window, cx)),
1305 events: events.clone(),
1306 });
1307 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1308
1309 cx.run_until_parked();
1310 cx.update(|window, cx| {
1311 _ = window.draw(cx);
1312 state.update(cx, |state, cx| state.focus(window, cx));
1313 _ = window.draw(cx);
1314 });
1315
1316 let action_width = Rc::new(Cell::new(None));
1317 let plain_width = Rc::new(Cell::new(None));
1318 let global_width = Rc::new(Cell::new(None));
1319 let (action_probe, plain_probe, global_probe) = cx.update(|_, cx| {
1320 (
1321 cx.new(|_| CommandItemWidthHarness {
1322 state: state.clone(),
1323 matched_ix: 0,
1324 width: action_width.clone(),
1325 }),
1326 cx.new(|_| CommandItemWidthHarness {
1327 state: state.clone(),
1328 matched_ix: 1,
1329 width: plain_width.clone(),
1330 }),
1331 cx.new(|_| CommandItemWidthHarness {
1332 state: state.clone(),
1333 matched_ix: 2,
1334 width: global_width.clone(),
1335 }),
1336 )
1337 });
1338 cx.draw(
1339 point(px(0.), px(0.)),
1340 AvailableSpace::min_size(),
1341 move |_, _| action_probe.into_any_element(),
1342 );
1343 cx.draw(
1344 point(px(0.), px(0.)),
1345 AvailableSpace::min_size(),
1346 move |_, _| plain_probe.into_any_element(),
1347 );
1348 cx.draw(
1349 point(px(0.), px(0.)),
1350 AvailableSpace::min_size(),
1351 move |_, _| global_probe.into_any_element(),
1352 );
1353 let action_width = action_width.get().unwrap();
1354 let plain_width = plain_width.get().unwrap();
1355 let global_width = global_width.get().unwrap();
1356 assert!(
1357 action_width > plain_width,
1358 "the scoped Action binding should add a visible Kbd ({action_width:?} vs {plain_width:?})",
1359 );
1360 assert!(
1361 global_width > plain_width,
1362 "the app-level fallback binding should add a visible Kbd ({global_width:?} vs {plain_width:?})",
1363 );
1364
1365 cx.update(|window, cx| {
1366 state.update(cx, |state, cx| {
1367 state.set_query("needle", window, cx);
1368 state.set_query("needle", window, cx);
1369 state.set_query("", window, cx);
1370 });
1371 window.dispatch_action(Box::new(SelectDown), cx);
1372 window.dispatch_action(Box::new(crate::actions::SelectUp), cx);
1373 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1374 });
1375 cx.run_until_parked();
1376
1377 assert_eq!(
1378 events.borrow().as_slice(),
1379 [
1380 "query:needle",
1381 "query:",
1382 "select:0:1",
1383 "select:0:0",
1384 "action",
1385 "confirm:0:0",
1386 ]
1387 );
1388
1389 cx.simulate_click(point(px(20.), px(52.)), Modifiers::default());
1390 cx.run_until_parked();
1391 cx.update(|window, cx| window.dispatch_action(Box::new(Cancel), cx));
1392 cx.run_until_parked();
1393
1394 assert_eq!(
1395 events.borrow().as_slice(),
1396 [
1397 "query:needle",
1398 "query:",
1399 "select:0:1",
1400 "select:0:0",
1401 "action",
1402 "confirm:0:0",
1403 "action",
1404 "confirm:0:0",
1405 "cancel",
1406 "propagated_cancel",
1407 ]
1408 );
1409 }
1410
1411 struct CommandOwnedEntriesHarness {
1412 state: Entity<CommandState>,
1413 }
1414
1415 impl Render for CommandOwnedEntriesHarness {
1416 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
1417 Command::new(&self.state)
1418 .searchable(false)
1419 .item(CommandItem::new().label("alpha"))
1420 .group(
1421 CommandGroup::new()
1422 .label("Settings")
1423 .item(CommandItem::new().label("beta")),
1424 )
1425 .separator()
1426 .item(
1427 CommandItem::new()
1428 .label("custom")
1429 .child(|_, _| div().h(px(72.)).child("Custom")),
1430 )
1431 }
1432 }
1433
1434 #[gpui::test]
1435 fn command_owns_entries_and_lazy_item_content(cx: &mut TestAppContext) {
1436 cx.update(crate::init);
1437 let (harness, cx) = cx.add_window_view(|window, cx| CommandOwnedEntriesHarness {
1438 state: cx.new(|cx| CommandState::new(window, cx)),
1439 });
1440
1441 cx.run_until_parked();
1442 cx.update(|window, cx| _ = window.draw(cx));
1443
1444 let (labels, rows, row_sizes) = cx.update(|_, cx| {
1445 let state = harness.read(cx).state.read(cx);
1446 (
1447 (0..state.matched_count())
1448 .map(|matched_ix| {
1449 state
1450 .item_at(matched_ix)
1451 .unwrap()
1452 .label_text()
1453 .unwrap()
1454 .clone()
1455 })
1456 .collect::<Vec<_>>(),
1457 state.rows.clone(),
1458 state.row_sizes.clone(),
1459 )
1460 });
1461
1462 assert_eq!(labels, ["alpha", "beta", "custom"]);
1463 assert!(matches!(
1464 rows.as_slice(),
1465 [
1466 CommandRow::Item(_),
1467 CommandRow::Heading(heading),
1468 CommandRow::Item(_),
1469 CommandRow::Separator,
1470 CommandRow::Item(_),
1471 ] if heading == "Settings"
1472 ));
1473 assert_eq!(row_sizes[4].height, px(84.));
1474 }
1475
1476 fn command_with_entries(
1477 state: &Entity<CommandState>,
1478 entries: impl IntoIterator<Item = CommandEntry>,
1479 ) -> Command {
1480 entries
1481 .into_iter()
1482 .fold(Command::new(state), |command, entry| match entry {
1483 CommandEntry::Item(item) => command.item(item),
1484 CommandEntry::Group(group) => command.group(group),
1485 CommandEntry::Separator => command.separator(),
1486 })
1487 }
1488
1489 fn command_state(
1490 window: &mut Window,
1491 cx: &mut gpui::Context<CommandState>,
1492 entries: impl IntoIterator<Item = CommandEntry>,
1493 ) -> CommandState {
1494 let mut state = CommandState::new(window, cx);
1495 state.install_model(
1496 CommandModel {
1497 entries: entries.into_iter().collect(),
1498 ..CommandModel::default()
1499 },
1500 cx,
1501 );
1502 state
1503 }
1504
1505 fn command_state_with_options(
1506 window: &mut Window,
1507 cx: &mut gpui::Context<CommandState>,
1508 entries: impl IntoIterator<Item = CommandEntry>,
1509 searchable: bool,
1510 ) -> CommandState {
1511 let mut state = CommandState::new(window, cx);
1512 state.install_model(
1513 CommandModel {
1514 entries: entries.into_iter().collect(),
1515 searchable,
1516 ..CommandModel::default()
1517 },
1518 cx,
1519 );
1520 state
1521 }
1522
1523 fn suggestion_entries() -> Vec<CommandEntry> {
1524 vec![
1525 CommandGroup::new()
1526 .label("Suggestions")
1527 .item(CommandItem::new().label("Calendar"))
1528 .item(CommandItem::new().label("Search Emoji"))
1529 .item(CommandItem::new().label("Calculator").disabled(true))
1530 .into(),
1531 CommandEntry::Separator,
1532 CommandGroup::new()
1533 .label("Settings")
1534 .item(CommandItem::new().label("Profile"))
1535 .item(CommandItem::new().label("Billing"))
1536 .into(),
1537 ]
1538 }
1539
1540 #[gpui::test]
1541 fn query_hides_the_groups_that_have_no_match(cx: &mut TestAppContext) {
1542 cx.update(crate::init);
1543 let cx = cx.add_empty_window();
1544
1545 cx.update(|window, cx| {
1546 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1547
1548 state.update(cx, |state, cx| {
1549 state.update_matches(cx);
1550 assert_eq!(state.matched_count(), 5);
1551 assert_eq!(
1552 state
1553 .rows
1554 .iter()
1555 .filter(|row| matches!(row, CommandRow::Heading(_)))
1556 .count(),
1557 2,
1558 );
1559 assert_eq!(
1560 state
1561 .rows
1562 .iter()
1563 .filter(|row| matches!(row, CommandRow::Separator))
1564 .count(),
1565 1,
1566 );
1567
1568 state.set_query("Bil", window, cx);
1571 state.update_matches(cx);
1572
1573 assert_eq!(state.matched_count(), 1);
1574 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1575 assert_eq!(
1576 state
1577 .rows
1578 .iter()
1579 .filter(|row| matches!(row, CommandRow::Separator))
1580 .count(),
1581 0,
1582 );
1583 assert!(matches!(state.rows.first(), Some(CommandRow::Heading(_))));
1584 });
1585 });
1586 }
1587
1588 fn custom_entries() -> Vec<CommandEntry> {
1589 vec![CommandEntry::Item(
1590 CommandItem::new().label("Custom").child(|_, _| div()),
1591 )]
1592 }
1593
1594 #[gpui::test]
1595 fn reinstalling_an_unchanged_model_keeps_the_measured_rows(cx: &mut TestAppContext) {
1596 cx.update(crate::init);
1597 let cx = cx.add_empty_window();
1598
1599 cx.update(|window, cx| {
1600 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1601
1602 state.update(cx, |state, cx| {
1603 state.needs_measure = false;
1605 state.install_model(
1606 CommandModel {
1607 entries: suggestion_entries(),
1608 ..CommandModel::default()
1609 },
1610 cx,
1611 );
1612 assert!(!state.needs_measure);
1613 assert_eq!(state.matched_count(), 5);
1614
1615 state.install_model(
1618 CommandModel {
1619 entries: custom_entries(),
1620 ..CommandModel::default()
1621 },
1622 cx,
1623 );
1624 assert!(state.needs_measure);
1625 assert_eq!(state.matched_count(), 1);
1626
1627 state.needs_measure = false;
1630 let entries = state.model.entries.clone();
1631 state.install_model(
1632 CommandModel {
1633 entries,
1634 ..CommandModel::default()
1635 },
1636 cx,
1637 );
1638 assert!(state.needs_measure);
1639
1640 let item = || CommandItem::new().label("Renamed");
1643 let icon = || Icon::new(IconName::Check);
1644 for entries in [
1645 vec![CommandEntry::Item(item())],
1646 vec![CommandEntry::Item(item().icon(icon()))],
1647 vec![CommandEntry::Item(item().icon(icon().ml_2()))],
1648 vec![CommandEntry::Item(
1649 item().icon(icon().ml_2()).disabled(true),
1650 )],
1651 ] {
1652 state.needs_measure = false;
1653 state.install_model(
1654 CommandModel {
1655 entries,
1656 ..CommandModel::default()
1657 },
1658 cx,
1659 );
1660 assert!(state.needs_measure);
1661 }
1662 assert_eq!(state.selected_index(), None);
1663 });
1664 });
1665 }
1666
1667 #[gpui::test]
1668 fn a_query_that_matches_nothing_leaves_no_rows(cx: &mut TestAppContext) {
1669 cx.update(crate::init);
1670 let cx = cx.add_empty_window();
1671
1672 cx.update(|window, cx| {
1673 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1674
1675 state.update(cx, |state, cx| {
1676 state.set_query("zzz", window, cx);
1677 state.update_matches(cx);
1678
1679 assert_eq!(state.matched_count(), 0);
1680 assert!(state.rows.is_empty());
1681 assert_eq!(state.selected_index(), None);
1682 });
1683 });
1684 }
1685
1686 #[gpui::test]
1687 fn filterable_off_keeps_every_item_and_resets_the_highlight(cx: &mut TestAppContext) {
1688 cx.update(crate::init);
1689 let cx = cx.add_empty_window();
1690
1691 cx.update(|window, cx| {
1692 let state = cx.new(|cx| {
1693 let mut state = CommandState::new(window, cx);
1694 state.install_model(
1695 CommandModel {
1696 entries: suggestion_entries(),
1697 filterable: false,
1698 ..CommandModel::default()
1699 },
1700 cx,
1701 );
1702 state
1703 });
1704
1705 state.update(cx, |state, cx| {
1706 state.set_selected_index(Some(IndexPath::new(1).section(1)), window, cx);
1707
1708 state.set_query("Bil", window, cx);
1712
1713 assert_eq!(state.matched_count(), 5);
1714 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1715 });
1716 });
1717 }
1718
1719 #[gpui::test]
1720 fn keywords_match_when_the_label_does_not(cx: &mut TestAppContext) {
1721 cx.update(crate::init);
1722 let cx = cx.add_empty_window();
1723
1724 cx.update(|window, cx| {
1725 let state = cx.new(|cx| {
1726 command_state(
1727 window,
1728 cx,
1729 [CommandEntry::Item(
1730 CommandItem::new().label("Profile").keywords(["account"]),
1731 )],
1732 )
1733 });
1734
1735 state.update(cx, |state, cx| {
1736 state.set_query("account", window, cx);
1737 state.update_matches(cx);
1738
1739 assert_eq!(state.matched_count(), 1);
1740 });
1741 });
1742 }
1743
1744 #[gpui::test]
1745 fn non_searchable_command_keeps_every_item(cx: &mut TestAppContext) {
1746 cx.update(crate::init);
1747 let cx = cx.add_empty_window();
1748 cx.update(|window, cx| {
1749 let state = cx.new(|cx| {
1750 command_state_with_options(
1751 window,
1752 cx,
1753 [
1754 CommandEntry::Item(CommandItem::new().label("alpha")),
1755 CommandEntry::Item(CommandItem::new().label("beta")),
1756 ],
1757 false,
1758 )
1759 });
1760 state.update(cx, |state, cx| {
1761 state.set_query("missing", window, cx);
1762 assert_eq!(state.matched_count(), 2);
1763 });
1764 });
1765 }
1766
1767 #[gpui::test]
1768 fn non_searchable_command_uses_frame_focus(cx: &mut TestAppContext) {
1769 cx.update(crate::init);
1770 let confirmed = Rc::new(RefCell::new(None));
1771 let confirmed_for_render = confirmed.clone();
1772 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1773 state: cx.new(|cx| CommandState::new(window, cx)),
1774 command: Rc::new(move |state| {
1775 let confirmed = confirmed_for_render.clone();
1776 Command::new(state)
1777 .searchable(false)
1778 .item(CommandItem::new().label("alpha"))
1779 .item(CommandItem::new().label("beta"))
1780 .on_confirm(move |index_path, _, _| {
1781 *confirmed.borrow_mut() = Some(index_path);
1782 })
1783 }),
1784 });
1785 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1786
1787 cx.run_until_parked();
1788 cx.update(|window, cx| _ = window.draw(cx));
1789 cx.update(|window, cx| {
1790 state.update(cx, |state, cx| state.focus(window, cx));
1791 assert!(state.read(cx).focus_handle.is_focused(window));
1792 window.dispatch_action(Box::new(SelectDown), cx);
1793 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1794 });
1795
1796 assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1797 }
1798
1799 #[gpui::test]
1800 fn filtered_ungrouped_item_keeps_its_input_row(cx: &mut TestAppContext) {
1801 cx.update(crate::init);
1802 let confirmed = Rc::new(RefCell::new(None));
1803 let confirmed_for_render = confirmed.clone();
1804 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1805 state: cx.new(|cx| CommandState::new(window, cx)),
1806 command: Rc::new(move |state| {
1807 let confirmed = confirmed_for_render.clone();
1808 Command::new(state)
1809 .items([
1810 CommandItem::new().label("alpha"),
1811 CommandItem::new().label("beta"),
1812 CommandItem::new().label("gamma"),
1813 ])
1814 .on_confirm(move |index_path, _, _| {
1815 *confirmed.borrow_mut() = Some(index_path);
1816 })
1817 }),
1818 });
1819 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1820
1821 cx.run_until_parked();
1822 cx.update(|window, cx| {
1823 state.update(cx, |state, cx| {
1824 state.set_query("gamma", window, cx);
1825 state.focus(window, cx);
1826 });
1827 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1828 });
1829
1830 assert_eq!(*confirmed.borrow(), Some(IndexPath::new(2).section(0)));
1831 }
1832
1833 #[gpui::test]
1834 fn initially_rendered_disabled_first_item_selects_and_confirms_the_first_enabled_item(
1835 cx: &mut TestAppContext,
1836 ) {
1837 cx.update(crate::init);
1838 let confirmed = Rc::new(RefCell::new(None));
1839 let confirmed_for_render = confirmed.clone();
1840 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1841 state: cx.new(|cx| CommandState::new(window, cx)),
1842 command: Rc::new(move |state| {
1843 let confirmed = confirmed_for_render.clone();
1844 Command::new(state)
1845 .item(CommandItem::new().label("disabled").disabled(true))
1846 .item(CommandItem::new().label("enabled"))
1847 .on_confirm(move |index_path, _, _| {
1848 *confirmed.borrow_mut() = Some(index_path);
1849 })
1850 }),
1851 });
1852 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1853
1854 cx.run_until_parked();
1855 cx.update(|window, cx| _ = window.draw(cx));
1856 cx.update(|window, cx| {
1857 state.update(cx, |state, cx| state.focus(window, cx));
1858 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1859 });
1860
1861 assert_eq!(
1862 state.read_with(cx, |state, _| state.selected_index()),
1863 Some(IndexPath::new(1).section(0))
1864 );
1865 assert_eq!(*confirmed.borrow(), Some(IndexPath::new(1).section(0)));
1866 }
1867
1868 #[gpui::test]
1869 fn initially_rendered_all_disabled_items_have_no_selected_index_and_ignore_enter(
1870 cx: &mut TestAppContext,
1871 ) {
1872 cx.update(crate::init);
1873 let confirmed = Rc::new(RefCell::new(None));
1874 let confirmed_for_render = confirmed.clone();
1875 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1876 state: cx.new(|cx| CommandState::new(window, cx)),
1877 command: Rc::new(move |state| {
1878 let confirmed = confirmed_for_render.clone();
1879 Command::new(state)
1880 .item(CommandItem::new().label("one").disabled(true))
1881 .item(CommandItem::new().label("two").disabled(true))
1882 .on_confirm(move |index_path, _, _| {
1883 *confirmed.borrow_mut() = Some(index_path);
1884 })
1885 }),
1886 });
1887 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1888
1889 cx.run_until_parked();
1890 cx.update(|window, cx| _ = window.draw(cx));
1891 cx.update(|window, cx| {
1892 state.update(cx, |state, cx| state.focus(window, cx));
1893 window.dispatch_action(Box::new(Confirm { secondary: false }), cx);
1894 });
1895
1896 assert_eq!(state.read_with(cx, |state, _| state.selected_index()), None);
1897 assert_eq!(*confirmed.borrow(), None);
1898 }
1899
1900 #[gpui::test]
1901 fn non_searchable_command_cancels_without_clearing_a_hidden_query(cx: &mut TestAppContext) {
1902 cx.update(crate::init);
1903 let cancelled = Rc::new(Cell::new(false));
1904 let cancelled_for_render = cancelled.clone();
1905 let query_calls = Rc::new(Cell::new(0));
1906 let query_calls_for_render = query_calls.clone();
1907 let (harness, cx) = cx.add_window_view(move |window, cx| Harness {
1908 state: cx.new(|cx| CommandState::new(window, cx)),
1909 command: Rc::new(move |state| {
1910 let cancelled = cancelled_for_render.clone();
1911 let query_calls = query_calls_for_render.clone();
1912 Command::new(state)
1913 .searchable(false)
1914 .item(CommandItem::new().label("alpha"))
1915 .on_query(move |_, _, _| query_calls.set(query_calls.get() + 1))
1916 .on_cancel(move |_, _| cancelled.set(true))
1917 }),
1918 });
1919 let state = cx.update(|_, cx| harness.read(cx).state.clone());
1920
1921 cx.run_until_parked();
1922 cx.update(|window, cx| _ = window.draw(cx));
1923 cx.update(|window, cx| {
1924 state.update(cx, |state, cx| {
1925 state.set_query("hidden query", window, cx);
1926 state.focus(window, cx);
1927 });
1928 window.dispatch_action(Box::new(Cancel), cx);
1929 });
1930
1931 assert!(cancelled.get());
1932 assert_eq!(query_calls.get(), 0);
1933 assert_eq!(
1934 state.read_with(cx, |state, cx| state.query(cx)),
1935 "hidden query"
1936 );
1937 }
1938
1939 #[gpui::test]
1940 fn moving_the_highlight_skips_disabled_items_and_wraps(cx: &mut TestAppContext) {
1941 cx.update(crate::init);
1942 let cx = cx.add_empty_window();
1943
1944 cx.update(|window, cx| {
1945 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1946
1947 state.update(cx, |state, cx| {
1948 state.update_matches(cx);
1949 state.reset_selection();
1950 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1951
1952 state.select_by(1, window, cx);
1953 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1954
1955 state.select_by(1, window, cx);
1957 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(1)));
1958
1959 state.select_by(-1, window, cx);
1960 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(0)));
1961
1962 state.select_by(-1, window, cx);
1964 assert_eq!(state.selected_index(), Some(IndexPath::new(0).section(0)));
1965 state.select_by(-1, window, cx);
1966 assert_eq!(state.selected_index(), Some(IndexPath::new(1).section(1)));
1967 });
1968 });
1969 }
1970
1971 #[gpui::test]
1972 fn owner_can_set_and_clear_selection_by_original_index_path(cx: &mut TestAppContext) {
1973 cx.update(crate::init);
1974 let cx = cx.add_empty_window();
1975
1976 cx.update(|window, cx| {
1977 let initially_empty = cx.new(|cx| CommandState::new(window, cx));
1978 initially_empty.update(cx, |state, cx| {
1979 state.set_selected_index(None, window, cx);
1980 state.install_model(
1981 CommandModel {
1982 entries: suggestion_entries().into_iter().collect(),
1983 ..CommandModel::default()
1984 },
1985 cx,
1986 );
1987 assert_eq!(state.selected_index(), None);
1988 });
1989
1990 let state = cx.new(|cx| command_state(window, cx, suggestion_entries()));
1991
1992 state.update(cx, |state, cx| {
1993 let target = IndexPath::new(1).section(1);
1994 state.set_selected_index(Some(target), window, cx);
1995 assert_eq!(state.selected_index(), Some(target));
1996
1997 state.set_selected_index(None, window, cx);
1998 assert_eq!(state.selected_index(), None);
1999
2000 state.install_model(
2001 CommandModel {
2002 entries: suggestion_entries().into_iter().collect(),
2003 ..CommandModel::default()
2004 },
2005 cx,
2006 );
2007 assert_eq!(state.selected_index(), None);
2008
2009 state.set_query("calendar", window, cx);
2010 state.set_selected_index(Some(target), window, cx);
2011 assert_eq!(state.selected_index(), None);
2012 });
2013 });
2014 }
2015
2016 #[gpui::test]
2017 fn confirming_a_disabled_item_does_nothing(cx: &mut TestAppContext) {
2018 cx.update(crate::init);
2019 let cx = cx.add_empty_window();
2020
2021 cx.update(|window, cx| {
2022 let state = cx.new(|cx| {
2023 command_state(
2024 window,
2025 cx,
2026 [
2027 CommandEntry::Item(CommandItem::new().label("enabled")),
2028 CommandEntry::Item(CommandItem::new().label("disabled").disabled(true)),
2029 ],
2030 )
2031 });
2032
2033 state.update(cx, |state, cx| {
2034 state.update_matches(cx);
2035
2036 assert_eq!(state.matched_count(), 2);
2037 state.confirm(1, window, cx);
2040 assert_eq!(state.selected_index, Some(0));
2041 });
2042 });
2043 }
2044
2045 #[gpui::test]
2046 fn a_checked_item_uses_an_xsmall_trailing_check_icon(cx: &mut TestAppContext) {
2047 cx.update(crate::init);
2048 let cx = cx.add_empty_window();
2049 let unchecked_width = Rc::new(Cell::new(None));
2050 let checked_width = Rc::new(Cell::new(None));
2051 let (unchecked, checked) = cx.update(|window, cx| {
2052 let unchecked_state = cx.new(|cx| {
2053 command_state(
2054 window,
2055 cx,
2056 [CommandEntry::Item(CommandItem::new().label("theme"))],
2057 )
2058 });
2059 let checked_state = cx.new(|cx| {
2060 command_state(
2061 window,
2062 cx,
2063 [CommandEntry::Item(
2064 CommandItem::new().label("theme").checked(true),
2065 )],
2066 )
2067 });
2068 let unchecked_width = unchecked_width.clone();
2069 let checked_width = checked_width.clone();
2070 (
2071 cx.new(|_| CheckIconWidthHarness {
2072 state: unchecked_state,
2073 width: unchecked_width,
2074 }),
2075 cx.new(|_| CheckIconWidthHarness {
2076 state: checked_state,
2077 width: checked_width,
2078 }),
2079 )
2080 });
2081
2082 cx.draw(
2083 gpui::point(px(0.), px(0.)),
2084 gpui::AvailableSpace::min_size(),
2085 move |_, _| unchecked.into_any_element(),
2086 );
2087
2088 cx.draw(
2089 gpui::point(px(0.), px(0.)),
2090 gpui::AvailableSpace::min_size(),
2091 move |_, _| checked.into_any_element(),
2092 );
2093
2094 assert_eq!(
2095 checked_width.get().unwrap() - unchecked_width.get().unwrap(),
2096 px(20.)
2097 );
2098 }
2099
2100 struct CheckIconWidthHarness {
2101 state: Entity<CommandState>,
2102 width: Rc<Cell<Option<gpui::Pixels>>>,
2103 }
2104
2105 impl Render for CheckIconWidthHarness {
2106 fn render(
2107 &mut self,
2108 window: &mut Window,
2109 cx: &mut gpui::Context<Self>,
2110 ) -> impl IntoElement {
2111 let width = self.width.clone();
2112 let item = self.state.update(cx, |state, cx| {
2113 state.update_matches(cx);
2114 state.render_item(0, window, cx)
2115 });
2116
2117 div()
2118 .on_children_prepainted(move |bounds, _, _| width.set(Some(bounds[0].size.width)))
2119 .child(item)
2120 }
2121 }
2122
2123 struct Harness {
2124 state: Entity<CommandState>,
2125 command: Rc<dyn Fn(&Entity<CommandState>) -> Command>,
2126 }
2127
2128 impl Render for Harness {
2129 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2130 div()
2131 .size_full()
2132 .child((self.command)(&self.state).max_h(px(200.)))
2133 }
2134 }
2135
2136 #[gpui::test]
2137 fn header_and_footer_render_with_current_state(cx: &mut TestAppContext) {
2138 cx.update(crate::init);
2139 let header_calls = Rc::new(Cell::new(0));
2140 let footer_calls = Rc::new(Cell::new(0));
2141 let header_matched_count = Rc::new(Cell::new(None));
2142 let footer_matched_count = Rc::new(Cell::new(None));
2143
2144 let (harness, cx) = cx.add_window_view(|window, cx| HeaderFooterHarness {
2145 state: cx.new(|cx| CommandState::new(window, cx)),
2146 header_calls,
2147 footer_calls,
2148 header_matched_count,
2149 footer_matched_count,
2150 });
2151
2152 cx.run_until_parked();
2153 cx.update(|window, cx| _ = window.draw(cx));
2154
2155 let (header_calls, footer_calls, header_matched_count, footer_matched_count) =
2156 cx.update(|_, cx| {
2157 let harness = harness.read(cx);
2158 (
2159 harness.header_calls.get(),
2160 harness.footer_calls.get(),
2161 harness.header_matched_count.get(),
2162 harness.footer_matched_count.get(),
2163 )
2164 });
2165 assert!(header_calls > 0);
2166 assert!(footer_calls > 0);
2167 assert_eq!(header_matched_count, Some(2));
2168 assert_eq!(footer_matched_count, Some(2));
2169 }
2170
2171 #[gpui::test]
2172 fn custom_empty_slot_renders_with_current_state(cx: &mut TestAppContext) {
2173 cx.update(crate::init);
2174 let empty_calls = Rc::new(Cell::new(0));
2175 let empty_matched_count = Rc::new(Cell::new(None));
2176 let calls = empty_calls.clone();
2177 let matched_count = empty_matched_count.clone();
2178 let (_harness, cx) = cx.add_window_view(move |window, cx| Harness {
2179 state: cx.new(|cx| CommandState::new(window, cx)),
2180 command: Rc::new(move |state| {
2181 let calls = calls.clone();
2182 let matched_count = matched_count.clone();
2183 Command::new(state).empty(
2184 move |state: &CommandState, _: &mut Window, _: &mut gpui::App| {
2185 calls.set(calls.get() + 1);
2186 matched_count.set(Some(state.matched_count()));
2187 div().child("Custom empty")
2188 },
2189 )
2190 }),
2191 });
2192
2193 cx.run_until_parked();
2194 cx.update(|window, cx| _ = window.draw(cx));
2195
2196 assert!(empty_calls.get() > 0);
2197 assert_eq!(empty_matched_count.get(), Some(0));
2198 }
2199
2200 fn entries_with_late_first_enabled_item() -> Vec<CommandEntry> {
2201 vec![
2202 CommandGroup::new()
2203 .label("Disabled")
2204 .items((0..30).map(|ix| {
2205 CommandItem::new()
2206 .label(format!("disabled-{ix}"))
2207 .keywords(["match"])
2208 .disabled(true)
2209 }))
2210 .into(),
2211 CommandEntry::Separator,
2212 CommandGroup::new()
2213 .label("Enabled")
2214 .item(CommandItem::new().label("enabled").keywords(["match"]))
2215 .into(),
2216 ]
2217 }
2218
2219 fn assert_first_enabled_row_is_scrolled_into_view(
2220 state: &Entity<CommandState>,
2221 cx: &mut TestAppContext,
2222 ) {
2223 let (selected_row, offset) = state.read_with(cx, |state, _| {
2224 (
2225 state.matched[state.selected_index.unwrap()].row_ix,
2226 state.scroll_handle.base_handle().offset().y,
2227 )
2228 });
2229
2230 assert!(selected_row > 30);
2231 assert!(
2232 offset < px(-900.),
2233 "the list should scroll to the selected row, not row zero ({offset:?})",
2234 );
2235 }
2236
2237 #[gpui::test]
2238 fn first_enabled_selection_resets_scroll_to_its_late_row(cx: &mut TestAppContext) {
2239 cx.update(crate::init);
2240 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2241 state: cx.new(|cx| CommandState::new(window, cx)),
2242 command: Rc::new(|state| {
2243 command_with_entries(state, entries_with_late_first_enabled_item())
2244 }),
2245 });
2246 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2247
2248 cx.run_until_parked();
2249 cx.update(|window, cx| _ = window.draw(cx));
2250 assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2251
2252 cx.update(|window, cx| {
2253 state.update(cx, |state, cx| state.set_query("match", window, cx));
2254 _ = window.draw(cx);
2255 });
2256 assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2257
2258 cx.update(|window, cx| {
2259 harness.update(cx, |_, cx| {
2260 cx.notify();
2261 });
2262 _ = window.draw(cx);
2263 });
2264 assert_first_enabled_row_is_scrolled_into_view(&state, cx);
2265 }
2266
2267 struct HeaderFooterHarness {
2268 state: Entity<CommandState>,
2269 header_calls: Rc<Cell<usize>>,
2270 footer_calls: Rc<Cell<usize>>,
2271 header_matched_count: Rc<Cell<Option<usize>>>,
2272 footer_matched_count: Rc<Cell<Option<usize>>>,
2273 }
2274
2275 impl Render for HeaderFooterHarness {
2276 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2277 let header_calls = self.header_calls.clone();
2278 let header_matched_count = self.header_matched_count.clone();
2279 let footer_calls = self.footer_calls.clone();
2280 let footer_matched_count = self.footer_matched_count.clone();
2281
2282 div().size_full().child(
2283 Command::new(&self.state)
2284 .items([
2285 CommandItem::new().label("Calendar"),
2286 CommandItem::new().label("Calculator"),
2287 ])
2288 .max_h(px(200.))
2289 .header(move |state, _, _| {
2290 header_calls.set(header_calls.get() + 1);
2291 header_matched_count.set(Some(state.matched_count()));
2292 div()
2293 })
2294 .footer(move |state, _, _| {
2295 footer_calls.set(footer_calls.get() + 1);
2296 footer_matched_count.set(Some(state.matched_count()));
2297 div()
2298 }),
2299 )
2300 }
2301 }
2302
2303 struct PaddedHarness {
2304 state: Entity<CommandState>,
2305 }
2306
2307 impl Render for PaddedHarness {
2308 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2309 div().size_full().child(
2310 Command::new(&self.state)
2311 .item(
2312 CommandItem::new()
2313 .label("fixed")
2314 .child(|_, _| div().h(px(32.))),
2315 )
2316 .max_h(px(200.))
2317 .p_4(),
2318 )
2319 }
2320 }
2321
2322 struct WrappingHarness {
2323 state: Entity<CommandState>,
2324 width: Pixels,
2325 no_wrap: bool,
2326 }
2327
2328 impl Render for WrappingHarness {
2329 fn render(&mut self, _: &mut Window, _: &mut gpui::Context<Self>) -> impl IntoElement {
2330 div().size_full().child(
2331 div().w(self.width).child(
2332 Command::new(&self.state)
2333 .item(CommandItem::new().label("wrapped").child(|_, _| {
2334 div()
2335 .w_full()
2336 .child("A command row whose content wraps at narrow list widths")
2337 }))
2338 .max_h(px(200.))
2339 .when(self.no_wrap, |this| this.whitespace_nowrap()),
2340 ),
2341 )
2342 }
2343 }
2344
2345 #[gpui::test]
2346 fn wrapping_rows_remeasure_for_the_list_content_width(cx: &mut TestAppContext) {
2347 cx.update(crate::init);
2348
2349 let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2350 state: cx.new(|cx| CommandState::new(window, cx)),
2351 width: px(360.),
2352 no_wrap: false,
2353 });
2354
2355 cx.run_until_parked();
2356 cx.update(|window, cx| _ = window.draw(cx));
2357 cx.run_until_parked();
2358 cx.update(|window, cx| _ = window.draw(cx));
2359
2360 let wide = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2361
2362 cx.update(|_, cx| {
2363 harness.update(cx, |harness, cx| {
2364 harness.width = px(120.);
2365 cx.notify();
2366 })
2367 });
2368 cx.run_until_parked();
2369 cx.update(|window, cx| _ = window.draw(cx));
2370 cx.run_until_parked();
2371 cx.update(|window, cx| _ = window.draw(cx));
2372 let narrow = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2373
2374 assert!(
2375 narrow > wide,
2376 "the narrow list should cache a taller wrapped row ({narrow:?} vs {wide:?})",
2377 );
2378 }
2379
2380 #[gpui::test]
2381 fn wrapping_rows_remeasure_when_rem_size_changes(cx: &mut TestAppContext) {
2382 cx.update(crate::init);
2383
2384 let (harness, cx) = cx.add_window_view(|window, cx| {
2385 window.set_rem_size(px(20.));
2386 WrappingHarness {
2387 state: cx.new(|cx| CommandState::new(window, cx)),
2388 width: px(160.),
2389 no_wrap: false,
2390 }
2391 });
2392
2393 cx.run_until_parked();
2394 cx.update(|window, cx| _ = window.draw(cx));
2395 cx.run_until_parked();
2396 cx.update(|window, cx| _ = window.draw(cx));
2397 let smaller_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2398
2399 cx.update(|window, cx| {
2400 window.set_rem_size(px(28.));
2401 _ = window.draw(cx);
2402 });
2403 cx.run_until_parked();
2404 cx.update(|window, cx| _ = window.draw(cx));
2405 let larger_rem = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2406
2407 assert!(
2408 larger_rem > smaller_rem,
2409 "a larger rem should remeasure the fixed-width wrapped row ({larger_rem:?} vs {smaller_rem:?})",
2410 );
2411 }
2412
2413 #[gpui::test]
2414 fn wrapping_rows_remeasure_when_inherited_typography_changes(cx: &mut TestAppContext) {
2415 cx.update(crate::init);
2416
2417 let (harness, cx) = cx.add_window_view(|window, cx| WrappingHarness {
2418 state: cx.new(|cx| CommandState::new(window, cx)),
2419 width: px(160.),
2420 no_wrap: false,
2421 });
2422
2423 cx.run_until_parked();
2424 cx.update(|window, cx| _ = window.draw(cx));
2425 cx.run_until_parked();
2426 cx.update(|window, cx| _ = window.draw(cx));
2427 let wrapped_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2428
2429 cx.update(|window, cx| {
2430 harness.update(cx, |harness, cx| {
2431 harness.no_wrap = true;
2432 cx.notify();
2433 });
2434 _ = window.draw(cx);
2435 });
2436 cx.run_until_parked();
2437 cx.update(|window, cx| _ = window.draw(cx));
2438 let no_wrap_height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2439 assert!(
2440 no_wrap_height < wrapped_height,
2441 "a changed inherited typography should remeasure the fixed-width row ({no_wrap_height:?} vs {wrapped_height:?})",
2442 );
2443 }
2444
2445 #[gpui::test]
2446 fn outer_command_padding_does_not_inflate_measured_row_heights(cx: &mut TestAppContext) {
2447 cx.update(crate::init);
2448
2449 let (harness, cx) = cx.add_window_view(|window, cx| PaddedHarness {
2450 state: cx.new(|cx| CommandState::new(window, cx)),
2451 });
2452
2453 cx.run_until_parked();
2454 cx.update(|window, cx| _ = window.draw(cx));
2455 cx.run_until_parked();
2456 cx.update(|window, cx| _ = window.draw(cx));
2457 let height = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes[0].height);
2458
2459 assert_eq!(height, px(44.));
2460 }
2461
2462 #[gpui::test]
2463 fn custom_rows_keep_independent_heights(cx: &mut TestAppContext) {
2464 cx.update(crate::init);
2465
2466 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2467 state: cx.new(|cx| CommandState::new(window, cx)),
2468 command: Rc::new(|state| {
2469 Command::new(state)
2470 .group(
2471 CommandGroup::new().label("Short").item(
2472 CommandItem::new()
2473 .label("short")
2474 .child(|_, _| div().h(px(32.))),
2475 ),
2476 )
2477 .separator()
2478 .group(
2479 CommandGroup::new().label("Tall").item(
2480 CommandItem::new()
2481 .label("tall")
2482 .child(|_, _| div().h(px(72.))),
2483 ),
2484 )
2485 }),
2486 });
2487
2488 cx.run_until_parked();
2489 cx.update(|window, cx| _ = window.draw(cx));
2490 let row_sizes = cx.update(|_, cx| harness.read(cx).state.read(cx).row_sizes.clone());
2491
2492 assert_eq!(row_sizes.len(), 5);
2493 assert!(row_sizes[0].height > px(0.));
2494 assert_eq!(row_sizes[1].height, px(44.));
2495 assert_eq!(row_sizes[2].height, px(SEPARATOR_ROW_HEIGHT));
2496 assert!(row_sizes[3].height > px(0.));
2497 assert_eq!(row_sizes[4].height, px(84.));
2498 }
2499
2500 #[gpui::test]
2501 fn reinstalling_a_model_preserves_selection_by_index_path_and_remeasures_rows(
2502 cx: &mut TestAppContext,
2503 ) {
2504 cx.update(crate::init);
2505 let reversed = Rc::new(Cell::new(false));
2506 let reversed_for_render = reversed.clone();
2507 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2508 state: cx.new(|cx| CommandState::new(window, cx)),
2509 command: Rc::new(move |state| {
2510 if reversed_for_render.get() {
2511 Command::new(state)
2512 .item(
2513 CommandItem::new()
2514 .label("beta")
2515 .child(|_, _| div().h(px(72.))),
2516 )
2517 .item(
2518 CommandItem::new()
2519 .label("alpha")
2520 .child(|_, _| div().h(px(32.))),
2521 )
2522 } else {
2523 Command::new(state)
2524 .item(
2525 CommandItem::new()
2526 .label("alpha")
2527 .child(|_, _| div().h(px(32.))),
2528 )
2529 .item(
2530 CommandItem::new()
2531 .label("beta")
2532 .child(|_, _| div().h(px(72.))),
2533 )
2534 }
2535 }),
2536 });
2537 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2538
2539 cx.run_until_parked();
2540 cx.update(|window, cx| _ = window.draw(cx));
2541 cx.update(|window, cx| {
2542 state.update(cx, |state, cx| state.select_by(1, window, cx));
2543 });
2544 assert_eq!(
2545 state.read_with(cx, |state, _| state.selected_index()),
2546 Some(IndexPath::new(1).section(0)),
2547 );
2548
2549 reversed.set(true);
2550 cx.update(|window, cx| {
2551 harness.update(cx, |_, cx| cx.notify());
2552 _ = window.draw(cx);
2553 });
2554
2555 let (selected_matched_index, selected_index, row_sizes) =
2556 state.read_with(cx, |state, _| {
2557 (
2558 state.selected_index,
2559 state.selected_index(),
2560 state.row_sizes.clone(),
2561 )
2562 });
2563 assert_eq!(selected_matched_index, Some(1));
2564 assert_eq!(selected_index, Some(IndexPath::new(1).section(0)));
2565 assert_eq!(row_sizes[0].height, px(84.));
2566 assert_eq!(row_sizes[1].height, px(44.));
2567 }
2568
2569 #[gpui::test]
2570 fn a_state_redraw_reuses_the_installed_custom_row_measurement(cx: &mut TestAppContext) {
2571 cx.update(crate::init);
2572 let renders = Rc::new(Cell::new(0));
2573 let count = renders.clone();
2574 let cx = cx.add_empty_window();
2575 let state = cx.update(|window, cx| {
2576 cx.new(|cx| {
2577 command_state(
2578 window,
2579 cx,
2580 [CommandEntry::Item(
2581 CommandItem::new().label("custom").child(move |_, _| {
2582 count.set(count.get() + 1);
2583 div().child("Custom")
2584 }),
2585 )],
2586 )
2587 })
2588 });
2589
2590 let first_state = state.clone();
2591 cx.draw(
2592 gpui::point(px(0.), px(0.)),
2593 gpui::AvailableSpace::min_size(),
2594 move |_, _| first_state.into_any_element(),
2595 );
2596 let settled_state = state.clone();
2597 cx.draw(
2598 gpui::point(px(0.), px(0.)),
2599 gpui::AvailableSpace::min_size(),
2600 move |_, _| settled_state.into_any_element(),
2601 );
2602 let after_first_draw = renders.get();
2603 cx.draw(
2604 gpui::point(px(0.), px(0.)),
2605 gpui::AvailableSpace::min_size(),
2606 move |_, _| state.into_any_element(),
2607 );
2608
2609 assert_eq!(renders.get() - after_first_draw, 2);
2610 }
2611
2612 #[gpui::test]
2613 fn moving_past_the_visible_rows_scrolls_the_list(cx: &mut TestAppContext) {
2614 cx.update(crate::init);
2615
2616 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2617 state: cx.new(|cx| CommandState::new(window, cx)),
2618 command: Rc::new(|state| {
2619 Command::new(state)
2620 .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2621 }),
2622 });
2623
2624 cx.run_until_parked();
2625 cx.update(|window, cx| _ = window.draw(cx));
2626
2627 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2628 assert_eq!(
2629 state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2630 px(0.),
2631 );
2632
2633 cx.update(|window, cx| {
2636 state.update(cx, |state, cx| {
2637 for _ in 0..49 {
2638 state.select_by(1, window, cx);
2639 }
2640 })
2641 });
2642 cx.update(|window, cx| _ = window.draw(cx));
2643
2644 assert_eq!(
2645 state.read_with(cx, |state, _| state.selected_index()),
2646 Some(IndexPath::new(49).section(0))
2647 );
2648 assert!(
2649 state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y) < px(0.),
2650 "selecting the last row should have scrolled the list",
2651 );
2652 }
2653
2654 #[gpui::test]
2655 fn a_reinstalled_model_does_not_scroll_a_preserved_selection(cx: &mut TestAppContext) {
2656 cx.update(crate::init);
2657
2658 let (harness, cx) = cx.add_window_view(|window, cx| Harness {
2659 state: cx.new(|cx| CommandState::new(window, cx)),
2660 command: Rc::new(|state| {
2661 Command::new(state)
2662 .items((0..50).map(|ix| CommandItem::new().label(format!("Item {ix}"))))
2663 }),
2664 });
2665
2666 cx.run_until_parked();
2667 cx.update(|window, cx| _ = window.draw(cx));
2668
2669 let state = cx.update(|_, cx| harness.read(cx).state.clone());
2670
2671 cx.update(|window, cx| {
2676 state.update(cx, |state, cx| state.select(10, window, cx));
2677 });
2678 cx.update(|window, cx| _ = window.draw(cx));
2679
2680 assert_eq!(
2681 state.read_with(cx, |state, _| state.scroll_handle.base_handle().offset().y),
2682 px(0.),
2683 "reinstalling the model must keep the scroll position",
2684 );
2685 }
2686}