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