ui/components/command_palette/
palette.rs1use std::rc::Rc;
2
3use gpui::{
4 AnyElement, App, Context, Entity, Focusable, IntoElement, KeyDownEvent, Pixels, Render, Window,
5 div, px, rgb,
6};
7
8use crate::{IconName, List, ListItem, TextInput, overlay_backdrop, overlay_panel, prelude::*};
9
10use super::fuzzy::score;
11
12pub const COMMAND_PALETTE_WIDTH: Pixels = px(600.);
14
15pub struct CommandPaletteItem {
17 label: SharedString,
18 subtitle: Option<SharedString>,
19 icon: Option<IconName>,
20 keybinding: Option<SharedString>,
21 on_select: Rc<dyn Fn(&mut Window, &mut App)>,
22}
23
24impl CommandPaletteItem {
25 pub fn new(
26 label: impl Into<SharedString>,
27 on_select: impl Fn(&mut Window, &mut App) + 'static,
28 ) -> Self {
29 Self {
30 label: label.into(),
31 subtitle: None,
32 icon: None,
33 keybinding: None,
34 on_select: Rc::new(on_select),
35 }
36 }
37
38 pub fn subtitle(mut self, subtitle: impl Into<SharedString>) -> Self {
39 self.subtitle = Some(subtitle.into());
40 self
41 }
42
43 pub fn icon(mut self, icon: IconName) -> Self {
44 self.icon = Some(icon);
45 self
46 }
47
48 pub fn keybinding(mut self, keybinding: impl Into<SharedString>) -> Self {
49 self.keybinding = Some(keybinding.into());
50 self
51 }
52}
53
54pub struct CommandPalette {
72 query_input: Entity<TextInput>,
73 items: Vec<CommandPaletteItem>,
74 selected_ix: usize,
75 on_dismiss: Option<Rc<dyn Fn(&mut Window, &mut App)>>,
76}
77
78impl CommandPalette {
79 pub fn new(cx: &mut Context<Self>, items: Vec<CommandPaletteItem>) -> Self {
80 let query_input = cx.new(|cx| TextInput::new(cx).placeholder("Type a command or search…"));
81
82 cx.observe(&query_input, |this, _, cx| {
83 this.selected_ix = 0;
84 cx.notify();
85 })
86 .detach();
87
88 Self {
89 query_input,
90 items,
91 selected_ix: 0,
92 on_dismiss: None,
93 }
94 }
95
96 pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
99 self.on_dismiss = Some(Rc::new(handler));
100 self
101 }
102
103 pub fn focus_input(&self, window: &mut Window, cx: &mut App) {
106 let focus_handle = self.query_input.read(cx).focus_handle(cx);
107 window.focus(&focus_handle, cx);
108 }
109
110 fn matched_indices(&self, cx: &App) -> Vec<usize> {
114 let query = self.query_input.read(cx).text();
115 let mut matches: Vec<(usize, i32)> = self
116 .items
117 .iter()
118 .enumerate()
119 .filter_map(|(ix, item)| score(query, item.label.as_ref()).map(|s| (ix, s)))
120 .collect();
121 matches.sort_by(|a, b| b.1.cmp(&a.1));
122 matches.into_iter().map(|(ix, _)| ix).collect()
123 }
124
125 fn handle_key_down(
126 &mut self,
127 event: &KeyDownEvent,
128 window: &mut Window,
129 cx: &mut Context<Self>,
130 ) {
131 let matched = self.matched_indices(cx);
132
133 match event.keystroke.key.as_str() {
134 "up" if !matched.is_empty() => {
135 self.selected_ix = if self.selected_ix == 0 {
136 matched.len() - 1
137 } else {
138 self.selected_ix - 1
139 };
140 cx.notify();
141 }
142 "down" if !matched.is_empty() => {
143 self.selected_ix = (self.selected_ix + 1) % matched.len();
144 cx.notify();
145 }
146 "enter" => {
147 if let Some(item_ix) = matched.get(self.selected_ix).copied() {
148 let on_select = self.items[item_ix].on_select.clone();
149 on_select(window, cx);
150 }
151 }
152 "escape" => {
153 if let Some(on_dismiss) = self.on_dismiss.clone() {
154 on_dismiss(window, cx);
155 }
156 }
157 _ => {}
158 }
159 }
160
161 fn render_row(&self, item_ix: usize, is_selected: bool) -> AnyElement {
162 let item = &self.items[item_ix];
163 let on_select = item.on_select.clone();
164
165 ListItem::new(("command-palette-item", item_ix))
166 .focused(is_selected)
167 .when_some(item.icon, |this, icon| this.start_slot(Icon::new(icon)))
168 .child(
169 v_flex()
170 .gap_0p5()
171 .child(Label::new(item.label.clone()))
172 .when_some(item.subtitle.clone(), |this, subtitle| {
173 this.child(
174 Label::new(subtitle)
175 .size(LabelSize::Small)
176 .color(Color::Muted),
177 )
178 }),
179 )
180 .when_some(item.keybinding.clone(), |this, keybinding| {
181 this.end_slot(kbd_badge(keybinding))
182 })
183 .on_click(move |_, window, cx| on_select(window, cx))
184 .into_any_element()
185 }
186
187 fn render_query_row(&self) -> impl IntoElement {
188 h_flex()
189 .w_full()
190 .items_center()
191 .justify_between()
192 .gap_3()
193 .px(px(20.))
194 .py(px(16.))
195 .border_b_1()
196 .border_color(rgb(0x2A313B))
197 .child(
198 div()
199 .flex_1()
200 .text_size(px(15.))
201 .child(self.query_input.clone()),
202 )
203 .child(kbd_badge("ESC"))
204 }
205}
206
207impl Render for CommandPalette {
208 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
209 let matched = self.matched_indices(cx);
210 if self.selected_ix >= matched.len() {
211 self.selected_ix = 0;
212 }
213 let selected_ix = self.selected_ix;
214
215 let rows: Vec<AnyElement> = matched
216 .iter()
217 .enumerate()
218 .map(|(row_ix, item_ix)| self.render_row(*item_ix, row_ix == selected_ix))
219 .collect();
220
221 overlay_backdrop()
222 .on_key_down(cx.listener(Self::handle_key_down))
223 .child(
224 overlay_panel()
225 .w(COMMAND_PALETTE_WIDTH)
226 .max_w(vw(0.9, window))
227 .max_h(px(420.))
228 .child(self.render_query_row())
229 .child(
230 div()
231 .id("command-palette-results")
232 .flex_1()
233 .overflow_y_scroll()
234 .child(
235 List::new()
236 .empty_message("No matching commands")
237 .children(rows),
238 ),
239 ),
240 )
241 }
242}
243
244fn kbd_badge(label: impl Into<SharedString>) -> impl IntoElement {
245 div()
246 .flex()
247 .items_center()
248 .justify_center()
249 .px(px(6.))
250 .h(px(20.))
251 .rounded(px(4.))
252 .bg(rgb(0x1B212A))
253 .border_1()
254 .border_color(rgb(0x2A313B))
255 .child(
256 Label::new(label.into())
257 .size(LabelSize::XSmall)
258 .color(Color::Muted),
259 )
260}