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