1use std::{cell::Cell, rc::Rc};
2
3use gpui::{
4 AnyElement, Bounds, Context, Entity, KeyDownEvent, Pixels, Render, anchored, canvas, deferred,
5 point,
6};
7
8use crate::utils::fuzzy_subsequence_score;
9use crate::{ListHeader, TextInput, prelude::*};
10
11#[derive(Clone)]
13pub enum CommandItem {
14 Group(SharedString),
15 Entry {
16 id: SharedString,
17 label: SharedString,
18 },
19}
20
21#[derive(RegisterComponent)]
26pub struct Command {
27 items: Vec<CommandItem>,
28 input: Entity<TextInput>,
29 selected: usize,
30 open: bool,
31 last_query: String,
32 trigger_bounds: Rc<Cell<Option<Bounds<Pixels>>>>,
33}
34
35impl Command {
36 pub fn new(cx: &mut Context<Self>, items: Vec<CommandItem>) -> Self {
37 let input = cx.new(|cx| TextInput::new(cx).placeholder("Type a command or search…"));
38 cx.observe(&input, |_, _, cx| cx.notify()).detach();
39 Self {
40 items,
41 input,
42 selected: 0,
43 open: true,
44 last_query: String::new(),
45 trigger_bounds: Rc::new(Cell::new(None)),
46 }
47 }
48
49 fn filtered_indices(&self, cx: &App) -> Vec<usize> {
50 let query = self.input.read(cx).text();
51 let mut matches: Vec<(usize, usize)> = self
52 .items
53 .iter()
54 .enumerate()
55 .filter_map(|(i, item)| match item {
56 CommandItem::Group(_) => None,
57 CommandItem::Entry { label, .. } => {
58 fuzzy_subsequence_score(query, label).map(|score| (i, score))
59 }
60 })
61 .collect();
62 matches.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
63 matches.into_iter().map(|(i, _)| i).collect()
64 }
65
66 fn selectable_count(&self, cx: &App) -> usize {
67 self.filtered_indices(cx).len()
68 }
69
70 fn clamp_selected(&mut self, cx: &App) {
71 let count = self.selectable_count(cx);
72 if count == 0 {
73 self.selected = 0;
74 } else {
75 self.selected = self.selected.min(count - 1);
76 }
77 }
78
79 fn on_key_down(&mut self, event: &KeyDownEvent, _window: &mut Window, cx: &mut Context<Self>) {
80 let count = self.selectable_count(cx);
81 if count == 0 {
82 return;
83 }
84 match event.keystroke.key.as_str() {
85 "up" => {
86 self.selected = self.selected.saturating_sub(1);
87 cx.notify();
88 }
89 "down" => {
90 self.selected = (self.selected + 1).min(count - 1);
91 cx.notify();
92 }
93 "enter" => {
94 cx.notify();
96 }
97 _ => {}
98 }
99 }
100
101 pub fn selected_entry(&self, cx: &App) -> Option<&CommandItem> {
103 self.filtered_indices(cx)
104 .get(self.selected)
105 .and_then(|&i| self.items.get(i))
106 }
107}
108
109impl Render for Command {
110 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
121 let query = self.input.read(cx).text().to_string();
122 if query != self.last_query {
123 self.last_query = query;
124 self.selected = 0;
125 }
126 self.clamp_selected(cx);
127 let open = self.open;
128 let filtered = self.filtered_indices(cx);
129 let selected = self.selected;
130 let items = self.items.clone();
131
132 let trigger = h_flex()
133 .id("command-trigger")
134 .w_full()
135 .items_center()
136 .px_3()
137 .py_2()
138 .rounded_md()
139 .bg(semantic::surface(cx))
140 .border_1()
141 .border_color(semantic::border(cx))
142 .child(div().flex_1().min_w_0().child(self.input.clone()))
143 .child(
144 div()
145 .id("command-toggle")
146 .cursor_pointer()
147 .child(Icon::new(IconName::MagnifyingGlass).size(IconSize::Small))
148 .on_click(cx.listener(|this, _, _, cx| {
149 this.open = !this.open;
150 cx.notify();
151 })),
152 )
153 .on_key_down(cx.listener(Self::on_key_down))
154 .child({
155 let trigger_bounds = self.trigger_bounds.clone();
156 canvas(
157 move |bounds, _window, _cx| trigger_bounds.set(Some(bounds)),
158 |_bounds, _state, _window, _cx| {},
159 )
160 .absolute()
161 .top_0()
162 .left_0()
163 .size_full()
164 });
165
166 let trigger_width = px(360.);
167 let hover = semantic::hover_bg(cx);
168
169 v_flex()
170 .w(trigger_width)
171 .gap_1()
172 .child(trigger)
173 .when(open, |this| {
174 let mut list = v_flex()
175 .w(trigger_width)
176 .p_1()
177 .rounded_md()
178 .bg(semantic::elevated_surface(cx))
179 .border_1()
180 .border_color(semantic::border(cx))
181 .shadow_level(Shadow::Lg)
182 .max_h(px(280.));
183
184 if filtered.is_empty() {
185 list = list.child(
186 div()
187 .px_3()
188 .py_2()
189 .child(Label::new("No results found.").color(Color::Muted)),
190 );
191 } else {
192 let mut selectable = 0usize;
193 let mut last_group: Option<SharedString> = None;
194
195 for (idx, &item_index) in filtered.iter().enumerate() {
196 let Some(item) = items.get(item_index) else {
197 continue;
198 };
199 let CommandItem::Entry { label, .. } = item else {
200 continue;
201 };
202
203 for (gi, group_item) in items[..item_index].iter().enumerate().rev() {
204 if let CommandItem::Group(group) = group_item {
205 if last_group.as_ref() != Some(group) {
206 last_group = Some(group.clone());
207 list = list.child(
208 ListHeader::new(group.clone())
209 .inset(true)
210 .into_any_element(),
211 );
212 }
213 break;
214 }
215 if gi == 0 {
216 break;
217 }
218 }
219
220 let is_selected = idx == selected;
221 let label = label.clone();
222 list = list.child(
223 h_flex()
224 .id(("command-item", item_index))
225 .w_full()
226 .px_3()
227 .py_2()
228 .rounded_md()
229 .cursor_pointer()
230 .when(is_selected, |this| this.bg(palette::primary(100)))
231 .when(!is_selected, |this| this.hover(move |s| s.bg(hover)))
232 .on_click(cx.listener(move |this, _, _, cx| {
233 this.selected = selectable;
234 cx.notify();
235 }))
236 .child(Label::new(label)),
237 );
238 selectable += 1;
239 }
240 }
241
242 let mut anchor = anchored().snap_to_window_with_margin(px(8.));
243 if let Some(bounds) = self.trigger_bounds.get() {
244 anchor = anchor.position(point(
245 bounds.origin.x,
246 bounds.origin.y + bounds.size.height + px(4.),
247 ));
248 }
249
250 this.child(deferred(anchor.child(div().occlude().child(list))).with_priority(1))
251 })
252 }
253}
254
255impl Component for Command {
256 fn scope() -> ComponentScope {
257 ComponentScope::Overlays
258 }
259
260 fn description() -> Option<&'static str> {
261 Some("A command palette with fuzzy filter and keyboard navigation.")
262 }
263
264 fn preview(_window: &mut Window, cx: &mut App) -> Option<AnyElement> {
265 let items = vec![
266 CommandItem::Group("Suggestions".into()),
267 CommandItem::Entry {
268 id: "cal".into(),
269 label: "Calendar".into(),
270 },
271 CommandItem::Entry {
272 id: "sr".into(),
273 label: "Search Emoji".into(),
274 },
275 CommandItem::Entry {
276 id: "calc".into(),
277 label: "Calculator".into(),
278 },
279 CommandItem::Group("Settings".into()),
280 CommandItem::Entry {
281 id: "prof".into(),
282 label: "Profile".into(),
283 },
284 CommandItem::Entry {
285 id: "bill".into(),
286 label: "Billing".into(),
287 },
288 ];
289 Some(cx.new(|cx| Command::new(cx, items)).into_any_element())
290 }
291}
292
293pub fn command_preview(_window: &mut Window, cx: &mut App) -> AnyElement {
295 Command::preview(_window, cx).unwrap_or_else(|| div().into_any_element())
296}