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