Skip to main content

kitchen_shell/
search.rs

1//! The search (Ctrl+K, F3; as Spotlight or Blender's F3): every registered command by name,
2//! filtered as you type, under its group: an icon tile, the title, a line saying what it does,
3//! its key at the right. A command that cannot run now stays listed, greyed, with the reason in
4//! place of its line. ↑ ↓ move between the commands that can run, Enter runs the highlighted
5//! one, a click runs any, Escape or a click outside closes.
6
7use std::rc::Rc;
8
9use gpui_kit::assets::IconName;
10use gpui_kit::component::input::{Input, InputEvent, InputState};
11use gpui_kit::component::{Icon, h_flex, v_flex};
12use gpui_kit::prelude::FluentBuilder as _;
13use gpui_kit::{
14    App, AppContext as _, Context, Entity, EventEmitter, FontWeight, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, Render,
15    SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, Window, div, px,
16};
17use kitchen_ui::theme::{self, color};
18
19use crate::command::{CommandSpec, Registry};
20
21/// What the search asks of its owner.
22#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum SearchEvent {
24    /// Close it.
25    Close,
26    /// Close it, then run the command `id` (the owner runs it once the search is gone, so what it
27    /// opens keeps the focus).
28    Run(SharedString),
29}
30
31/// Which commands a search lists (all of them by default).
32pub type Filter = Rc<dyn Fn(&CommandSpec) -> bool>;
33
34/// A row of the search: a command, and why it cannot run now (if it cannot).
35#[derive(Clone)]
36pub struct Row {
37    /// The command.
38    pub command: CommandSpec,
39    /// Why it cannot run now.
40    pub disabled: Option<String>,
41}
42
43/// The search over a [`Registry`].
44pub struct Search {
45    registry: Registry,
46    filter: Option<Filter>,
47    input: Entity<InputState>,
48    title: SharedString,
49    /// The highlighted row (of the filtered list).
50    cursor: usize,
51    _subs: Vec<Subscription>,
52}
53
54impl EventEmitter<SearchEvent> for Search {}
55
56/// Rows listed at most.
57const SHOWN: usize = 50;
58/// Height of a row (px): two lines of text and the icon tile.
59const ROW_H: f32 = 48.;
60
61impl Search {
62    /// A search over every command of `registry`; focuses its input.
63    pub fn new(registry: Registry, window: &mut Window, cx: &mut Context<Self>) -> Self {
64        let input = cx.new(|cx| InputState::new(window, cx).placeholder("Search commands…"));
65        let subs = vec![cx.subscribe_in(&input, window, |this, _, e: &InputEvent, _, cx| match e {
66            InputEvent::PressEnter { .. } => this.run_cursor(cx),
67            InputEvent::Change => {
68                this.cursor = this.first_enabled(cx);
69                cx.notify()
70            }
71            _ => {}
72        })];
73        let focus = gpui_kit::Focusable::focus_handle(input.read(cx), cx);
74        window.focus(&focus, cx);
75        let mut s = Self { registry, filter: None, input, title: "Search".into(), cursor: 0, _subs: subs };
76        s.cursor = s.first_enabled(cx);
77        s
78    }
79
80    /// Lists only the commands `filter` accepts (Process ▸ "Find an operation…": one group).
81    pub fn filter(mut self, filter: impl Fn(&CommandSpec) -> bool + 'static) -> Self {
82        self.filter = Some(Rc::new(filter));
83        self
84    }
85
86    /// Sets the heading ("Search", "Find an operation").
87    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
88        self.title = title.into();
89        self
90    }
91
92    /// Sets the text shown in the empty input.
93    pub fn placeholder(self, text: impl Into<SharedString>, window: &mut Window, cx: &mut Context<Self>) -> Self {
94        let text: SharedString = text.into();
95        self.input.update(cx, |i, cx| i.set_placeholder(text, window, cx));
96        self
97    }
98
99    /// Types `query` into the search (as a person would).
100    pub fn set_query(&mut self, query: &str, window: &mut Window, cx: &mut Context<Self>) {
101        let q = query.to_string();
102        self.input.update(cx, |i, cx| i.set_value(q, window, cx));
103        self.cursor = self.first_enabled(cx);
104        cx.notify();
105    }
106
107    /// The rows matching what is typed: every word typed is in the group, title, hint or key.
108    pub fn rows(&self, cx: &App) -> Vec<Row> {
109        let q = self.input.read(cx).value().to_lowercase();
110        let words: Vec<&str> = q.split_whitespace().collect();
111        let mut rows: Vec<Row> = self
112            .registry
113            .all()
114            .into_iter()
115            .filter(|c| self.filter.as_ref().is_none_or(|f| f(c)))
116            .filter(|c| {
117                let hay = format!("{} {} {} {}", c.group, c.title, c.hint, c.key_label().unwrap_or_default()).to_lowercase();
118                words.iter().all(|w| hay.contains(w))
119            })
120            .map(|c| Row { disabled: (c.enabled)(cx).err(), command: c })
121            .collect();
122        // Grouped, each group where its first command was registered
123        let order: Vec<SharedString> = rows.iter().map(|r| r.command.group.clone()).fold(Vec::new(), |mut v, g| {
124            if !v.contains(&g) {
125                v.push(g);
126            }
127            v
128        });
129        rows.sort_by_key(|r| order.iter().position(|g| *g == r.command.group));
130        rows.truncate(SHOWN);
131        rows
132    }
133
134    /// The highlighted row's index.
135    pub fn cursor(&self) -> usize {
136        self.cursor
137    }
138
139    fn first_enabled(&self, cx: &App) -> usize {
140        self.rows(cx).iter().position(|r| r.disabled.is_none()).unwrap_or(0)
141    }
142
143    fn run_cursor(&mut self, cx: &mut Context<Self>) {
144        if let Some(r) = self.rows(cx).into_iter().nth(self.cursor).filter(|r| r.disabled.is_none()) {
145            cx.emit(SearchEvent::Run(r.command.id));
146        }
147    }
148
149    fn on_key(&mut self, e: &KeyDownEvent, _: &mut Window, cx: &mut Context<Self>) {
150        let rows = self.rows(cx);
151        let enabled: Vec<usize> = rows.iter().enumerate().filter(|(_, r)| r.disabled.is_none()).map(|(i, _)| i).collect();
152        let at = enabled.iter().position(|&i| i == self.cursor);
153        match e.keystroke.key.as_str() {
154            "down" if !enabled.is_empty() => self.cursor = enabled[at.map_or(0, |i| (i + 1).min(enabled.len() - 1))],
155            "up" if !enabled.is_empty() => self.cursor = enabled[at.map_or(0, |i| i.saturating_sub(1))],
156            "escape" => cx.emit(SearchEvent::Close),
157            "enter" => {
158                self.run_cursor(cx);
159                cx.stop_propagation();
160                return;
161            }
162            _ => return,
163        }
164        cx.stop_propagation();
165        cx.notify();
166    }
167}
168
169impl Render for Search {
170    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
171        let rows = self.rows(cx);
172        let cursor = self.cursor;
173        let mut list = v_flex().id("search-rows").flex_1().min_h_0().overflow_y_scroll();
174        let mut last_group = SharedString::default();
175        if rows.is_empty() {
176            list = list.child(div().p(px(theme::MD)).text_sm().text_color(color::text_muted()).child("No command matches."));
177        }
178        for (i, r) in rows.into_iter().enumerate() {
179            let c = r.command;
180            if c.group != last_group {
181                last_group = c.group.clone();
182                list = list.child(
183                    div().px(px(theme::SM)).pt(px(if i == 0 { theme::XS } else { theme::MD })).pb(px(theme::XS)).text_xs().font_weight(FontWeight::SEMIBOLD).text_color(color::text_muted()).child(c.group.clone()),
184                );
185            }
186            let enabled = r.disabled.is_none();
187            let line: SharedString = r.disabled.map(SharedString::from).unwrap_or(c.hint.clone());
188            let me = cx.entity().downgrade();
189            let id = c.id.clone();
190            list = list.child(
191                h_flex()
192                    .id(SharedString::from(format!("search-row-{}", c.id)))
193                    .min_h(px(ROW_H))
194                    .px(px(theme::SM))
195                    .gap(px(theme::MD))
196                    .rounded(px(theme::RADIUS))
197                    .when(enabled && i == cursor, |d| d.bg(color::control()))
198                    .when(enabled, |d| {
199                        d.cursor_pointer().hover(|d| d.bg(color::control())).on_click(move |_, _, cx| {
200                            let id = id.clone();
201                            let _ = me.update(cx, |_, cx| cx.emit(SearchEvent::Run(id)));
202                        })
203                    })
204                    .child(
205                        div()
206                            .flex_none()
207                            .size(px(32.))
208                            .rounded(px(theme::RADIUS))
209                            .border_1()
210                            .border_color(color::hover())
211                            .flex()
212                            .items_center()
213                            .justify_center()
214                            .child(Icon::new(c.icon).text_color(if enabled { color::text() } else { color::text_disabled() })),
215                    )
216                    .child(
217                        v_flex()
218                            .flex_1()
219                            .min_w_0()
220                            .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).truncate().text_color(if enabled { color::text() } else { color::text_disabled() }).child(c.title.clone()))
221                            .child(div().text_xs().text_color(color::text_muted()).truncate().child(line)),
222                    )
223                    .children(c.key_label().map(|k| div().flex_none().text_xs().font_family("monospace").text_color(color::text_muted()).child(k))),
224            );
225        }
226        let (back, close) = (cx.entity().downgrade(), cx.entity().downgrade());
227        div()
228            .id("search-backdrop")
229            .absolute()
230            .top_0()
231            .left_0()
232            .size_full()
233            .flex()
234            .items_start()
235            .justify_center()
236            .pt(px(72.))
237            .px(px(theme::LG))
238            .bg(gpui_kit::black().opacity(0.35))
239            .on_click(move |_, _, cx| {
240                let _ = back.update(cx, |_, cx| cx.emit(SearchEvent::Close));
241            })
242            .child(
243                v_flex()
244                    .id("search")
245                    .occlude()
246                    // Narrows with the window, never wider than reads well
247                    .w_full()
248                    .max_w(px(560.))
249                    .max_h(gpui_kit::relative(0.8))
250                    .p(px(theme::SM))
251                    .gap(px(theme::XS))
252                    .rounded(px(theme::RADIUS_LG))
253                    .border_1()
254                    .border_color(color::border())
255                    .bg(color::raised())
256                    .text_color(color::text())
257                    .shadow_lg()
258                    .capture_key_down(cx.listener(Self::on_key))
259                    .on_click(|_, _, cx| cx.stop_propagation())
260                    .child(
261                        h_flex()
262                            .px(px(theme::SM))
263                            .py(px(theme::XS))
264                            .gap(px(theme::SM))
265                            .items_center()
266                            .child(div().text_sm().font_weight(FontWeight::SEMIBOLD).child(self.title.clone()))
267                            .child(div().text_xs().text_color(color::text_muted()).child("↑ ↓ Enter · Esc"))
268                            .child(div().flex_1())
269                            .child(kitchen_ui::widgets::icon_button("search-close", IconName::Close, "Close (Esc)").on_click(move |_, _, cx| {
270                                let _ = close.update(cx, |_, cx| cx.emit(SearchEvent::Close));
271                            })),
272                    )
273                    .child(Input::new(&self.input))
274                    .child(list),
275            )
276    }
277}