Skip to main content

gpui_kit_material/components/
search.rs

1//! M3 search: the search bar and the search view (docked and full-screen).
2//!
3//! Reference: <https://m3.material.io/components/search/specs> and the
4//! Material 3 Design Kit "Search" page. Editing, selection, clipboard, IME and
5//! the input's accessibility belong to Kit's `component::input::Input`, drawn
6//! without its own chrome; suggestions are Base buttons with the shared M3
7//! focus ring. This module owns the pill container, state layers, slots and
8//! the view's expand/collapse motion (default spatial size, fast effects fade,
9//! retargeting mid-flight and honoring reduced motion via [`crate::motion`]).
10use super::InteractionPreview;
11use super::focus::{RingPlacement, RingedButton};
12use super::text_field::type_style;
13use crate::{
14    color::ColorRole,
15    motion::{self, MotionToken},
16    theme::color,
17    tokens::{
18        shape::RADIUS_MEDIUM,
19        state,
20        typography::{TypeRole, rem},
21    },
22};
23use gpui::{prelude::*, *};
24use gpui_base::input::InputState;
25use gpui_kit::component::{Sizable as _, Size, input::Input};
26use std::rc::Rc;
27
28/// The search bar container: surface-container-high (not the highest).
29pub(crate) const SEARCH_CONTAINER: ColorRole = ColorRole::SURFACE_CONTAINER_HIGH;
30/// Search bar height.
31pub const SEARCH_BAR_HEIGHT: f32 = 56.;
32/// One suggestion row (a middle-aligned two-line list item).
33pub const SEARCH_SUGGESTION_HEIGHT: f32 = 64.;
34/// The gap between a docked bar and its results.
35const DOCKED_GAP: f32 = 2.;
36/// A 48dp touch target around each 24dp icon slot.
37const TARGET: f32 = 48.;
38/// The full-screen view's margin around its bar.
39const FULL_SCREEN_MARGIN: f32 = 12.;
40
41enum BarInput {
42    Live(Entity<InputState>),
43    /// A specimen: `(text, is_placeholder)`.
44    Static(SharedString, bool),
45}
46
47/// The M3 search bar: a 56dp pill on surface-container-high with a leading
48/// icon, the query and up to two trailing icons or an avatar.
49#[derive(gpui::IntoElement)]
50pub struct SearchField {
51    id: ElementId,
52    input: BarInput,
53    leading: Option<AnyElement>,
54    trailing: Vec<AnyElement>,
55    disabled: bool,
56    preview: Option<InteractionPreview>,
57    style: StyleRefinement,
58}
59
60impl SearchField {
61    /// A live bar editing `state`. Its placeholder is the state's placeholder.
62    pub fn new(state: &Entity<InputState>) -> Self {
63        Self::with_input(
64            ElementId::from(("search-field", state.entity_id())),
65            BarInput::Live(state.clone()),
66        )
67    }
68
69    /// A non-editable specimen showing `text`, painted as hint text when
70    /// `placeholder` is true.
71    pub fn specimen(
72        id: impl Into<ElementId>,
73        text: impl Into<SharedString>,
74        placeholder: bool,
75    ) -> Self {
76        Self::with_input(id.into(), BarInput::Static(text.into(), placeholder))
77    }
78
79    fn with_input(id: ElementId, input: BarInput) -> Self {
80        Self {
81            id,
82            input,
83            leading: None,
84            trailing: Vec::new(),
85            disabled: false,
86            preview: None,
87            style: StyleRefinement::default(),
88        }
89    }
90
91    /// A disabled bar takes no focus, input or hover, and fades its content
92    /// (icons, query and placeholder) to on-surface at 38%.
93    pub fn disabled(mut self, disabled: bool) -> Self {
94        self.disabled = disabled;
95        self
96    }
97
98    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
99        self.id = id.into();
100        self
101    }
102    /// The leading icon or icon button (menu, back arrow, search).
103    pub fn leading(mut self, element: impl IntoElement) -> Self {
104        self.leading = Some(element.into_any_element());
105        self
106    }
107    /// Append a trailing icon, icon button or avatar.
108    pub fn trailing(mut self, element: impl IntoElement) -> Self {
109        self.trailing.push(element.into_any_element());
110        self
111    }
112    /// Render a static specimen in one forced interaction state. See
113    /// [`InteractionPreview`].
114    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
115        self.preview = Some(state);
116        self
117    }
118}
119
120impl Styled for SearchField {
121    fn style(&mut self) -> &mut StyleRefinement {
122        &mut self.style
123    }
124}
125
126fn alpha(mut c: Rgba, a: f32) -> Rgba {
127    c.a *= a;
128    c
129}
130
131/// A 48dp target holding one 24dp slot.
132fn target(child: AnyElement, ink: Rgba) -> Div {
133    div()
134        .flex()
135        .flex_shrink_0()
136        .items_center()
137        .justify_center()
138        .size(rem(TARGET))
139        .text_color(ink)
140        .child(child)
141}
142
143impl RenderOnce for SearchField {
144    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
145        let on_surface = color(ColorRole::ON_SURFACE);
146        let container = color(SEARCH_CONTAINER);
147        let disabled = self.disabled || self.preview.is_some_and(InteractionPreview::is_disabled);
148        let preview = self.preview.filter(|_| !disabled);
149        let live = preview.is_none() && !disabled;
150        let fade = if disabled {
151            state::DISABLED_CONTENT
152        } else {
153            1.
154        };
155        let layer = preview.map_or(0., InteractionPreview::layer_opacity);
156        // Group hover resolves to the nearest painting ancestor of this name,
157        // so a static name stays scoped to this bar without formatting one
158        // per frame.
159        let group = SharedString::new_static("material-search-bar");
160        let focus = match &self.input {
161            BarInput::Live(state) => Some(state.read(cx).focus_handle(cx)),
162            BarInput::Static(..) => None,
163        };
164        let content: AnyElement = match self.input {
165            BarInput::Live(state) => Input::new(&state)
166                .appearance(false)
167                .disabled(disabled)
168                .with_size(Size::XSmall)
169                .p_0()
170                .h(rem(24.))
171                .text_size(TypeRole::BodyLarge.scale().font_size)
172                .line_height(TypeRole::BodyLarge.scale().line_height)
173                .into_any_element(),
174            BarInput::Static(text, placeholder) => div()
175                .overflow_hidden()
176                .whitespace_nowrap()
177                .when(placeholder, |this| {
178                    this.text_color(color(ColorRole::ON_SURFACE_VARIANT))
179                })
180                .child(text)
181                .into_any_element(),
182        };
183        let has_leading = self.leading.is_some();
184        let trailing_count = self.trailing.len();
185        let mut bar = div()
186            .id(self.id.clone())
187            .debug_selector(|| "search-bar".into())
188            .group(group.clone())
189            .relative()
190            .flex()
191            .items_center()
192            .w_full()
193            .min_w_0()
194            .h(rem(SEARCH_BAR_HEIGHT))
195            .px(rem(4.))
196            .gap(rem(4.))
197            .rounded_full()
198            .bg(container)
199            .text_color(on_surface)
200            .map(|this| type_style(this, TypeRole::BodyLarge))
201            .when(live, |this| this.cursor_text())
202            // The state layer, under the content.
203            .child(
204                div()
205                    .absolute()
206                    .inset_0()
207                    .rounded_full()
208                    .bg(alpha(on_surface, layer))
209                    .when(live, |this| {
210                        this.group_hover(group.clone(), move |s| {
211                            s.bg(alpha(on_surface, state::HOVER))
212                        })
213                    }),
214            )
215            .when(live, |this| {
216                this.active(move |s| s.bg(state::pressed(container, on_surface)))
217            })
218            .children(
219                self.leading
220                    .map(|icon| target(icon, color(ColorRole::ON_SURFACE)).opacity(fade)),
221            )
222            .child(
223                div()
224                    .relative()
225                    .flex_1()
226                    .min_w_0()
227                    .opacity(fade)
228                    .when(!has_leading, |this| this.pl(rem(12.)))
229                    .when(trailing_count == 0, |this| this.pr(rem(12.)))
230                    .child(content),
231            )
232            .children(
233                self.trailing
234                    .into_iter()
235                    .map(|icon| target(icon, color(ColorRole::ON_SURFACE_VARIANT)).opacity(fade)),
236            );
237        if let Some(focus) = focus.filter(|_| live) {
238            bar = bar.on_mouse_down(MouseButton::Left, move |_, window, cx| {
239                focus.focus(window, cx);
240            });
241        }
242        let _ = window;
243        bar.style().refine(&self.style);
244        bar
245    }
246}
247
248type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
249
250/// One search suggestion: a 64dp row with an optional leading element (icon
251/// or 40dp avatar), a label and optional supporting text.
252pub struct SearchSuggestion {
253    label: SharedString,
254    supporting: Option<SharedString>,
255    leading: Option<AnyElement>,
256    on_click: Option<ClickHandler>,
257}
258
259impl SearchSuggestion {
260    pub fn new(label: impl Into<SharedString>) -> Self {
261        Self {
262            label: label.into(),
263            supporting: None,
264            leading: None,
265            on_click: None,
266        }
267    }
268    pub fn supporting_text(mut self, text: impl Into<SharedString>) -> Self {
269        self.supporting = Some(text.into());
270        self
271    }
272    pub fn leading(mut self, element: impl IntoElement) -> Self {
273        self.leading = Some(element.into_any_element());
274        self
275    }
276    pub fn on_click(
277        mut self,
278        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
279    ) -> Self {
280        self.on_click = Some(Rc::new(handler));
281        self
282    }
283
284    /// The row; only a `live` (expanded) view's rows take focus or clicks,
285    /// so a collapsing view's fading rows cannot be activated.
286    fn render(self, id: ElementId, live: bool) -> AnyElement {
287        let on_surface = color(ColorRole::ON_SURFACE);
288        let clear = alpha(on_surface, 0.);
289        let label = self.label.clone();
290        let mut row = RingedButton::new(id, RingPlacement::Inner(Corners::default()))
291            .state_layers(
292                alpha(on_surface, state::HOVER),
293                alpha(on_surface, state::PRESS),
294            )
295            .accessibility_label(label)
296            .tab_stop(live)
297            .map(|button| {
298                button
299                    .flex()
300                    .items_center()
301                    .w_full()
302                    .h(rem(SEARCH_SUGGESTION_HEIGHT))
303                    .pl(rem(16.))
304                    .pr(rem(24.))
305                    .gap(rem(16.))
306                    .bg(clear)
307                    .cursor_pointer()
308            })
309            .children(self.leading.map(|leading| {
310                div()
311                    .flex()
312                    .flex_shrink_0()
313                    .items_center()
314                    .justify_center()
315                    .min_w(rem(24.))
316                    .text_color(color(ColorRole::ON_SURFACE_VARIANT))
317                    .child(leading)
318            }))
319            .child(
320                div()
321                    .flex()
322                    .flex_col()
323                    .flex_1()
324                    .min_w_0()
325                    .child(
326                        type_style(div(), TypeRole::BodyLarge)
327                            .text_color(on_surface)
328                            .whitespace_nowrap()
329                            .overflow_hidden()
330                            .child(self.label),
331                    )
332                    .children(self.supporting.map(|text| {
333                        type_style(div(), TypeRole::BodyMedium)
334                            .text_color(color(ColorRole::ON_SURFACE_VARIANT))
335                            .whitespace_nowrap()
336                            .overflow_hidden()
337                            .child(text)
338                    })),
339            );
340        if let Some(handler) = self.on_click.filter(|_| live) {
341            row = row.on_click(move |event, window, cx| handler(event, window, cx));
342        }
343        row.into_any_element()
344    }
345}
346
347/// Where a search view shows its results.
348#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
349pub enum SearchViewLayout {
350    /// Results drop below the bar in a container of their own.
351    #[default]
352    Docked,
353    /// The view covers its whole area, the bar inset at the top.
354    FullScreen,
355}
356
357/// How a search view sets its results apart from the bar (md3.io's Style).
358#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
359pub enum SearchViewStyle {
360    /// M3 Expressive: results in a container of their own, 2dp below a
361    /// docked bar (full-screen: under the inset pill).
362    #[default]
363    Contained,
364    /// The baseline view: bar and results share one surface (28dp corners
365    /// when docked, edge to edge full-screen) split by a 1dp outline
366    /// divider.
367    Divided,
368}
369
370/// The M3 search view: a search bar that expands into a results surface.
371///
372/// The caller controls `expanded` (typically opening on the input's focus and
373/// closing from a back button or a chosen suggestion). The results grow from
374/// the bar on the default spatial token and fade on fast effects; collapsing
375/// plays the same motion back and keeps the rows painted until it settles.
376#[derive(gpui::IntoElement)]
377pub struct SearchView {
378    id: ElementId,
379    bar: SearchField,
380    layout: SearchViewLayout,
381    view_style: SearchViewStyle,
382    expanded: bool,
383    suggestions: Vec<SearchSuggestion>,
384    max_visible: usize,
385    style: StyleRefinement,
386}
387
388impl SearchView {
389    pub fn new(id: impl Into<ElementId>, bar: SearchField) -> Self {
390        Self {
391            id: id.into(),
392            bar,
393            layout: SearchViewLayout::Docked,
394            view_style: SearchViewStyle::Contained,
395            expanded: false,
396            suggestions: Vec::new(),
397            max_visible: 5,
398            style: StyleRefinement::default(),
399        }
400    }
401    pub fn layout(mut self, layout: SearchViewLayout) -> Self {
402        self.layout = layout;
403        self
404    }
405    /// Contained (default) or divided results. See [`SearchViewStyle`].
406    pub fn view_style(mut self, style: SearchViewStyle) -> Self {
407        self.view_style = style;
408        self
409    }
410    pub fn expanded(mut self, expanded: bool) -> Self {
411        self.expanded = expanded;
412        self
413    }
414    pub fn suggestion(mut self, suggestion: SearchSuggestion) -> Self {
415        self.suggestions.push(suggestion);
416        self
417    }
418    pub fn suggestions(mut self, suggestions: impl IntoIterator<Item = SearchSuggestion>) -> Self {
419        self.suggestions.extend(suggestions);
420        self
421    }
422    /// How many rows a docked view shows before it scrolls (default 5).
423    pub fn max_visible(mut self, rows: usize) -> Self {
424        self.max_visible = rows.max(1);
425        self
426    }
427}
428
429impl Styled for SearchView {
430    fn style(&mut self) -> &mut StyleRefinement {
431        &mut self.style
432    }
433}
434
435/// The results' revealed height in design px for an expansion `t`.
436pub(crate) fn results_height(rows: usize, max_visible: usize, t: f32) -> f32 {
437    rows.min(max_visible) as f32 * SEARCH_SUGGESTION_HEIGHT * t.clamp(0., 1.)
438}
439
440impl RenderOnce for SearchView {
441    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
442        let id = self.id.clone();
443        let expand = motion::token_value(
444            (id.clone(), "expand"),
445            if self.expanded { 1. } else { 0. },
446            MotionToken::DefaultSpatial,
447            window,
448            cx,
449        );
450        let fade = motion::token_value(
451            (id.clone(), "fade"),
452            if self.expanded { 1. } else { 0. },
453            MotionToken::FastEffects,
454            window,
455            cx,
456        )
457        .clamp(0., 1.);
458        let visible = self.expanded || fade > 0.001 || expand > 0.001;
459        let rows = self.suggestions.len();
460        let live = self.expanded;
461        let divided = self.view_style == SearchViewStyle::Divided;
462        let divider = |fade: f32| {
463            div()
464                .debug_selector(|| "search-divider".into())
465                .flex_shrink_0()
466                .w_full()
467                .h(px(1.))
468                .bg(color(ColorRole::OUTLINE))
469                .opacity(fade)
470        };
471        let list = |suggestions: Vec<SearchSuggestion>| {
472            div()
473                .id((id.clone(), "results"))
474                .flex()
475                .flex_col()
476                .w_full()
477                .overflow_y_scroll()
478                .children(suggestions.into_iter().enumerate().map(|(ix, s)| {
479                    s.render(
480                        ElementId::NamedInteger("suggestion".into(), ix as u64),
481                        live,
482                    )
483                }))
484        };
485        let mut root = match self.layout {
486            SearchViewLayout::Docked if divided => {
487                let height = results_height(rows, self.max_visible, expand);
488                let open = visible && rows > 0;
489                div()
490                    .id(id.clone())
491                    .relative()
492                    .flex()
493                    .flex_col()
494                    .w(rem(360.))
495                    .rounded(rem(SEARCH_BAR_HEIGHT / 2.))
496                    .overflow_hidden()
497                    .when(open, |this| this.bg(color(SEARCH_CONTAINER)))
498                    .child(self.bar)
499                    .when(open, |this| {
500                        this.child(divider(fade)).child(
501                            div()
502                                .debug_selector(|| "search-results".into())
503                                .w_full()
504                                .h(rem(height))
505                                .overflow_hidden()
506                                .opacity(fade)
507                                .child(list(self.suggestions)),
508                        )
509                    })
510            }
511            SearchViewLayout::Docked => {
512                let height = results_height(rows, self.max_visible, expand);
513                div()
514                    .id(id.clone())
515                    .relative()
516                    .flex()
517                    .flex_col()
518                    .w(rem(360.))
519                    .child(self.bar)
520                    .when(visible && rows > 0, |this| {
521                        this.child(
522                            div()
523                                .debug_selector(|| "search-results".into())
524                                .mt(rem(DOCKED_GAP))
525                                .w_full()
526                                .h(rem(height))
527                                .overflow_hidden()
528                                .rounded(rem(RADIUS_MEDIUM.as_f32()))
529                                .bg(color(SEARCH_CONTAINER))
530                                .opacity(fade)
531                                .child(list(self.suggestions)),
532                        )
533                    })
534            }
535            SearchViewLayout::FullScreen => {
536                // A divided view runs its header edge to edge.
537                let margin = if divided {
538                    0.
539                } else {
540                    FULL_SCREEN_MARGIN * expand.clamp(0., 1.)
541                };
542                div()
543                    .id(id.clone())
544                    .relative()
545                    .flex()
546                    .flex_col()
547                    .size_full()
548                    .overflow_hidden()
549                    .child(
550                        div()
551                            .absolute()
552                            .inset_0()
553                            .bg(color(ColorRole::SURFACE))
554                            .opacity(fade),
555                    )
556                    .child(div().relative().w_full().px(rem(margin)).child(if divided {
557                        self.bar.rounded_none()
558                    } else {
559                        self.bar
560                    }))
561                    .when(visible && divided, |this| this.child(divider(fade)))
562                    .when(visible, |this| {
563                        this.child(
564                            div()
565                                .debug_selector(|| "search-results".into())
566                                .relative()
567                                .flex_1()
568                                .min_h_0()
569                                .w_full()
570                                .overflow_hidden()
571                                .opacity(fade)
572                                .child(list(self.suggestions)),
573                        )
574                    })
575            }
576        };
577        root.style().refine(&self.style);
578        root
579    }
580}
581
582#[cfg(test)]
583#[path = "search_tests.rs"]
584mod tests;