1mod item;
9
10use std::rc::Rc;
11
12use gpui_kit::{
13 AnyElement, App, AppContext as _, Context, ElementId, Entity, EventEmitter, FocusHandle,
14 Focusable, InteractiveElement as _, IntoElement, KeyBinding, ListAlignment, ListState,
15 ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _,
16 StyleRefinement, Styled, Subscription, Task, Window,
17 base::{
18 StyledExt as _, TestSupportExt as _,
19 actions::{Cancel, Confirm, SelectDown, SelectUp},
20 input::{InputEvent, InputState},
21 v_flex,
22 },
23 div,
24 prelude::FluentBuilder as _,
25};
26
27use item::Row;
28pub use item::{CommandEntry, CommandGroup, CommandItem, CommandRow};
29
30use crate::{
31 ActiveTheme as _, ScrollArea, Theme,
32 menu::{
33 MenuLook, label_block, line_slot, row_frame, row_line, search_row, search_style, separator,
34 shortcut,
35 },
36};
37
38const DEFAULT_EMPTY: &str = "No results.";
40
41const CONTEXT: &str = "GpuiCnCommand";
44
45pub(crate) fn init(cx: &mut App) {
46 cx.bind_keys([
47 KeyBinding::new("up", SelectUp, Some(CONTEXT)),
48 KeyBinding::new("down", SelectDown, Some(CONTEXT)),
49 KeyBinding::new("enter", Confirm { secondary: false }, Some(CONTEXT)),
50 KeyBinding::new("escape", Cancel, Some(CONTEXT)),
51 ]);
52}
53
54pub type CommandSearchHandler =
58 Rc<dyn Fn(SharedString, &mut Context<CommandState>) -> Task<Vec<CommandEntry>>>;
59
60type Pending = (Box<dyn gpui_kit::Action>, FocusHandle);
62
63#[derive(Clone, Debug, PartialEq, Eq)]
65pub enum CommandEvent {
66 QueryChanged(SharedString),
68 Confirmed(SharedString),
71 Submitted(SharedString),
74}
75
76pub struct CommandState {
84 input: Entity<InputState>,
85 entries: Vec<CommandEntry>,
86 rows: Vec<Row>,
87 highlighted: Option<usize>,
88 list: ListState,
89 handler: Option<CommandSearchHandler>,
90 search: Option<Task<()>>,
93 _subscriptions: Vec<Subscription>,
94}
95
96impl EventEmitter<CommandEvent> for CommandState {}
97
98impl Focusable for CommandState {
100 fn focus_handle(&self, cx: &App) -> FocusHandle {
101 self.input.read(cx).focus_handle(cx)
102 }
103}
104
105impl CommandState {
106 pub fn new(
108 placeholder: impl Into<SharedString>,
109 window: &mut Window,
110 cx: &mut Context<Self>,
111 ) -> Self {
112 let style = search_style(cx.theme());
113 let input = cx.new(|cx| {
114 let mut input = InputState::new(window, cx).placeholder(placeholder);
115 input.set_editor_style(style);
116 input
117 });
118 let subscription = cx.subscribe(&input, |this, input, event: &InputEvent, cx| {
119 if matches!(event, InputEvent::Change) {
120 let query = input.read(cx).value();
121 this.query_changed(query, cx);
122 }
123 });
124 let overdraw = cx.theme().metrics.menu_max_height;
125 Self {
126 input,
127 entries: Vec::new(),
128 rows: Vec::new(),
129 highlighted: None,
130 list: ListState::new(0, ListAlignment::Top, overdraw),
131 handler: None,
132 search: None,
133 _subscriptions: vec![subscription],
134 }
135 }
136
137 pub fn with_entries(
139 mut self,
140 entries: impl IntoIterator<Item = impl Into<CommandEntry>>,
141 ) -> Self {
142 self.entries = entries.into_iter().map(Into::into).collect();
143 self.refilter("");
144 self
145 }
146
147 pub fn with_search_handler(
175 mut self,
176 handler: impl Fn(SharedString, &mut Context<Self>) -> Task<Vec<CommandEntry>> + 'static,
177 cx: &mut Context<Self>,
178 ) -> Self {
179 self.handler = Some(Rc::new(handler));
180 self.run_search(SharedString::default(), cx);
181 self
182 }
183
184 pub fn is_searching(&self) -> bool {
186 self.search.is_some()
187 }
188
189 pub fn set_entries(
194 &mut self,
195 entries: impl IntoIterator<Item = impl Into<CommandEntry>>,
196 cx: &mut Context<Self>,
197 ) {
198 self.search = None;
199 let highlighted = self.highlighted();
200 self.entries = entries.into_iter().map(Into::into).collect();
201 let query = self.query(cx);
202 self.refilter(&query);
203 if let Some(key) = highlighted
204 && let Some(row) = self.row_of(&key)
205 {
206 self.highlight(row);
207 }
208 cx.notify();
209 }
210
211 pub fn query(&self, cx: &App) -> SharedString {
213 self.input.read(cx).value()
214 }
215
216 pub fn set_query(
218 &mut self,
219 query: impl Into<SharedString>,
220 window: &mut Window,
221 cx: &mut Context<Self>,
222 ) {
223 let query = query.into();
224 if self.query(cx) == query {
225 return;
226 }
227 self.input
228 .update(cx, |input, cx| input.set_value(query.clone(), window, cx));
229 self.query_changed(query, cx);
232 }
233
234 fn query_changed(&mut self, query: SharedString, cx: &mut Context<Self>) {
236 if self.handler.is_some() {
237 self.run_search(query.clone(), cx);
238 } else {
239 self.refilter(&query);
240 }
241 cx.emit(CommandEvent::QueryChanged(query));
242 cx.notify();
243 }
244
245 fn run_search(&mut self, query: SharedString, cx: &mut Context<Self>) {
248 let Some(handler) = self.handler.clone() else {
249 return;
250 };
251 let answer = handler(query, cx);
252 self.highlighted = None;
255 self.search = Some(cx.spawn(async move |this, cx| {
256 let entries = answer.await;
257 this.update(cx, |this, cx| {
258 this.search = None;
259 this.entries = entries;
260 this.refilter("");
261 cx.notify();
262 })
263 .ok();
264 }));
265 }
266
267 pub fn highlighted(&self) -> Option<SharedString> {
269 match self.rows.get(self.highlighted?)? {
270 Row::Item(item) => Some(item.key().clone()),
271 Row::Heading(_) | Row::Separator => None,
272 }
273 }
274
275 pub fn matched_count(&self) -> usize {
277 self.rows
278 .iter()
279 .filter(|row| matches!(row, Row::Item(_)))
280 .count()
281 }
282
283 fn refilter(&mut self, query: &str) {
286 let query = if self.handler.is_none() {
288 query.trim().to_string()
289 } else {
290 String::new()
291 };
292 self.rows = item::rows(&self.entries, &query);
293 self.list.reset(self.rows.len());
294 self.highlighted = None;
295 if let Some(first) = (0..self.rows.len()).find(|row| self.is_selectable(*row)) {
296 self.highlight(first);
297 }
298 }
299
300 fn row_of(&self, key: &SharedString) -> Option<usize> {
301 self.rows.iter().position(|row| match row {
302 Row::Item(item) => item.key() == key && !item.is_disabled(),
303 Row::Heading(_) | Row::Separator => false,
304 })
305 }
306
307 fn is_selectable(&self, row: usize) -> bool {
308 matches!(self.rows.get(row), Some(Row::Item(item)) if !item.is_disabled())
309 }
310
311 fn highlight(&mut self, row: usize) {
312 self.highlighted = Some(row);
313 self.list.scroll_to_reveal_item(row);
314 }
315
316 fn move_highlight(&mut self, delta: isize, cx: &mut Context<Self>) {
318 let count = self.rows.len() as isize;
319 if count == 0 {
320 return;
321 }
322 let mut row = self
323 .highlighted
324 .map(|row| row as isize)
325 .unwrap_or(if delta > 0 { -1 } else { count });
326 for _ in 0..count {
327 row = (row + delta).rem_euclid(count);
328 if self.is_selectable(row as usize) {
329 self.highlight(row as usize);
330 cx.notify();
331 return;
332 }
333 }
334 }
335
336 fn hover_row(&mut self, row: usize, cx: &mut Context<Self>) {
338 if self.is_selectable(row) && self.highlighted != Some(row) {
339 self.highlighted = Some(row);
340 cx.notify();
341 }
342 }
343
344 fn choose(
348 &mut self,
349 row: usize,
350 window: &mut Window,
351 cx: &mut Context<Self>,
352 ) -> Option<Pending> {
353 let Some(Row::Item(item)) = self.rows.get(row).cloned() else {
354 return None;
355 };
356 if item.is_disabled() {
357 return None;
358 }
359 let pending = item.dispatched_action().map(|action| {
360 let context = item
361 .own_action_context()
362 .cloned()
363 .unwrap_or_else(|| self.input.read(cx).focus_handle(cx));
364 (action.boxed_clone(), context)
365 });
366 self.set_query("", window, cx);
367 cx.emit(CommandEvent::Confirmed(item.key().clone()));
368 pending
369 }
370
371 fn confirm(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Option<Pending> {
374 match self.highlighted {
375 Some(row) => self.choose(row, window, cx),
376 None => {
377 let query = self.query(cx);
378 if !query.trim().is_empty() {
379 self.set_query("", window, cx);
380 cx.emit(CommandEvent::Submitted(query));
381 }
382 None
383 }
384 }
385 }
386
387 fn activate(
391 this: &Entity<Self>,
392 choose: impl FnOnce(&mut Self, &mut Window, &mut Context<Self>) -> Option<Pending>,
393 window: &mut Window,
394 cx: &mut App,
395 ) {
396 if let Some((action, context)) = this.update(cx, |state, cx| choose(state, window, cx)) {
397 context.dispatch_action(action.as_ref(), window, cx);
398 }
399 }
400}
401
402#[derive(IntoElement)]
426pub struct Command {
427 id: ElementId,
428 state: Entity<CommandState>,
429 bordered: bool,
430 empty: SharedString,
431 style: StyleRefinement,
432}
433
434impl Command {
435 pub fn new(id: impl Into<ElementId>, state: &Entity<CommandState>) -> Self {
437 Self {
438 id: id.into(),
439 state: state.clone(),
440 bordered: true,
441 empty: DEFAULT_EMPTY.into(),
442 style: StyleRefinement::default(),
443 }
444 }
445
446 pub fn bordered(mut self, bordered: bool) -> Self {
448 self.bordered = bordered;
449 self
450 }
451
452 pub fn empty(mut self, text: impl Into<SharedString>) -> Self {
455 self.empty = text.into();
456 self
457 }
458}
459
460impl Styled for Command {
461 fn style(&mut self) -> &mut StyleRefinement {
462 &mut self.style
463 }
464}
465
466impl RenderOnce for Command {
467 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
468 let look = MenuLook::of(cx.theme(), window.rem_size());
469 let pointer_cursors = Theme::global(cx).pointer_cursors;
470 let state = self.state;
471 let (input, list, empty) = {
472 let state = state.read(cx);
473 (
474 state.input.clone(),
475 state.list.clone(),
476 state.rows.is_empty(),
477 )
478 };
479 let child = |name: &'static str| ElementId::NamedChild(self.id.clone().into(), name.into());
480 let body = if empty {
481 div()
482 .py_6()
483 .text_center()
484 .text_color(look.muted_foreground)
485 .child(self.empty)
486 .into_any_element()
487 } else {
488 let rows = Rows {
489 id: self.id.clone(),
490 state: state.clone(),
491 look: look.clone(),
492 pointer_cursors,
493 action_context: input.read(cx).focus_handle(cx),
494 };
495 ScrollArea::list(
496 child("rows"),
497 &list,
498 look.row_height,
499 move |row, window, cx| rows.render(row, window, cx),
500 )
501 .max_h(look.rows_max_height(true))
502 .into_any_element()
503 };
504 let update = |f: fn(&mut CommandState, &mut Context<CommandState>)| {
505 let state = state.clone();
506 move |cx: &mut App| state.update(cx, f)
507 };
508 let (up, down) = (
509 update(|state, cx| state.move_highlight(-1, cx)),
510 update(|state, cx| state.move_highlight(1, cx)),
511 );
512 v_flex()
513 .id(self.id.clone())
514 .test_support()
515 .key_context(CONTEXT)
516 .min_w(look.search_min_width)
517 .max_w(look.max_width)
518 .text_size(look.text_size)
519 .line_height(look.line_height)
520 .text_color(look.foreground)
521 .when(self.bordered, |this| {
522 this.p(look.padding)
523 .rounded(look.radius)
524 .bg(look.surface)
525 .border_1()
526 .border_color(look.border)
527 })
528 .refine_style(&self.style)
529 .on_action(move |_: &SelectUp, _, cx| up(cx))
530 .on_action(move |_: &SelectDown, _, cx| down(cx))
531 .on_action({
532 let state = state.clone();
533 move |_: &Confirm, window, cx| {
534 CommandState::activate(
535 &state,
536 |state, window, cx| state.confirm(window, cx),
537 window,
538 cx,
539 );
540 }
541 })
542 .on_action({
543 let state = state.clone();
544 move |_: &Cancel, window, cx| {
547 if state.read(cx).query(cx).is_empty() {
548 cx.propagate();
549 } else {
550 state.update(cx, |state, cx| state.set_query("", window, cx));
551 }
552 }
553 })
554 .child(
555 search_row(&input, &look, window, cx)
556 .id(child("search"))
557 .test_support(),
558 )
559 .child(separator(&look))
560 .child(body)
561 }
562}
563
564struct Rows {
567 id: ElementId,
568 state: Entity<CommandState>,
569 look: MenuLook,
570 pointer_cursors: bool,
571 action_context: FocusHandle,
574}
575
576impl Rows {
577 fn render(&self, row: usize, window: &mut Window, cx: &mut App) -> AnyElement {
578 let look = &self.look;
579 let (entry, highlighted, query) = {
580 let state = self.state.read(cx);
581 let Some(entry) = state.rows.get(row).cloned() else {
582 return div().into_any_element();
583 };
584 (entry, state.highlighted == Some(row), state.query(cx))
585 };
586 let context = &self.action_context;
587 let item = match entry {
588 Row::Separator => return separator(look),
589 Row::Heading(text) => {
590 return div()
591 .w_full()
592 .h(look.row_height)
593 .px(look.row_padding)
594 .flex()
595 .items_center()
596 .text_color(look.muted_foreground)
597 .child(text)
598 .into_any_element();
599 }
600 Row::Item(item) => item,
601 };
602 let disabled = item.is_disabled();
603 let foreground = if disabled {
604 look.muted_foreground
605 } else {
606 look.foreground
607 };
608 let shortcut = item.dispatched_action().and_then(|action| {
609 shortcut(action, item.own_action_context().unwrap_or(context), window)
610 });
611 let row_id = ElementId::NamedChild(self.id.clone().into(), item.key().clone());
612 let hover = self.state.clone();
613 let choose = self.state.clone();
614 let touch = look.touch;
615 row_frame(look, highlighted && !disabled)
616 .id(row_id)
617 .test_support()
618 .role(Role::ListBoxOption)
619 .aria_label(item.label().clone())
620 .when_some(shortcut.clone(), |this, shortcut| {
621 this.aria_keyshortcuts(shortcut)
622 })
623 .when(highlighted && !disabled, |this| {
624 this.aria_active_descendant()
625 })
626 .text_color(foreground)
627 .map(|this| {
628 if !disabled && self.pointer_cursors {
629 this.cursor_pointer()
630 } else {
631 this.cursor_default()
632 }
633 })
634 .map(|this| match item.renderer() {
635 Some(render) => this.child(render(
636 CommandRow {
637 highlighted,
638 disabled,
639 },
640 window,
641 cx,
642 )),
643 None => this.child(
644 row_line()
645 .children(item.leading_icon().cloned().map(|icon| {
646 line_slot(look)
647 .w_4()
648 .justify_center()
649 .child(icon.size_4().text_color(foreground))
650 }))
651 .child(div().flex_1().min_w_0().child(label_block(
652 item.label().clone(),
653 &crate::fuzzy::matched(&query, item.label()).unwrap_or_default(),
654 None,
655 look,
656 )))
657 .children(shortcut.map(|shortcut| {
658 line_slot(look)
659 .ml_4()
660 .text_color(look.description)
661 .child(shortcut)
662 })),
663 ),
664 })
665 .when(!touch, |this| {
668 this.on_mouse_move(move |_, _, cx| {
669 hover.update(cx, |state, cx| state.hover_row(row, cx));
670 })
671 })
672 .on_click(move |_, window, cx| {
673 CommandState::activate(
674 &choose,
675 |state, window, cx| state.choose(row, window, cx),
676 window,
677 cx,
678 );
679 })
680 .into_any_element()
681 }
682}