Skip to main content

ui/
palette.rs

1//! [`CommandPalette`] — a filtered command list over a [`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, Entity, EventEmitter, FocusHandle, Focusable, KeyBinding, SharedString, Window,
23    actions, div, prelude::*, px,
24};
25
26use motion::{Fade, Painter};
27use theme::Theme;
28
29use crate::{
30    input::{self, TextField},
31    material::Frosted as _,
32    popover,
33};
34
35actions!(
36    bezel_command_palette,
37    [SelectNext, SelectPrevious, Confirm, Dismiss]
38);
39
40/// The key context the palette claims. It wraps the field's own context, so
41/// typing goes to the field while navigation keys fall through to here.
42pub const KEY_CONTEXT: &str = "CommandPalette";
43
44/// Install the palette's navigation bindings. Call once, alongside
45/// [`crate::input::init`].
46pub fn init(cx: &mut App) {
47    let ctx = Some(KEY_CONTEXT);
48    cx.bind_keys([
49        KeyBinding::new("down", SelectNext, ctx),
50        KeyBinding::new("up", SelectPrevious, ctx),
51        KeyBinding::new("enter", Confirm, ctx),
52        KeyBinding::new("escape", Dismiss, ctx),
53        // The emacs pair, for the same reason the field honours ctrl-b/f.
54        KeyBinding::new("ctrl-n", SelectNext, ctx),
55        KeyBinding::new("ctrl-p", SelectPrevious, ctx),
56    ]);
57}
58
59/// What the palette reports. Indices are into the ORIGINAL item list, never
60/// into the filtered view — a caller matching on a filtered index would run
61/// the wrong command the moment a query is typed.
62#[derive(Clone, Debug, PartialEq, Eq)]
63pub enum PaletteEvent {
64    Selected(usize),
65    Dismissed,
66}
67
68pub struct CommandPalette {
69    query: Entity<TextField>,
70    filter: popover::Filter,
71    focus_handle: FocusHandle,
72}
73
74impl EventEmitter<PaletteEvent> for CommandPalette {}
75
76impl CommandPalette {
77    pub fn new(items: Vec<SharedString>, cx: &mut Context<Self>) -> Self {
78        let query = cx.new(|cx| TextField::new(cx).with_placeholder("Type a command…"));
79        // Re-filter whenever the field's content changes.
80        cx.observe(&query, |palette, _, cx| {
81            palette.refilter(cx);
82        })
83        .detach();
84        Self {
85            query,
86            filter: popover::Filter::new(items),
87            focus_handle: cx.focus_handle(),
88        }
89    }
90
91    /// Focus the query field — call after mounting, or the palette swallows
92    /// keys without showing a caret.
93    pub fn focus(&self, window: &mut Window, cx: &mut App) {
94        window.focus(&self.query.focus_handle(cx), cx);
95    }
96
97    pub fn query_text(&self, cx: &App) -> SharedString {
98        self.query.read(cx).content().clone()
99    }
100
101    /// The item the user would get by confirming right now.
102    pub fn active_item(&self) -> Option<usize> {
103        self.filter.active_item()
104    }
105
106    fn refilter(&mut self, cx: &mut Context<Self>) {
107        let query = self.query.read(cx).content().clone();
108        self.filter.refilter(&query);
109        cx.notify();
110    }
111
112    fn select_next(&mut self, _: &SelectNext, _: &mut Window, cx: &mut Context<Self>) {
113        self.filter.step(1);
114        cx.notify();
115    }
116
117    fn select_previous(&mut self, _: &SelectPrevious, _: &mut Window, cx: &mut Context<Self>) {
118        self.filter.step(-1);
119        cx.notify();
120    }
121
122    fn confirm(&mut self, _: &Confirm, _: &mut Window, cx: &mut Context<Self>) {
123        if let Some(item) = self.active_item() {
124            cx.emit(PaletteEvent::Selected(item));
125        }
126    }
127
128    fn dismiss(&mut self, _: &Dismiss, _: &mut Window, cx: &mut Context<Self>) {
129        cx.emit(PaletteEvent::Dismissed);
130    }
131}
132
133impl Focusable for CommandPalette {
134    /// The field holds focus; the palette is the context around it.
135    fn focus_handle(&self, _: &App) -> FocusHandle {
136        self.focus_handle.clone()
137    }
138}
139
140impl Render for CommandPalette {
141    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
142        let theme = Theme::of(cx).clone();
143        let view = Painter::of(cx);
144        let rows: Vec<gpui::AnyElement> = self
145            .filter
146            .filtered()
147            .iter()
148            .enumerate()
149            .map(|(position, &item)| {
150                popover::menu_row(
151                    &theme,
152                    Some(position) == self.filter.active(),
153                    Fade::new(view, format!("palette-row-{item}")),
154                )
155                .id(SharedString::from(format!("palette-{item}")))
156                .on_click(cx.listener(move |_, _, _, cx| {
157                    cx.emit(PaletteEvent::Selected(item));
158                }))
159                .child(self.filter.items()[item].clone())
160                .into_any_element()
161            })
162            .collect();
163
164        let card = popover::popover_card(&theme)
165            .w(px(420.0))
166            .child(popover::search_input_frame(
167                &theme,
168                self.query.clone().into_any_element(),
169            ))
170            .child(if rows.is_empty() {
171                div()
172                    .px(px(10.0))
173                    .py(px(8.0))
174                    .text_size(px(13.0))
175                    .text_color(theme.text_muted)
176                    .child("No matches")
177                    .into_any_element()
178            } else {
179                div().flex().flex_col().children(rows).into_any_element()
180            });
181
182        // The actions live on a wrapper, not the card, because the card is
183        // handed to `material` — which frosts the backdrop so the content
184        // behind the palette blurs instead of reading through it.
185        div()
186            .key_context(KEY_CONTEXT)
187            .track_focus(&self.focus_handle)
188            .on_action(cx.listener(Self::select_next))
189            .on_action(cx.listener(Self::select_previous))
190            .on_action(cx.listener(Self::confirm))
191            .on_action(cx.listener(Self::dismiss))
192            .child(card.material(crate::material::MENU_BLUR))
193    }
194}
195
196/// Re-exported so a host can bind its own "open palette" chord without
197/// depending on gpui's action macros directly.
198pub use input::KEY_CONTEXT as FIELD_KEY_CONTEXT;