1use 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#[derive(Clone, Debug, PartialEq, Eq)]
23pub enum SearchEvent {
24 Close,
26 Run(SharedString),
29}
30
31pub type Filter = Rc<dyn Fn(&CommandSpec) -> bool>;
33
34#[derive(Clone)]
36pub struct Row {
37 pub command: CommandSpec,
39 pub disabled: Option<String>,
41}
42
43pub struct Search {
45 registry: Registry,
46 filter: Option<Filter>,
47 input: Entity<InputState>,
48 title: SharedString,
49 cursor: usize,
51 _subs: Vec<Subscription>,
52}
53
54impl EventEmitter<SearchEvent> for Search {}
55
56const SHOWN: usize = 50;
58const ROW_H: f32 = 48.;
60
61impl Search {
62 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 pub fn filter(mut self, filter: impl Fn(&CommandSpec) -> bool + 'static) -> Self {
82 self.filter = Some(Rc::new(filter));
83 self
84 }
85
86 pub fn title(mut self, title: impl Into<SharedString>) -> Self {
88 self.title = title.into();
89 self
90 }
91
92 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 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 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 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 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 .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}