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