Skip to main content

ui/
palette.rs

1//! [`CommandPalette`] — a filtered command list over a [`input::TextField`].
2//!
3//! Stateful for the same reason the text field is: it owns a query, a filtered
4//! view of the items and an active row. It reports outcomes as gpui events
5//! rather than taking a callback, so the host decides what a selection *means*
6//! and the palette never knows about the app's actions.
7//!
8//! The state underneath is [`popover::Filter`], shared with
9//! [`crate::combobox::Combobox`] and tested there.
10//!
11//! ```ignore
12//! ui::palette::init(cx);   // once, at startup (with input::init)
13//! let palette = cx.new(|cx| CommandPalette::new(vec!["Open File".into()], cx));
14//! cx.subscribe(&palette, |_, _, event, _| match event {
15//!     PaletteEvent::Selected(index) => { /* run command `index` */ }
16//!     PaletteEvent::Dismissed => { /* unmount */ }
17//! })
18//! .detach();
19//! ```
20
21use gpui::{
22    App, Context, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window, actions,
23    div, prelude::*, px,
24};
25
26use theme::Theme;
27
28use crate::{input, popover, search::SearchList, surface::Surfaced as _};
29
30actions!(
31    bezel_command_palette,
32    [SelectNext, SelectPrevious, Confirm, Dismiss]
33);
34
35/// The key context the palette claims. It wraps the field's own context, so
36/// typing goes to the field while navigation keys fall through to here.
37pub const KEY_CONTEXT: &str = "CommandPalette";
38
39/// Install the bindings — [`bindings`], bound. Call once, alongside
40/// [`crate::input::init`].
41pub fn init(cx: &mut App) {
42    cx.bind_keys(bindings());
43}
44
45/// The palette's navigation keymap, as data, so an app can have it without having to
46/// take it — see [`crate::keys`] for layering over it or taking a chord
47/// away.
48pub fn bindings() -> Vec<KeyBinding> {
49    let mut bindings = Vec::new();
50    let ctx = Some(KEY_CONTEXT);
51    bindings.extend([
52        KeyBinding::new("down", SelectNext, ctx),
53        KeyBinding::new("up", SelectPrevious, ctx),
54        KeyBinding::new("enter", Confirm, ctx),
55        KeyBinding::new("escape", Dismiss, ctx),
56        // The emacs pair, for the same reason the field honours ctrl-b/f.
57        KeyBinding::new("ctrl-n", SelectNext, ctx),
58        KeyBinding::new("ctrl-p", SelectPrevious, ctx),
59    ]);
60
61    bindings
62}
63
64/// What the palette reports. Indices are into the ORIGINAL item list, never
65/// into the filtered view — a caller matching on a filtered index would run
66/// the wrong command the moment a query is typed.
67#[derive(Clone, Debug, PartialEq, Eq)]
68pub enum PaletteEvent {
69    Selected(usize),
70    Dismissed,
71}
72
73/// Paints its card and nothing around it: mounting, as in
74/// [`popover::modal_glass`], is the caller's. Twelve rows show and the rest
75/// scroll under the query line.
76pub struct CommandPalette {
77    search: SearchList,
78    focus_handle: FocusHandle,
79}
80
81impl EventEmitter<PaletteEvent> for CommandPalette {}
82
83impl CommandPalette {
84    pub fn new(items: Vec<SharedString>, cx: &mut Context<Self>) -> Self {
85        Self {
86            search: SearchList::new(
87                items,
88                "Type a command…",
89                |view: &mut Self| &mut view.search,
90                cx,
91            ),
92            focus_handle: cx.focus_handle(),
93        }
94    }
95
96    /// Focus the query field — call after mounting, or the palette swallows
97    /// keys without showing a caret.
98    pub fn focus(&self, window: &mut Window, cx: &mut App) {
99        window.focus(&self.search.query.focus_handle(cx), cx);
100    }
101
102    pub fn query_text(&self, cx: &App) -> SharedString {
103        self.search.query.read(cx).content().clone()
104    }
105
106    /// The item the user would get by confirming right now.
107    pub fn active_item(&self) -> Option<usize> {
108        self.search.filter.active_item()
109    }
110
111    fn choose(&mut self, item: usize, _: &mut Window, cx: &mut Context<Self>) {
112        cx.emit(PaletteEvent::Selected(item));
113    }
114
115    fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
116        self.search.filter.step(1);
117        cx.notify();
118    }
119
120    fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
121        self.search.filter.step(-1);
122        cx.notify();
123    }
124
125    fn confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
126        if let Some(item) = self.active_item() {
127            cx.emit(PaletteEvent::Selected(item));
128        }
129    }
130
131    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
132        cx.emit(PaletteEvent::Dismissed);
133    }
134}
135
136impl Focusable for CommandPalette {
137    /// The field holds focus; the palette is the context around it.
138    fn focus_handle(&self, _: &App) -> FocusHandle {
139        self.focus_handle.clone()
140    }
141}
142
143impl Render for CommandPalette {
144    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
145        let theme = Theme::of(cx).clone();
146        let card = popover::popover_card(&theme)
147            .w(px(420.0))
148            .child(self.search.body(
149                &theme,
150                None,
151                None,
152                |view| &mut view.search,
153                Self::choose,
154                cx,
155            ));
156
157        // The actions live on a wrapper, not the card, because the card is
158        // handed to `material` — which frosts the backdrop so the content
159        // behind the palette blurs instead of reading through it.
160        div()
161            .key_context(KEY_CONTEXT)
162            .track_focus(&self.focus_handle)
163            .on_action(cx.listener(Self::select_next))
164            .on_action(cx.listener(Self::select_previous))
165            .on_action(cx.listener(Self::confirm))
166            .on_action(cx.listener(Self::dismiss))
167            .child(card.surface(&theme, theme.popover_surface))
168    }
169}
170
171/// Re-exported so a host can bind its own "open palette" chord without
172/// depending on gpui's action macros directly.
173pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;