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