Skip to main content

guise/overlay/
spotlight.rs

1//! `Spotlight` — a command palette (gpui entity).
2//!
3//! A centered overlay with a search field and a keyboard-navigable command list.
4//! Type to filter, ↑/↓ to move the highlight, Enter to run, Esc to dismiss.
5//! Built on the same deferred-backdrop pattern as [`Modal`](super::Modal) plus
6//! the [`TextEdit`](crate::TextEdit) query model.
7
8use gpui::prelude::*;
9use gpui::{
10    deferred, div, px, App, Context, FocusHandle, IntoElement, KeyDownEvent, SharedString, Window,
11};
12
13use crate::icon::{Icon, IconName};
14use crate::input::TextEdit;
15use crate::theme::{theme, Size};
16
17type CommandHandler = Box<dyn Fn(&mut Window, &mut App) + 'static>;
18
19struct Command {
20    label: SharedString,
21    hint: Option<SharedString>,
22    handler: Option<CommandHandler>,
23}
24
25/// A command palette. Create with `cx.new(|cx| Spotlight::new(cx))`, register
26/// commands with [`Spotlight::item`], and open it from an action.
27pub struct Spotlight {
28    open: bool,
29    focus: FocusHandle,
30    query: TextEdit,
31    commands: Vec<Command>,
32    selected: usize,
33}
34
35impl Spotlight {
36    pub fn new(cx: &mut Context<Self>) -> Self {
37        Spotlight {
38            open: false,
39            focus: cx.focus_handle(),
40            query: TextEdit::new(""),
41            commands: Vec::new(),
42            selected: 0,
43        }
44    }
45
46    /// Register a command with the action to run when it is chosen.
47    pub fn item(
48        mut self,
49        label: impl Into<SharedString>,
50        handler: impl Fn(&mut Window, &mut App) + 'static,
51    ) -> Self {
52        self.commands.push(Command {
53            label: label.into(),
54            hint: None,
55            handler: Some(Box::new(handler)),
56        });
57        self
58    }
59
60    /// Register a command with a trailing hint (e.g. a shortcut).
61    pub fn item_hint(
62        mut self,
63        label: impl Into<SharedString>,
64        hint: impl Into<SharedString>,
65        handler: impl Fn(&mut Window, &mut App) + 'static,
66    ) -> Self {
67        self.commands.push(Command {
68            label: label.into(),
69            hint: Some(hint.into()),
70            handler: Some(Box::new(handler)),
71        });
72        self
73    }
74
75    pub fn is_open(&self) -> bool {
76        self.open
77    }
78
79    pub fn open(&mut self, window: &mut Window, cx: &mut Context<Self>) {
80        self.open = true;
81        self.query = TextEdit::new("");
82        self.selected = 0;
83        window.focus(&self.focus);
84        cx.notify();
85    }
86
87    pub fn close(&mut self, cx: &mut Context<Self>) {
88        self.open = false;
89        cx.notify();
90    }
91
92    fn filtered(&self) -> Vec<usize> {
93        let q = self.query.text().to_lowercase();
94        self.commands
95            .iter()
96            .enumerate()
97            .filter(|(_, c)| q.is_empty() || c.label.to_lowercase().contains(&q))
98            .map(|(i, _)| i)
99            .collect()
100    }
101
102    fn on_key(&mut self, event: &KeyDownEvent, window: &mut Window, cx: &mut Context<Self>) {
103        let ks = &event.keystroke;
104        if ks.modifiers.platform || ks.modifiers.control {
105            return;
106        }
107        match ks.key.as_str() {
108            "escape" => self.open = false,
109            "up" => self.selected = self.selected.saturating_sub(1),
110            "down" => {
111                let n = self.filtered().len();
112                if n > 0 {
113                    self.selected = (self.selected + 1).min(n - 1);
114                }
115            }
116            "enter" => {
117                let filtered = self.filtered();
118                if let Some(&idx) = filtered.get(self.selected) {
119                    self.open = false;
120                    if let Some(handler) = &self.commands[idx].handler {
121                        handler(window, cx);
122                    }
123                }
124            }
125            "backspace" => {
126                self.query.backspace();
127                self.selected = 0;
128            }
129            _ => {
130                if let Some(text) = ks
131                    .key_char
132                    .as_deref()
133                    .filter(|t| !t.is_empty() && !ks.modifiers.alt)
134                {
135                    self.query.insert(text);
136                    self.selected = 0;
137                }
138            }
139        }
140        cx.notify();
141        cx.stop_propagation();
142    }
143}
144
145impl Render for Spotlight {
146    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
147        let mut root = div();
148        if !self.open {
149            return root;
150        }
151
152        let t = theme(cx);
153        let radius = t.radius(Size::Md);
154        let surface = t.surface().hsla();
155        let border = t.border().hsla();
156        let text_color = t.text().hsla();
157        let dimmed = t.dimmed().hsla();
158        let caret = t.primary().hsla();
159        let selected_bg = t.primary().alpha(0.14);
160        let scrim = t.black.alpha(0.45);
161        let font = t.font_size(Size::Md);
162        let font_sm = t.font_size(Size::Sm);
163        let viewport = window.viewport_size();
164
165        let (before, after) = self.query.split();
166        let query_text = self.query.text();
167        let search = div()
168            .flex()
169            .items_center()
170            .gap(px(8.0))
171            .px(px(14.0))
172            .h(px(48.0))
173            .border_b_1()
174            .border_color(border)
175            .child(Icon::new(IconName::Search).color(crate::theme::ColorName::Gray))
176            .child(if query_text.is_empty() {
177                div()
178                    .flex()
179                    .items_center()
180                    .text_color(dimmed)
181                    .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
182                    .child(SharedString::new_static("Type a command…"))
183            } else {
184                div()
185                    .flex()
186                    .items_center()
187                    .text_color(text_color)
188                    .child(SharedString::from(before))
189                    .child(div().w(px(1.0)).h(px(font * 1.15)).bg(caret))
190                    .child(SharedString::from(after))
191            });
192
193        let filtered = self.filtered();
194        let mut list = div().flex().flex_col().gap(px(2.0)).p(px(6.0));
195        if filtered.is_empty() {
196            list = list.child(
197                div()
198                    .px(px(10.0))
199                    .py(px(8.0))
200                    .text_size(px(font_sm))
201                    .text_color(dimmed)
202                    .child(SharedString::new_static("No commands")),
203            );
204        }
205        for (j, idx) in filtered.iter().enumerate() {
206            let idx = *idx;
207            let is_active = j == self.selected;
208            let command = &self.commands[idx];
209            let mut row = div()
210                .id(("guise-spotlight-item", idx))
211                .flex()
212                .items_center()
213                .justify_between()
214                .px(px(10.0))
215                .py(px(8.0))
216                .rounded(px(6.0))
217                .text_size(px(font_sm))
218                .text_color(text_color)
219                .child(command.label.clone())
220                .on_click(cx.listener(move |this, _ev, window, cx| {
221                    this.open = false;
222                    if let Some(handler) = &this.commands[idx].handler {
223                        handler(window, cx);
224                    }
225                    cx.notify();
226                }));
227            if let Some(hint) = command.hint.clone() {
228                row = row.child(div().text_size(px(font_sm)).text_color(dimmed).child(hint));
229            }
230            if is_active {
231                row = row.bg(selected_bg);
232            }
233            list = list.child(row);
234        }
235
236        let panel = div()
237            .id("guise-spotlight-panel")
238            .occlude()
239            .track_focus(&self.focus)
240            .on_key_down(cx.listener(Self::on_key))
241            .on_click(|_ev, _window, cx| cx.stop_propagation())
242            .mt(px(80.0))
243            .w(px(560.0))
244            .flex()
245            .flex_col()
246            .bg(surface)
247            .rounded(px(radius))
248            .border_1()
249            .border_color(border)
250            .shadow_xl()
251            .child(search)
252            .child(list);
253
254        let backdrop = div()
255            .id("guise-spotlight-backdrop")
256            .occlude()
257            .absolute()
258            .top(px(0.0))
259            .left(px(0.0))
260            .w(viewport.width)
261            .h(viewport.height)
262            .flex()
263            .justify_center()
264            .items_start()
265            .bg(scrim)
266            .on_click(cx.listener(|this, _ev, _window, cx| {
267                this.open = false;
268                cx.notify();
269            }))
270            .child(panel);
271
272        root = root.child(deferred(backdrop));
273        root
274    }
275}