1use gpui::{
10 App, AppContext as _, Context, Entity, EventEmitter, FocusHandle, Focusable,
11 InteractiveElement, IntoElement, KeyDownEvent, MouseButton, ParentElement, Render,
12 SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div,
13 prelude::FluentBuilder, px,
14};
15use gpui_kit_semantics::{NodeSpec, Role, Semantic};
16use gpui_kit_theme::{ActiveTheme, Elevation, Space, TypeScale};
17
18use crate::controls::input::{TextInput, TextInputEvent};
19use crate::display::empty::{EmptyKind, EmptyState};
20use crate::foundation::{Ident, Pressable, StyledExt};
21use crate::motion;
22use crate::overlay::kbd::Kbd;
23use crate::overlay::layer::surface;
24use crate::overlay::popover::{self, MenuKey};
25use crate::strings::{ActiveStrings, StringKey};
26
27const PALETTE_WIDTH: f32 = 480.0;
30const RESULTS_MAX_HEIGHT: f32 = 320.0;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Command {
36 id: SharedString,
37 label: SharedString,
38 section: Option<SharedString>,
39 shortcut: Option<SharedString>,
40 unavailable: Option<SharedString>,
42}
43
44impl Command {
45 pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
46 Self {
47 id: id.into(),
48 label: label.into(),
49 section: None,
50 shortcut: None,
51 unavailable: None,
52 }
53 }
54
55 pub fn section(mut self, section: impl Into<SharedString>) -> Self {
58 self.section = Some(section.into());
59 self
60 }
61
62 pub fn shortcut(mut self, keystroke: impl Into<SharedString>) -> Self {
64 self.shortcut = Some(keystroke.into());
65 self
66 }
67
68 pub fn unavailable(mut self, reason: impl Into<SharedString>) -> Self {
72 self.unavailable = Some(reason.into());
73 self
74 }
75
76 pub fn id(&self) -> &SharedString {
77 &self.id
78 }
79
80 pub fn label(&self) -> &SharedString {
81 &self.label
82 }
83
84 pub fn reason(&self) -> Option<&SharedString> {
85 self.unavailable.as_ref()
86 }
87
88 pub fn is_available(&self) -> bool {
89 self.unavailable.is_none()
90 }
91}
92
93#[derive(Debug, Clone, PartialEq, Eq)]
95pub enum CommandPaletteEvent {
96 QueryChanged(SharedString),
97 Invoked(SharedString),
99 Dismissed,
102}
103
104impl EventEmitter<CommandPaletteEvent> for CommandPalette {}
105
106fn order_matches(commands: &[Command], query: &str) -> Vec<usize> {
111 let ranked: Vec<(usize, usize)> = commands
112 .iter()
113 .enumerate()
114 .filter_map(|(index, command)| {
115 popover::match_rank(query, command.label.as_ref()).map(|rank| (rank, index))
116 })
117 .collect();
118
119 let section_of = |index: usize| commands[index].section.clone().unwrap_or_default();
120 let mut sections: Vec<(SharedString, usize, usize)> = Vec::new();
121 for &(rank, index) in &ranked {
122 let name = section_of(index);
123 match sections.iter_mut().find(|(known, _, _)| *known == name) {
124 Some(section) => section.1 = section.1.min(rank),
125 None => sections.push((name, rank, index)),
126 }
127 }
128 sections.sort_by_key(|(_, rank, first)| (*rank, *first));
129
130 let mut ordered = Vec::with_capacity(ranked.len());
131 for (name, _, _) in §ions {
132 let mut group: Vec<(usize, usize)> = ranked
133 .iter()
134 .copied()
135 .filter(|(_, index)| section_of(*index) == *name)
136 .collect();
137 group.sort_by_key(|&(rank, index)| (rank, index));
138 ordered.extend(group.into_iter().map(|(_, index)| index));
139 }
140 ordered
141}
142
143pub struct CommandPalette {
148 ident: Ident,
149 focus_handle: FocusHandle,
150 query: Entity<TextInput>,
151 commands: Vec<Command>,
152 active: Option<SharedString>,
155 _subscriptions: Vec<Subscription>,
157}
158
159impl std::fmt::Debug for CommandPalette {
160 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
161 formatter
162 .debug_struct("CommandPalette")
163 .field("ident", &self.ident)
164 .field("commands", &self.commands.len())
165 .field("active", &self.active)
166 .finish()
167 }
168}
169
170impl CommandPalette {
171 pub fn new(ident: impl Into<Ident>, window: &mut Window, cx: &mut Context<Self>) -> Self {
172 let ident = ident.into();
173 let query = cx.new(|cx| {
174 TextInput::new(ident.child("query"), window, cx)
175 .placeholder(cx.strings().text(StringKey::PalettePlaceholder))
176 });
177 let subscription = cx.subscribe(&query, |palette, _query, event, cx| match event {
178 TextInputEvent::Change(text) => {
179 palette.active = None;
182 cx.emit(CommandPaletteEvent::QueryChanged(text.clone()));
183 cx.notify();
184 }
185 TextInputEvent::Submit => palette.invoke(cx),
186 TextInputEvent::Cancel => cx.emit(CommandPaletteEvent::Dismissed),
187 _ => {}
188 });
189
190 Self {
191 ident,
192 focus_handle: cx.focus_handle(),
193 query,
194 commands: Vec::new(),
195 active: None,
196 _subscriptions: vec![subscription],
197 }
198 }
199
200 pub fn commands(mut self, commands: impl IntoIterator<Item = Command>) -> Self {
201 self.commands = commands.into_iter().collect();
202 self
203 }
204
205 pub fn set_commands(&mut self, commands: Vec<Command>, cx: &mut Context<Self>) {
206 self.commands = commands;
207 self.active = None;
208 cx.notify();
209 }
210
211 pub fn query(&self, cx: &App) -> SharedString {
212 self.query.read(cx).value().clone()
213 }
214
215 pub fn set_query(&mut self, query: impl Into<SharedString>, cx: &mut Context<Self>) {
218 self.query
219 .update(cx, |input, cx| input.set_value(query, cx));
220 }
221
222 pub fn query_input(&self) -> &Entity<TextInput> {
223 &self.query
224 }
225
226 pub fn focus_query(&self, window: &mut Window, cx: &mut App) {
228 self.query.read(cx).focus_handle(cx).focus(window, cx);
229 }
230
231 pub fn active_id(&self, cx: &App) -> Option<SharedString> {
233 let ordered = self.ordered(cx);
234 self.resolved(&ordered)
235 .map(|index| self.commands[index].id.clone())
236 }
237
238 fn ordered(&self, cx: &App) -> Vec<usize> {
239 order_matches(&self.commands, self.query.read(cx).value().as_ref())
240 }
241
242 fn resolved(&self, ordered: &[usize]) -> Option<usize> {
245 if let Some(active) = &self.active
246 && let Some(index) = ordered
247 .iter()
248 .copied()
249 .find(|index| &self.commands[*index].id == active)
250 && self.commands[index].is_available()
251 {
252 return Some(index);
253 }
254 ordered
255 .iter()
256 .copied()
257 .find(|index| self.commands[*index].is_available())
258 }
259
260 fn step(&mut self, delta: isize, cx: &mut Context<Self>) {
261 let ordered = self.ordered(cx);
262 let choosable: Vec<usize> = ordered
263 .iter()
264 .copied()
265 .filter(|index| self.commands[*index].is_available())
266 .collect();
267 if choosable.is_empty() {
268 return;
269 }
270 let current = self
271 .resolved(&ordered)
272 .and_then(|index| choosable.iter().position(|choice| *choice == index));
273 let Some(next) = popover::step(current, choosable.len(), delta) else {
274 return;
275 };
276 self.active = Some(self.commands[choosable[next]].id.clone());
277 cx.notify();
278 }
279
280 fn invoke(&mut self, cx: &mut Context<Self>) {
283 let ordered = self.ordered(cx);
284 let Some(index) = self.resolved(&ordered) else {
285 return;
286 };
287 cx.emit(CommandPaletteEvent::Invoked(
288 self.commands[index].id.clone(),
289 ));
290 }
291
292 fn choose(&mut self, id: SharedString, cx: &mut Context<Self>) {
293 self.active = Some(id.clone());
294 cx.emit(CommandPaletteEvent::Invoked(id));
295 cx.notify();
296 }
297
298 fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
299 let key = popover::classify_key(
300 event.keystroke.key.as_str(),
301 event.keystroke.modifiers.platform,
302 event.keystroke.modifiers.control,
303 );
304 match key {
305 MenuKey::Down => {
306 self.step(1, cx);
307 cx.stop_propagation();
308 }
309 MenuKey::Up => {
310 self.step(-1, cx);
311 cx.stop_propagation();
312 }
313 _ => {}
314 }
315 }
316
317 fn results(&self, cx: &mut Context<Self>) -> Vec<gpui::AnyElement> {
318 let theme = cx.theme().clone();
319 let ordered = self.ordered(cx);
320 let highlighted = self.resolved(&ordered);
321 let results_id = self.ident.child("results").semantic_id();
322 let mut section: Option<SharedString> = None;
323 let mut rows: Vec<gpui::AnyElement> = Vec::with_capacity(ordered.len());
324 let count = ordered.len();
325
326 for (position, index) in ordered.into_iter().enumerate() {
327 let command = &self.commands[index];
328 if command.section != section {
329 section = command.section.clone();
330 if let Some(name) = section.clone() {
331 rows.push(
332 popover::heading(&theme, name.as_ref())
333 .semantic_in(
334 cx,
335 NodeSpec::new(
336 self.ident
337 .child("section")
338 .child(name.as_ref())
339 .semantic_id(),
340 Role::Heading,
341 )
342 .parent(results_id.clone())
343 .level(2)
344 .text(name),
345 )
346 .into_any_element(),
347 );
348 }
349 }
350
351 let row_ident = self.ident.child(command.id.as_ref());
352 let active = highlighted == Some(index);
353 let available = command.is_available();
354 let id = command.id.clone();
355 let mut spec = NodeSpec::new(row_ident.semantic_id(), Role::MenuItem)
356 .parent(results_id.clone())
357 .text(command.label.clone())
358 .disabled(!available)
359 .hovered(active);
360 if let Some(reason) = command.reason() {
361 spec = spec.value(reason.clone());
362 }
363
364 let row = popover::menu_row(&theme, false, active)
365 .id(row_ident.element_id())
366 .when(available, |element| element.cursor_pointer().pressable(cx))
367 .when(!available, |element| {
368 element.opacity(theme.opacity.disabled)
369 })
370 .child(div().flex_1().child(command.label.clone()))
371 .children(command.reason().map(|reason| {
372 div()
373 .type_scale(&theme, TypeScale::Caption)
374 .text_color(theme.colors.warning)
375 .child(reason.clone())
376 }))
377 .children(
378 command
379 .shortcut
380 .clone()
381 .map(|keystroke| Kbd::new(keystroke).into_any_element()),
382 )
383 .when(available, |element| {
384 element.on_mouse_down(
385 MouseButton::Left,
386 cx.listener(move |palette, _, _, cx| {
387 palette.choose(id.clone(), cx);
388 }),
389 )
390 })
391 .semantic_in(cx, spec);
392
393 rows.push(
394 motion::row_in(
395 row_ident.child("in").element_id(),
396 &theme,
397 position,
398 count,
399 row,
400 )
401 .into_any_element(),
402 );
403 }
404
405 rows
406 }
407}
408
409impl Focusable for CommandPalette {
410 fn focus_handle(&self, _cx: &App) -> FocusHandle {
411 self.focus_handle.clone()
412 }
413}
414
415impl Render for CommandPalette {
416 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
417 let theme = cx.theme().clone();
418 let query = self.query.read(cx).value().clone();
419 let rows = self.results(cx);
420 let results_id = self.ident.child("results").semantic_id();
421
422 let body = if rows.is_empty() {
423 EmptyState::new(
426 self.ident.child("empty"),
427 cx.strings().format(StringKey::PaletteNoMatch, &[&query]),
428 )
429 .kind(EmptyKind::Empty)
430 .detail(cx.strings().text(StringKey::PaletteEmptyDetail))
431 .into_any_element()
432 } else {
433 div()
434 .id(self.ident.child("results").element_id())
435 .flex()
436 .flex_col()
437 .max_h(px(RESULTS_MAX_HEIGHT))
438 .overflow_y_scroll()
439 .children(rows)
440 .semantic_in(cx, NodeSpec::new(results_id, Role::Menu))
441 .into_any_element()
442 };
443
444 surface(&theme, Elevation::Modal)
445 .w(px(PALETTE_WIDTH))
446 .p_token(&theme, Space::Xs)
447 .gap_token(&theme, Space::Xs)
448 .track_focus(&self.focus_handle)
449 .on_key_down(cx.listener(Self::on_key_down))
450 .child(div().p_token(&theme, Space::Xs).child(self.query.clone()))
451 .child(body)
452 .semantic_in(
453 cx,
454 NodeSpec::new(self.ident.semantic_id(), Role::Group).value(query),
455 )
456 }
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn commands() -> Vec<Command> {
464 vec![
465 Command::new("editor.split", "Split editor").section("Editor"),
466 Command::new("workspace.save", "Save workspace").section("Workspace"),
467 Command::new("editor.save", "Save file")
468 .section("Editor")
469 .shortcut("cmd-s"),
470 Command::new("workspace.publish", "Publish workspace")
471 .section("Workspace")
472 .unavailable("Approval is required"),
473 ]
474 }
475
476 #[test]
477 fn an_empty_query_lists_everything_grouped_by_section() {
478 assert_eq!(order_matches(&commands(), ""), vec![0, 2, 1, 3]);
479 }
480
481 #[test]
482 fn a_section_sits_where_its_best_match_does() {
483 assert_eq!(order_matches(&commands(), "save"), vec![1, 2]);
486 }
487
488 #[test]
489 fn a_command_the_host_refused_is_still_listed() {
490 let ordered = order_matches(&commands(), "publish");
491 assert_eq!(ordered, vec![3]);
492 assert!(!commands()[3].is_available());
493 }
494
495 #[test]
496 fn a_query_nothing_answers_orders_nothing() {
497 assert!(order_matches(&commands(), "zzz").is_empty());
498 }
499}