1use crate::control::{PressReleaseExt as _, composed_button};
4use crate::decoration::{DecoratedExt as _, Decoration};
5use crate::handlers::SharedHandler;
6use crate::motion::{ArrivalRoster, MotionTokens};
7use crate::surface::CardFrameExt as _;
8use crate::surface::{initial_badge, initial_of};
9use crate::theme::SemanticStyledExt as _;
10use gpui::{
11 App, ClickEvent, ElementId, InteractiveElement as _, IntoElement, ParentElement as _,
12 RenderOnce, Role, SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled,
13 Window, div, prelude::FluentBuilder as _,
14};
15use gpui_component::{
16 ActiveTheme as _, Icon, IconName, Sizable as _, StyledExt as _, h_flex, spinner::Spinner,
17 v_flex,
18};
19
20#[derive(Debug, Clone)]
22pub struct SearchResult {
23 id: SharedString,
24 title: SharedString,
25 domain: Option<SharedString>,
26}
27
28impl SearchResult {
29 pub fn new(id: impl Into<SharedString>, title: impl Into<SharedString>) -> Self {
31 Self {
32 id: id.into(),
33 title: title.into(),
34 domain: None,
35 }
36 }
37
38 pub fn domain(mut self, domain: impl Into<SharedString>) -> Self {
40 self.domain = Some(domain.into());
41 self
42 }
43
44 fn opened_event(&self) -> SearchResultsEvent {
45 SearchResultsEvent::Opened {
46 id: self.id.clone(),
47 }
48 }
49}
50
51#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum SearchResultsEvent {
54 Opened {
56 id: SharedString,
58 },
59}
60
61#[derive(IntoElement)]
78pub struct SearchResults {
79 id: ElementId,
80 style: StyleRefinement,
81 decoration: Decoration,
82 query: SharedString,
83 searching: bool,
84 results: Vec<SearchResult>,
85 on_event: Option<SharedHandler<SearchResultsEvent>>,
86}
87
88impl SearchResults {
89 pub fn decoration(mut self, decoration: Decoration) -> Self {
95 self.decoration = decoration;
96 self
97 }
98
99 pub fn new(id: impl Into<ElementId>, query: impl Into<SharedString>) -> Self {
101 Self {
102 id: id.into(),
103 style: StyleRefinement::default(),
104 decoration: Decoration::default(),
105 query: query.into(),
106 searching: false,
107 results: Vec::new(),
108 on_event: None,
109 }
110 }
111
112 pub fn searching(mut self, searching: bool) -> Self {
114 self.searching = searching;
115 self
116 }
117
118 pub fn results(mut self, results: impl IntoIterator<Item = SearchResult>) -> Self {
120 self.results = results.into_iter().collect();
121 self
122 }
123
124 pub fn on_event(
126 mut self,
127 handler: impl Fn(&SearchResultsEvent, &mut Window, &mut App) + 'static,
128 ) -> Self {
129 self.on_event = Some(std::rc::Rc::new(handler));
130 self
131 }
132}
133
134impl Styled for SearchResults {
135 fn style(&mut self) -> &mut StyleRefinement {
136 &mut self.style
137 }
138}
139
140impl RenderOnce for SearchResults {
141 fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
142 let tokens = cx.theme().semantic_tokens();
143 let mut decoration = std::mem::take(&mut self.decoration);
146 let header: SharedString = if self.searching {
147 format!("Searching \u{201c}{}\u{201d}\u{2026}", self.query).into()
148 } else {
149 format!(
150 "{} result{} for \u{201c}{}\u{201d}",
151 self.results.len(),
152 if self.results.len() == 1 { "" } else { "s" },
153 self.query
154 )
155 .into()
156 };
157 let handler = self.on_event;
158 let root_id = self.id.clone();
159
160 let motion = MotionTokens::read(cx).clone();
164 let roster = window.use_keyed_state((root_id.clone(), "arrivals"), cx, |_, _| {
165 ArrivalRoster::new()
166 });
167 roster.update(cx, |roster, cx| {
168 roster.note(
169 self.results.iter().map(|result| {
170 ElementId::Name(SharedString::from(format!("result-{}", result.id)))
171 }),
172 true,
173 &motion,
174 cx.background_executor().now(),
175 );
176 });
177
178 v_flex()
179 .id(self.id)
180 .role(Role::Search)
181 .aria_label(header.clone())
182 .card_frame(cx)
183 .decoration_under(&mut decoration)
184 .overflow_hidden()
185 .child(
186 h_flex()
187 .items_center()
188 .gap(tokens.spacing.sm)
189 .px(tokens.spacing.md)
190 .py(tokens.spacing.xs)
191 .text_token(tokens.typography.xs)
192 .text_color(cx.theme().muted_foreground)
193 .child(if self.searching {
194 Spinner::new()
195 .xsmall()
196 .color(cx.theme().muted_foreground)
197 .into_any_element()
198 } else {
199 Icon::new(IconName::Search)
200 .xsmall()
201 .text_color(cx.theme().muted_foreground)
202 .into_any_element()
203 })
204 .child(header),
205 )
206 .when(!self.results.is_empty(), |this| {
207 this.child(
208 v_flex()
209 .border_t_1()
210 .border_color(crate::surface::hairline(cx))
211 .p(tokens.spacing.xs)
215 .gap(tokens.spacing.xxs)
216 .children(self.results.into_iter().map(|result| {
217 let event = result.opened_event();
218 let result_id = result.id.clone();
219 let arrival = roster.update(cx, |roster, cx| {
220 roster.progress(
221 &ElementId::Name(SharedString::from(format!(
222 "result-{}",
223 result.id
224 ))),
225 window,
226 cx,
227 )
228 });
229 let accessibility_label = result.title.clone();
230 let accessibility_description = result.domain.clone();
231 let row = h_flex()
232 .w_full()
233 .items_center()
234 .gap(tokens.spacing.sm)
235 .child(match &result.domain {
236 Some(domain) => {
237 initial_badge(initial_of(domain), cx).into_any_element()
238 }
239 None => Icon::new(IconName::Globe)
240 .xsmall()
241 .text_color(cx.theme().muted_foreground)
242 .into_any_element(),
243 })
244 .child(
245 div()
246 .flex_1()
247 .truncate()
248 .text_token(tokens.typography.sm)
249 .text_color(cx.theme().foreground)
250 .child(result.title),
251 )
252 .when_some(result.domain, |this, domain| {
253 this.child(
254 crate::surface::hint(domain, cx)
255 .flex_none()
256 .font_family(cx.theme().mono_font_family.clone()),
257 )
258 });
259
260 let row = match arrival {
261 Some(progress) => row.opacity(progress).top(
262 tokens.spacing.xxs
263 * (1.0 - progress)
264 * crate::motion::travel(cx),
265 ),
266 None => row,
267 };
268
269 match handler.clone() {
270 Some(handler) => {
271 composed_button(result.id.clone(), accessibility_label)
272 .w_full()
273 .justify_start()
274 .px(tokens.spacing.sm)
275 .py(tokens.spacing.xs)
276 .border_1()
277 .border_color(cx.theme().transparent)
278 .rounded(crate::surface::nested_radius(
279 tokens.radius.lg,
280 tokens.spacing.xs,
281 tokens.radius.sm,
282 ))
283 .hover(|style| style.bg(cx.theme().accent.opacity(0.6)))
284 .active(|style| style.bg(cx.theme().accent))
285 .focus_visible(|style| style.border_color(cx.theme().ring))
286 .press_release(
287 ElementId::from((
288 ElementId::from(result.id.clone()),
289 "press",
290 )),
291 tokens.radius.sm,
292 window,
293 cx,
294 )
295 .when_some(
296 accessibility_description,
297 |this, description| this.aria_description(description),
298 )
299 .child(row)
300 .on_click(move |_: &ClickEvent, window, cx| {
301 handler(&event, window, cx)
302 })
303 .into_any_element()
304 }
305 None => div()
306 .id(result_id)
307 .px(tokens.spacing.sm)
308 .py(tokens.spacing.xs)
309 .role(Role::ListItem)
310 .aria_label(accessibility_label)
311 .when_some(accessibility_description, |this, description| {
312 this.aria_description(description)
313 })
314 .w_full()
315 .px(tokens.spacing.md)
316 .py(tokens.spacing.xs)
317 .child(row)
318 .into_any_element(),
319 }
320 })),
321 )
322 })
323 .decoration_over(&mut decoration)
324 .refine_style(&self.style)
325 }
326}
327
328#[cfg(test)]
329mod tests {
330 use super::*;
331
332 #[test]
333 fn duplicate_titles_emit_distinct_stable_ids() {
334 let first = SearchResult::new("first", "Same title");
335 let second = SearchResult::new("second", "Same title");
336 assert_eq!(
337 first.opened_event(),
338 SearchResultsEvent::Opened { id: "first".into() }
339 );
340 assert_eq!(
341 second.opened_event(),
342 SearchResultsEvent::Opened {
343 id: "second".into()
344 }
345 );
346 }
347}