1use std::collections::HashMap;
75use std::rc::Rc;
76use std::sync::Arc;
77
78use gpui::{
79 div, prelude::FluentBuilder as _, px, App, ElementId, Entity, Focusable as _,
80 InteractiveElement, IntoElement, ParentElement, Pixels, RenderOnce, ScrollHandle, SharedString,
81 StatefulInteractiveElement, Styled, Window,
82};
83use herogpui_core::element_id;
84use herogpui_theme::ActiveTheme;
85
86use crate::a11y::{self, A11y as _};
87use crate::input::{Input, InputState};
88use crate::matches::MatchesCache;
89use crate::picker_item::PickerItem;
90use crate::{anim, util, Filter, Kbd, Sensitivity};
91
92type SelectCallback = Arc<dyn Fn(&SharedString, &mut Window, &mut App) + 'static>;
93type OpenChange = Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>;
94type CommandFilter = Rc<dyn Fn(&CommandItem, &str) -> bool + 'static>;
95
96const ROW_HEIGHT: Pixels = px(40.);
98const HEADING_HEIGHT: Pixels = px(28.);
100
101pub fn is_command_palette_shortcut(keystroke: &gpui::Keystroke) -> bool {
105 let m = &keystroke.modifiers;
106 keystroke.key == "k" && m.secondary() && !m.alt && !m.shift && !(m.control && m.platform)
107}
108
109#[must_use]
111#[derive(Clone)]
112pub struct CommandItem {
113 key: SharedString,
114 label: SharedString,
115 group: Option<SharedString>,
116 description: Option<SharedString>,
117 icon: Option<SharedString>,
118 shortcut: Vec<SharedString>,
119 keywords: Vec<SharedString>,
120 is_disabled: bool,
121}
122
123impl CommandItem {
124 pub fn new(key: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
127 Self {
128 key: key.into(),
129 label: label.into(),
130 group: None,
131 description: None,
132 icon: None,
133 shortcut: Vec::new(),
134 keywords: Vec::new(),
135 is_disabled: false,
136 }
137 }
138
139 pub fn group(mut self, group: impl Into<SharedString>) -> Self {
141 self.group = Some(group.into());
142 self
143 }
144
145 pub fn description(mut self, description: impl Into<SharedString>) -> Self {
147 self.description = Some(description.into());
148 self
149 }
150
151 pub fn icon(mut self, icon: impl Into<SharedString>) -> Self {
153 self.icon = Some(icon.into());
154 self
155 }
156
157 pub fn shortcut(mut self, keys: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
160 self.shortcut = keys.into_iter().map(Into::into).collect();
161 self
162 }
163
164 pub fn keywords(mut self, words: impl IntoIterator<Item = impl Into<SharedString>>) -> Self {
166 self.keywords = words.into_iter().map(Into::into).collect();
167 self
168 }
169
170 pub fn is_disabled(mut self, disabled: bool) -> Self {
172 self.is_disabled = disabled;
173 self
174 }
175
176 pub fn key(&self) -> &SharedString {
178 &self.key
179 }
180
181 pub fn label(&self) -> &SharedString {
183 &self.label
184 }
185
186 fn search_text(&self) -> String {
189 let mut text = self.label.to_string();
190 for part in self.keywords.iter().chain(self.group.iter()) {
191 text.push('\n');
192 text.push_str(part);
193 }
194 text
195 }
196}
197
198fn default_matches(filter: &Filter, text: &str, query: &str) -> bool {
200 query
201 .split_whitespace()
202 .all(|word| text.split('\n').any(|field| filter.contains(field, word)))
203}
204
205#[must_use]
207#[derive(IntoElement)]
208pub struct CommandPalette {
209 id: ElementId,
210 items: Vec<CommandItem>,
211 is_open: Option<bool>,
212 default_open: bool,
213 placeholder: Option<SharedString>,
214 empty_text: Option<SharedString>,
215 label: SharedString,
216 width: Pixels,
217 max_list_height: Pixels,
218 close_on_select: bool,
219 filter: Option<CommandFilter>,
220 on_select: Option<SelectCallback>,
221 on_open_change: Option<OpenChange>,
222 sx: Option<Box<gpui::StyleRefinement>>,
223}
224
225impl CommandPalette {
226 pub fn new(id: impl Into<ElementId>, items: Vec<CommandItem>) -> Self {
229 Self {
230 id: id.into(),
231 items,
232 is_open: None,
233 default_open: false,
234 placeholder: None,
235 empty_text: None,
236 label: SharedString::new_static("Command palette"),
237 width: px(560.),
238 max_list_height: px(320.),
239 close_on_select: true,
240 filter: None,
241 on_select: None,
242 on_open_change: None,
243 sx: None,
244 }
245 }
246
247 pub fn is_open(mut self, open: bool) -> Self {
249 self.is_open = Some(open);
250 self
251 }
252
253 pub fn default_open(mut self, open: bool) -> Self {
255 self.default_open = open;
256 self
257 }
258
259 pub fn placeholder(mut self, text: impl Into<SharedString>) -> Self {
261 self.placeholder = Some(text.into());
262 self
263 }
264
265 pub fn empty_text(mut self, text: impl Into<SharedString>) -> Self {
268 self.empty_text = Some(text.into());
269 self
270 }
271
272 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
274 self.label = label.into();
275 self
276 }
277
278 pub fn width(mut self, width: impl Into<Pixels>) -> Self {
280 self.width = width.into();
281 self
282 }
283
284 pub fn max_list_height(mut self, height: impl Into<Pixels>) -> Self {
286 self.max_list_height = height.into();
287 self
288 }
289
290 pub fn close_on_select(mut self, close: bool) -> Self {
292 self.close_on_select = close;
293 self
294 }
295
296 pub fn filter(mut self, filter: impl Fn(&CommandItem, &str) -> bool + 'static) -> Self {
299 self.filter = Some(Rc::new(filter));
300 self
301 }
302
303 pub fn on_select(mut self, f: impl Fn(&SharedString, &mut Window, &mut App) + 'static) -> Self {
305 self.on_select = Some(Arc::new(f));
306 self
307 }
308
309 pub fn on_open_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
311 self.on_open_change = Some(Arc::new(f));
312 self
313 }
314
315 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
317 util::refine_sx(&mut self.sx, style);
318 self
319 }
320}
321
322#[derive(Clone, Copy, PartialEq, Eq, Debug)]
325enum Line {
326 Heading(usize),
327 Command(usize),
328}
329
330fn group_lines(items: &[CommandItem], matched: &[usize]) -> (Vec<Line>, Vec<SharedString>) {
333 let mut groups: Vec<Option<SharedString>> = Vec::new();
334 let mut members: Vec<Vec<usize>> = Vec::new();
335 for &ix in matched {
336 let group = items[ix].group.clone();
337 let at = match groups.iter().position(|g| *g == group) {
338 Some(at) => at,
339 None => {
340 groups.push(group);
341 members.push(Vec::new());
342 groups.len() - 1
343 }
344 };
345 members[at].push(ix);
346 }
347 let mut lines = Vec::new();
348 let mut headings = Vec::new();
349 for (group, rows) in groups.into_iter().zip(members) {
350 if let Some(name) = group {
351 lines.push(Line::Heading(headings.len()));
352 headings.push(name);
353 }
354 lines.extend(rows.into_iter().map(Line::Command));
355 }
356 (lines, headings)
357}
358
359#[derive(Clone)]
361struct Opener {
362 own: Option<Entity<bool>>,
363 on_open_change: Option<OpenChange>,
364}
365
366impl Opener {
367 fn set(&self, open: bool, window: &mut Window, cx: &mut App) {
368 if let Some(own) = &self.own {
369 own.update(cx, |value, cx| {
370 *value = open;
371 cx.notify();
372 });
373 }
374 if let Some(cb) = &self.on_open_change {
375 cb(&open, window, cx);
376 }
377 }
378}
379
380impl RenderOnce for CommandPalette {
381 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
382 let base = self.id.clone();
383 let (open, own) = util::controlled(
384 window,
385 cx,
386 element_id::scoped(&base, "open"),
387 self.is_open,
388 self.default_open,
389 );
390 let (phase, token) =
391 util::overlay_scope(window, cx, element_id::scoped(&base, "phase"), open, true);
392 if phase == util::OverlayPhase::Closed {
393 crate::modal::release_dialog_focus(&base, window, cx);
394 return div().into_any_element();
395 }
396 let exiting = phase == util::OverlayPhase::Exiting;
397 let opener = Opener {
398 own,
399 on_open_change: self.on_open_change.clone(),
400 };
401
402 let query_state =
405 window.use_keyed_state(element_id::scoped(&base, "query"), cx, |_, cx| {
406 InputState::new(cx)
407 });
408 let cursor = window.use_keyed_state(element_id::scoped(&base, "cursor"), cx, |_, _| {
409 None::<SharedString>
410 });
411 let last_query =
412 window.use_keyed_state(element_id::scoped(&base, "last-query"), cx, |_, _| {
413 None::<String>
414 });
415 let scroll = window
416 .use_keyed_state(element_id::scoped(&base, "scroll"), cx, |_, _| {
417 ScrollHandle::new()
418 })
419 .read(cx)
420 .clone();
421 let cache = window.use_keyed_state(element_id::scoped(&base, "matches"), cx, |_, _| {
422 MatchesCache::default()
423 });
424 let input_focus = query_state.read(cx).focus_handle(cx);
425 crate::modal::claim_dialog_focus(&base, &input_focus, window, cx);
428
429 let query = query_state.read(cx).value().to_owned();
433 let items = Rc::new(self.items);
434 let matched: Vec<usize> = if query.trim().is_empty() {
435 (0..items.len()).collect()
436 } else if let Some(filter) = &self.filter {
437 (0..items.len())
438 .filter(|&ix| filter(&items[ix], &query))
439 .collect()
440 } else {
441 let index: HashMap<SharedString, usize> = items
442 .iter()
443 .enumerate()
444 .map(|(ix, item)| (item.key.clone(), ix))
445 .collect();
446 let pickers: Rc<[PickerItem]> = items
447 .iter()
448 .map(|item| PickerItem::new(item.key.clone(), item.search_text()))
449 .collect::<Vec<_>>()
450 .into();
451 let hits = cache.update(cx, |cache, _| {
452 cache.get(pickers, &query, usize::MAX, |all| {
453 let filter = Filter::new(Sensitivity::Base);
454 all.iter()
455 .filter(|p| default_matches(&filter, p.label(), &query))
456 .cloned()
457 .collect()
458 })
459 });
460 hits.iter()
461 .filter_map(|p| index.get(p.key()).copied())
462 .collect()
463 };
464 let (lines, headings) = group_lines(&items, &matched);
465 let stops: Vec<usize> = lines
466 .iter()
467 .enumerate()
468 .filter_map(|(at, line)| match line {
469 Line::Command(ix) if !items[*ix].is_disabled => Some(at),
470 _ => None,
471 })
472 .collect();
473
474 let query_changed = last_query.read(cx).as_deref() != Some(query.as_str());
477 if query_changed {
478 last_query.update(cx, |q, _| *q = Some(query.clone()));
479 }
480 let held_line = cursor.read(cx).as_ref().and_then(|key| {
481 lines.iter().position(|line| match line {
482 Line::Command(ix) => &items[*ix].key == key,
483 Line::Heading(_) => false,
484 })
485 });
486 let cursor_line = if query_changed {
487 None
488 } else {
489 held_line.filter(|at| stops.contains(at))
490 }
491 .or_else(|| stops.first().copied());
492 let cursor_key = cursor_line.and_then(|at| match lines[at] {
493 Line::Command(ix) => Some(items[ix].key.clone()),
494 Line::Heading(_) => None,
495 });
496 if *cursor.read(cx) != cursor_key {
497 cursor.update(cx, |c, _| *c = cursor_key.clone());
498 }
499 if query_changed {
500 if let Some(at) = cursor_line {
501 scroll.scroll_to_item(at);
502 }
503 }
504
505 let run = util::shared({
506 let opener = opener.clone();
507 let on_select = self.on_select.clone();
508 let items = items.clone();
509 let close_on_select = self.close_on_select;
510 move |ix: usize, window: &mut Window, cx: &mut App| {
511 let item = &items[ix];
512 if item.is_disabled {
513 return;
514 }
515 if close_on_select {
516 opener.set(false, window, cx);
517 }
518 if let Some(cb) = &on_select {
519 cb(&item.key, window, cx);
520 }
521 }
522 });
523 let dismiss = util::shared({
524 let opener = opener;
525 move |window: &mut Window, cx: &mut App| {
526 opener.set(false, window, cx);
527 util::DismissResult::Handled
528 }
529 });
530
531 let colors = cx.colors().clone();
532 let layout = cx.layout().clone();
533 let radius = util::container_radius(cx);
534 let row_radius = util::soft_radius(cx);
535 let pointer = util::interactive_cursor(cx);
536 let placeholder = self
537 .placeholder
538 .clone()
539 .unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::Search, cx));
540 let empty_text = self
541 .empty_text
542 .clone()
543 .unwrap_or_else(|| crate::i18n::ui_string(crate::i18n::UiString::NoResults, cx));
544
545 let search = div()
546 .flex()
547 .items_center()
548 .gap(px(8.))
549 .px(px(12.))
550 .h(px(48.))
551 .border_b(layout.border_width)
552 .border_color(colors.separator)
553 .child(
554 gpui::svg()
555 .size(util::FIELD_ICON)
556 .flex_shrink_0()
557 .path(crate::icons::SEARCH)
558 .text_color(colors.muted),
559 )
560 .child(
561 div().flex_1().min_w_0().child(
562 Input::new(&query_state)
563 .placeholder(placeholder)
564 .is_bare(true)
565 .focus_ring(false)
566 .full_width(),
567 ),
568 );
569
570 let mut list = div()
571 .id(element_id::scoped(&base, "list"))
572 .a11y_named(
573 a11y::Role::ListBox,
574 &a11y::Name::labelled(self.label.clone()),
575 )
576 .track_scroll(&scroll)
577 .overflow_y_scroll()
578 .max_h(self.max_list_height)
579 .flex()
580 .flex_col()
581 .p(px(6.))
582 .debug_selector({
583 let name = format!("{}-list", selector_base(&base));
584 move || name
585 });
586 if lines.is_empty() {
587 list = list.child(
588 div()
589 .flex()
590 .items_center()
591 .justify_center()
592 .h(px(64.))
593 .text_color(colors.muted)
594 .debug_selector({
595 let name = format!("{}-empty", selector_base(&base));
596 move || name
597 })
598 .child(empty_text),
599 );
600 }
601 for (at, line) in lines.iter().enumerate() {
602 match *line {
603 Line::Heading(h) => {
604 list = list.child(
605 div()
606 .flex()
607 .items_end()
608 .h(HEADING_HEIGHT)
609 .flex_shrink_0()
610 .px(px(8.))
611 .pb(px(4.))
612 .text_size(px(12.))
613 .line_height(px(16.))
614 .font_weight(gpui::FontWeight::MEDIUM)
615 .text_color(colors.muted)
616 .child(headings[h].clone()),
617 );
618 }
619 Line::Command(ix) => {
620 let item = &items[ix];
621 let highlighted = cursor_line == Some(at);
622 let mut row = div()
623 .id(element_id::scoped(
624 &element_id::scoped(&base, "item"),
625 item.key.clone(),
626 ))
627 .a11y_named(
628 a11y::Role::ListBoxOption,
629 &a11y::Name::labelled(item.label.clone())
630 .described(item.description.clone()),
631 )
632 .a11y_selected(highlighted)
633 .when(highlighted, |el| el.a11y_active_descendant())
634 .flex()
635 .items_center()
636 .gap(px(10.))
637 .h(ROW_HEIGHT)
638 .flex_shrink_0()
639 .px(px(8.))
640 .rounded(row_radius)
641 .text_color(colors.foreground)
642 .when(highlighted, |el| el.bg(colors.default.color))
643 .debug_selector({
644 let name = format!("{}-item-{}", selector_base(&base), item.key);
645 move || name
646 });
647 if let Some(icon) = &item.icon {
648 row = row.child(
649 gpui::svg()
650 .size(util::FIELD_ICON)
651 .flex_shrink_0()
652 .path(icon.clone())
653 .text_color(colors.muted),
654 );
655 }
656 row = row.child(
657 div()
658 .flex()
659 .items_baseline()
660 .gap(px(8.))
661 .flex_1()
662 .min_w_0()
663 .child(div().truncate().child(item.label.clone()))
664 .when_some(item.description.clone(), |el, description| {
665 el.child(
666 div()
667 .flex_1()
668 .min_w_0()
669 .truncate()
670 .text_size(px(12.))
671 .text_color(colors.muted)
672 .child(description),
673 )
674 }),
675 );
676 if !item.shortcut.is_empty() {
677 row = row.child(
678 div().flex().gap(px(4.)).flex_shrink_0().children(
679 item.shortcut
680 .iter()
681 .map(|key| Kbd::new().child(key.clone())),
682 ),
683 );
684 }
685 if item.is_disabled {
686 row = row.opacity(layout.disabled_opacity);
687 } else {
688 let hover_cursor = cursor.clone();
689 let key = item.key.clone();
690 let press = run.clone();
691 row = row
692 .cursor(pointer)
693 .on_hover(move |over, _, cx| {
694 if *over {
695 hover_cursor.update(cx, |c, cx| {
696 if c.as_ref() != Some(&key) {
697 *c = Some(key.clone());
698 cx.notify();
699 }
700 });
701 }
702 })
703 .on_click(move |_, window, cx| press(ix, window, cx));
704 }
705 list = list.child(row);
706 }
707 }
708 }
709
710 let keys_lines = Rc::new(lines);
711 let keys_cursor = cursor.clone();
712 let keys_scroll = scroll;
713 let keys_items = items;
714 let keys_run = run.clone();
715 let panel = div()
716 .id(element_id::scoped(&base, "dialog"))
717 .a11y_named(
718 a11y::Role::Dialog,
719 &a11y::Name::labelled(self.label.clone()),
720 )
721 .relative()
722 .flex()
723 .flex_col()
724 .w(self.width)
725 .max_w_full()
726 .bg(colors.overlay.background)
727 .text_color(colors.foreground)
728 .text_size(px(14.))
729 .line_height(px(20.))
730 .rounded(radius)
731 .overflow_hidden()
732 .when_some(layout.overlay_hairline, |el, hairline| {
733 el.border(layout.border_width).border_color(hairline)
734 })
735 .shadow(layout.overlay_shadow)
736 .debug_selector({
737 let name = format!("{}-panel", selector_base(&base));
738 move || name
739 })
740 .on_key_down(move |event, window, cx| {
741 let m = &event.keystroke.modifiers;
742 if m.control || m.alt || m.platform || m.shift || m.function {
743 return;
744 }
745 let key = event.keystroke.key.as_str();
746 let from = keys_cursor.read(cx).as_ref().and_then(|held| {
747 keys_lines.iter().position(|line| match line {
748 Line::Command(ix) => &keys_items[*ix].key == held,
749 Line::Heading(_) => false,
750 })
751 });
752 match key {
753 "up" | "down" => {
754 let target = match crate::list_nav::resolve(&stops, from, key, true) {
755 crate::list_nav::Move::To(to) => Some(to),
756 _ => None,
757 };
758 if let Some(to) = target {
759 if let Line::Command(ix) = keys_lines[to] {
760 let key = keys_items[ix].key.clone();
761 keys_cursor.update(cx, |c, cx| {
762 *c = Some(key);
763 cx.notify();
764 });
765 keys_scroll.scroll_to_item(to);
766 }
767 }
768 cx.stop_propagation();
769 }
770 "enter" => {
771 if let Some(Line::Command(ix)) = from.map(|at| keys_lines[at]) {
772 keys_run(ix, window, cx);
773 }
774 cx.stop_propagation();
775 }
776 _ => {}
777 }
778 })
779 .child(search)
780 .child(list);
781 let panel = util::apply_sx(panel, &self.sx);
782 let panel = if exiting {
783 panel
784 } else {
785 util::dismiss_on_press_outside_with_token(panel, token.clone(), {
786 let dismiss = dismiss.clone();
787 move |window, cx| dismiss(window, cx)
788 })
789 };
790
791 let focus_scope = window
792 .use_keyed_state(element_id::scoped(&base, "scope"), cx, |_, cx| {
793 cx.focus_handle()
794 })
795 .read(cx)
796 .clone();
797 let mut overlay = util::trap_tab(
798 div()
799 .id(element_id::scoped(&base, "overlay"))
800 .track_focus(&focus_scope),
801 &focus_scope,
802 )
803 .absolute()
804 .inset_0()
805 .flex()
806 .flex_col()
807 .items_center()
808 .pt(window.viewport_size().height * 0.15)
809 .px(px(16.));
810 overlay = util::dismiss_on_escape_with_token(overlay, token, move |window, cx| {
811 dismiss(window, cx)
812 });
813 let scrim = div()
814 .id(element_id::scoped(&base, "backdrop"))
815 .absolute()
816 .inset_0()
817 .bg(colors.backdrop);
818 overlay = overlay.child(if exiting {
819 anim::exiting(
820 scrim,
821 element_id::scoped(&base, "backdrop-out"),
822 anim::ZoomBox::default(),
823 anim::Motion::BACKDROP_OUT,
824 cx,
825 )
826 } else {
827 anim::entering(
828 scrim,
829 element_id::scoped(&base, "backdrop-in"),
830 anim::Motion::BACKDROP_IN,
831 cx,
832 )
833 });
834 let zoom = anim::ZoomBox {
835 width: Some(self.width),
836 radius: Some(radius),
837 ..Default::default()
838 };
839 overlay = overlay.child(if exiting {
840 anim::exiting(
841 panel,
842 element_id::scoped(&base, "panel-out"),
843 zoom,
844 anim::Motion::PANEL_OUT,
845 cx,
846 )
847 } else {
848 anim::entering_zoom(
849 panel,
850 element_id::scoped(&base, "panel-in"),
851 zoom,
852 anim::Motion::PANEL_IN,
853 cx,
854 )
855 });
856 util::window_overlay(overlay, window).into_any_element()
857 }
858}
859
860fn selector_base(id: &ElementId) -> String {
862 match id {
863 ElementId::Name(name) => name.to_string(),
864 other => format!("{other:?}"),
865 }
866}
867
868#[cfg(test)]
869mod tests {
870 use super::*;
871
872 fn items() -> Vec<CommandItem> {
873 vec![
874 CommandItem::new("new", "New file").group("File"),
875 CommandItem::new("theme", "Toggle theme").keywords(["dark", "light"]),
876 CommandItem::new("open", "Open file").group("File"),
877 CommandItem::new("close", "Close window").group("Window"),
878 ]
879 }
880
881 #[test]
882 fn groups_keep_first_appearance_order_and_ungrouped_rows_have_no_heading() {
883 let items = items();
884 let (lines, headings) = group_lines(&items, &[0, 1, 2, 3]);
885 assert_eq!(headings, ["File", "Window"]);
886 assert_eq!(
887 lines,
888 [
889 Line::Heading(0),
890 Line::Command(0),
891 Line::Command(2),
892 Line::Command(1),
893 Line::Heading(1),
894 Line::Command(3),
895 ]
896 );
897 }
898
899 #[test]
900 fn every_query_word_must_match_a_field() {
901 let filter = Filter::new(Sensitivity::Base);
902 let theme = items()[1].search_text();
903 assert!(default_matches(&filter, &theme, "toggle DARK"));
904 assert!(!default_matches(&filter, &theme, "toggle window"));
905 let new = items()[0].search_text();
906 assert!(default_matches(&filter, &new, "file new"));
907 assert!(!default_matches(&filter, &theme, "themedark"));
909 }
910
911 #[test]
912 fn the_shortcut_is_secondary_k_alone() {
913 let parse = |s: &str| gpui::Keystroke::parse(s).unwrap();
914 assert!(is_command_palette_shortcut(&parse("secondary-k")));
915 assert!(!is_command_palette_shortcut(&parse("k")));
916 assert!(!is_command_palette_shortcut(&parse("secondary-shift-k")));
917 assert!(!is_command_palette_shortcut(&parse("secondary-j")));
918 }
919}
920
921crate::util::impl_component_styled!(CommandPalette);