1use 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
28pub(crate) const SEARCH_CONTAINER: ColorRole = ColorRole::SURFACE_CONTAINER_HIGH;
30pub const SEARCH_BAR_HEIGHT: f32 = 56.;
32pub const SEARCH_SUGGESTION_HEIGHT: f32 = 64.;
34const DOCKED_GAP: f32 = 2.;
36const TARGET: f32 = 48.;
38const FULL_SCREEN_MARGIN: f32 = 12.;
40
41enum BarInput {
42 Live(Entity<InputState>),
43 Static(SharedString, bool),
45}
46
47#[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 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 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 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 pub fn leading(mut self, element: impl IntoElement) -> Self {
104 self.leading = Some(element.into_any_element());
105 self
106 }
107 pub fn trailing(mut self, element: impl IntoElement) -> Self {
109 self.trailing.push(element.into_any_element());
110 self
111 }
112 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
131fn 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 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 .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
250pub 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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
349pub enum SearchViewLayout {
350 #[default]
352 Docked,
353 FullScreen,
355}
356
357#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
359pub enum SearchViewStyle {
360 #[default]
363 Contained,
364 Divided,
368}
369
370#[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 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 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
435pub(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 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;