Skip to main content

gpui_ai/
search_results.rs

1//! Web-search tool output: a query and its results with sources.
2
3use 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/// One search hit inside [`SearchResults`].
21#[derive(Debug, Clone)]
22pub struct SearchResult {
23    id: SharedString,
24    title: SharedString,
25    domain: Option<SharedString>,
26}
27
28impl SearchResult {
29    /// Creates a result with its display title.
30    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    /// Sets the source domain shown after the title (`example.com`).
39    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/// An interaction emitted by [`SearchResults`].
52#[derive(Debug, Clone, PartialEq, Eq)]
53pub enum SearchResultsEvent {
54    /// A result was selected.
55    Opened {
56        /// Stable result identifier.
57        id: SharedString,
58    },
59}
60
61/// The output of a web-search tool call: the query, a live "searching"
62/// state, and the results once they arrive.
63///
64/// # Example
65///
66/// ```
67/// # use gpui_ai::prelude::*;
68/// SearchResults::new("search-1", "gpui wasm support")
69///     .results([
70///         SearchResult::new("gallery", "GPUI Component — Web Gallery")
71///             .domain("longbridge.github.io"),
72///         SearchResult::new("gpui-web", "zed-industries/zed: crates/gpui_web")
73///             .domain("github.com"),
74///     ])
75///     .on_event(|event, _, _| { /* open result */ });
76/// ```
77#[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    /// Layers painted into this card's frame: one under the content, one
90    /// over it, both clipped to its own shape and neither affecting layout.
91    ///
92    /// This crate ships no effects of its own — what goes in a decoration is
93    /// the application's expression.
94    pub fn decoration(mut self, decoration: Decoration) -> Self {
95        self.decoration = decoration;
96        self
97    }
98
99    /// Creates the component for a search `query`.
100    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    /// Marks the search as still running; the header shows a spinner.
113    pub fn searching(mut self, searching: bool) -> Self {
114        self.searching = searching;
115        self
116    }
117
118    /// Sets the results.
119    pub fn results(mut self, results: impl IntoIterator<Item = SearchResult>) -> Self {
120        self.results = results.into_iter().collect();
121        self
122    }
123
124    /// Handles typed result interactions.
125    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        // Taken once: each layer is placed at most once, and a
144        // component with no decoration adds no elements at all.
145        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        // Results the list has already shown stay at rest; stable IDs
161        // streamed into a mounted list settle in on the capped cascade, and
162        // the initial set joins at rest.
163        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                        // Rows are inset from the card that frames them, so
212                        // a hover fill sits inside the card's rounded edge
213                        // instead of running out to it.
214                        .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}