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