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