1use tessera_ui::{
8 CallbackWith, Color, CursorEventContent, Dp, KeyboardInput, KeyboardInputModifierNode,
9 Modifier, PointerInput, PointerInputModifierNode, PressKeyEventType, RenderSlot, State,
10 layout::layout, modifier::ModifierCapabilityExt as _, remember, tessera, use_context, winit,
11};
12
13use crate::{
14 alignment::{Alignment, CrossAxisAlignment},
15 boxed::boxed,
16 column::column,
17 divider::horizontal_divider,
18 modifier::ModifierExt as _,
19 pos_misc::is_position_inside_bounds,
20 row::row,
21 shape_def::Shape,
22 spacer::spacer,
23 surface::surface,
24 text_field::{TextFieldDefaults, TextFieldLineLimit, text_field},
25 text_input::TextInputController,
26 theme::MaterialTheme,
27};
28
29const DEFAULT_RESULTS_PADDING: Dp = Dp(16.0);
30
31#[derive(Clone, PartialEq, Copy, Debug)]
33pub struct SearchBarColors {
34 pub container_color: Color,
36 pub divider_color: Color,
38}
39
40impl Default for SearchBarColors {
41 fn default() -> Self {
42 SearchBarColors {
43 container_color: Color::new(0.9, 0.9, 0.9, 1.0),
44 divider_color: Color::new(0.5, 0.5, 0.5, 1.0),
45 }
46 }
47}
48
49pub struct SearchBarDefaults;
51
52impl SearchBarDefaults {
53 pub const INPUT_HEIGHT: Dp = Dp(56.0);
55 pub const TONAL_ELEVATION: Dp = Dp(0.0);
57 pub const SHADOW_ELEVATION: Dp = Dp(0.0);
59 pub const DOCKED_DROPDOWN_GAP: Dp = Dp(2.0);
61 pub const DOCKED_DROPDOWN_RADIUS: Dp = Dp(12.0);
63
64 pub fn input_shape() -> Shape {
66 Shape::CAPSULE
67 }
68
69 pub fn docked_shape() -> Shape {
71 Shape::rounded_rectangle(Dp(28.0))
72 }
73
74 pub fn docked_dropdown_shape() -> Shape {
76 Shape::rounded_rectangle(Self::DOCKED_DROPDOWN_RADIUS)
77 }
78
79 pub fn colors() -> SearchBarColors {
81 SearchBarColors::default()
82 }
83}
84
85#[derive(Clone, PartialEq)]
86struct SearchBarProps {
87 modifier: Modifier,
88 enabled: bool,
89 read_only: bool,
90 is_active: Option<bool>,
91 placeholder: Option<String>,
92 leading_icon: Option<RenderSlot>,
93 trailing_icon: Option<RenderSlot>,
94 on_query_change: Option<CallbackWith<String, String>>,
95 on_search: Option<CallbackWith<String, ()>>,
96 on_active_change: Option<CallbackWith<bool, ()>>,
97 shape: Shape,
98 colors: SearchBarColors,
99 results_container_color: Option<Color>,
100 tonal_elevation: Dp,
101 shadow_elevation: Dp,
102 dropdown_shape: Shape,
103 dropdown_gap: Dp,
104 show_divider: bool,
105 content_padding: Dp,
106 controller: Option<State<SearchBarController>>,
107 content: Option<RenderSlot>,
108}
109
110impl Default for SearchBarProps {
111 fn default() -> Self {
112 Self {
113 modifier: Modifier::new(),
114 enabled: true,
115 read_only: false,
116 is_active: None,
117 placeholder: None,
118 leading_icon: None,
119 trailing_icon: None,
120 on_query_change: None,
121 on_search: None,
122 on_active_change: None,
123 shape: SearchBarDefaults::input_shape(),
124 colors: SearchBarDefaults::colors(),
125 results_container_color: None,
126 tonal_elevation: SearchBarDefaults::TONAL_ELEVATION,
127 shadow_elevation: SearchBarDefaults::SHADOW_ELEVATION,
128 dropdown_shape: SearchBarDefaults::docked_dropdown_shape(),
129 dropdown_gap: SearchBarDefaults::DOCKED_DROPDOWN_GAP,
130 show_divider: true,
131 content_padding: DEFAULT_RESULTS_PADDING,
132 controller: None,
133 content: None,
134 }
135 }
136}
137
138#[derive(Clone, PartialEq)]
140pub struct SearchBarController {
141 is_active: bool,
142 query: String,
143}
144
145impl SearchBarController {
146 pub fn new(initial_active: bool) -> Self {
148 Self {
149 is_active: initial_active,
150 query: String::new(),
151 }
152 }
153
154 pub fn open(&mut self) {
156 self.is_active = true;
157 }
158
159 pub fn close(&mut self) {
161 self.is_active = false;
162 }
163
164 pub fn toggle(&mut self) {
166 self.is_active = !self.is_active;
167 }
168
169 pub fn is_active(&self) -> bool {
171 self.is_active
172 }
173
174 pub fn set_query(&mut self, query: impl Into<String>) {
176 self.query = query.into();
177 }
178
179 pub fn query(&self) -> &str {
181 &self.query
182 }
183}
184
185impl Default for SearchBarController {
186 fn default() -> Self {
187 Self::new(false)
188 }
189}
190
191#[derive(Clone, PartialEq, Copy, Default)]
192enum SearchBarLayoutKind {
193 #[default]
194 FullScreen,
195 Docked,
196}
197
198#[tessera]
249pub fn search_bar(
250 modifier: Option<Modifier>,
251 enabled: Option<bool>,
252 read_only: Option<bool>,
253 is_active: Option<bool>,
254 #[prop(into)] placeholder: Option<String>,
255 leading_icon: Option<RenderSlot>,
256 trailing_icon: Option<RenderSlot>,
257 on_query_change: Option<CallbackWith<String, String>>,
258 on_search: Option<CallbackWith<String, ()>>,
259 on_active_change: Option<CallbackWith<bool, ()>>,
260 shape: Option<Shape>,
261 colors: Option<SearchBarColors>,
262 results_container_color: Option<Color>,
263 tonal_elevation: Option<Dp>,
264 shadow_elevation: Option<Dp>,
265 dropdown_shape: Option<Shape>,
266 dropdown_gap: Option<Dp>,
267 show_divider: Option<bool>,
268 content_padding: Option<Dp>,
269 controller: Option<State<SearchBarController>>,
270 content: Option<RenderSlot>,
271) {
272 let scheme = use_context::<MaterialTheme>()
273 .expect("MaterialTheme must be provided")
274 .get()
275 .color_scheme;
276 let args = resolve_search_bar(
277 modifier,
278 enabled,
279 read_only,
280 is_active,
281 placeholder,
282 leading_icon,
283 trailing_icon,
284 on_query_change,
285 on_search,
286 on_active_change,
287 shape,
288 colors,
289 results_container_color,
290 tonal_elevation,
291 shadow_elevation,
292 dropdown_shape,
293 dropdown_gap,
294 show_divider,
295 content_padding,
296 controller,
297 content,
298 scheme,
299 );
300 mount_search_bar(args, SearchBarLayoutKind::FullScreen);
301}
302
303fn resolve_search_bar(
304 modifier: Option<Modifier>,
305 enabled: Option<bool>,
306 read_only: Option<bool>,
307 is_active: Option<bool>,
308 placeholder: Option<String>,
309 leading_icon: Option<RenderSlot>,
310 trailing_icon: Option<RenderSlot>,
311 on_query_change: Option<CallbackWith<String, String>>,
312 on_search: Option<CallbackWith<String, ()>>,
313 on_active_change: Option<CallbackWith<bool, ()>>,
314 shape: Option<Shape>,
315 colors: Option<SearchBarColors>,
316 results_container_color: Option<Color>,
317 tonal_elevation: Option<Dp>,
318 shadow_elevation: Option<Dp>,
319 dropdown_shape: Option<Shape>,
320 dropdown_gap: Option<Dp>,
321 show_divider: Option<bool>,
322 content_padding: Option<Dp>,
323 controller: Option<State<SearchBarController>>,
324 content: Option<RenderSlot>,
325 scheme: crate::theme::MaterialColorScheme,
326) -> SearchBarProps {
327 let modifier = modifier.unwrap_or_default();
328 let enabled = enabled.unwrap_or(true);
329 let read_only = read_only.unwrap_or(false);
330 let shape = shape.unwrap_or(SearchBarDefaults::docked_shape());
331 let colors = colors.unwrap_or(SearchBarColors {
332 container_color: scheme.surface_container_high,
333 divider_color: scheme.outline,
334 });
335 let defaults = SearchBarProps::default();
336 let tonal_elevation = tonal_elevation.unwrap_or(defaults.tonal_elevation);
337 let shadow_elevation = shadow_elevation.unwrap_or(defaults.shadow_elevation);
338 let dropdown_shape = dropdown_shape.unwrap_or(defaults.dropdown_shape);
339 let dropdown_gap = dropdown_gap.unwrap_or(defaults.dropdown_gap);
340 let show_divider = show_divider.unwrap_or(defaults.show_divider);
341 let content_padding = content_padding.unwrap_or(defaults.content_padding);
342 SearchBarProps {
343 modifier,
344 enabled,
345 read_only,
346 is_active,
347 placeholder,
348 leading_icon,
349 trailing_icon,
350 on_query_change,
351 on_search,
352 on_active_change,
353 shape,
354 colors,
355 results_container_color,
356 tonal_elevation,
357 shadow_elevation,
358 dropdown_shape,
359 dropdown_gap,
360 show_divider,
361 content_padding,
362 controller,
363 content,
364 }
365}
366
367#[tessera]
397pub fn docked_search_bar(
398 modifier: Option<Modifier>,
399 enabled: Option<bool>,
400 read_only: Option<bool>,
401 is_active: Option<bool>,
402 #[prop(into)] placeholder: Option<String>,
403 leading_icon: Option<RenderSlot>,
404 trailing_icon: Option<RenderSlot>,
405 on_query_change: Option<CallbackWith<String, String>>,
406 on_search: Option<CallbackWith<String, ()>>,
407 on_active_change: Option<CallbackWith<bool, ()>>,
408 shape: Option<Shape>,
409 colors: Option<SearchBarColors>,
410 results_container_color: Option<Color>,
411 tonal_elevation: Option<Dp>,
412 shadow_elevation: Option<Dp>,
413 dropdown_shape: Option<Shape>,
414 dropdown_gap: Option<Dp>,
415 show_divider: Option<bool>,
416 content_padding: Option<Dp>,
417 controller: Option<State<SearchBarController>>,
418 content: Option<RenderSlot>,
419) {
420 let scheme = use_context::<MaterialTheme>()
421 .expect("MaterialTheme must be provided")
422 .get()
423 .color_scheme;
424 let args = resolve_search_bar(
425 modifier,
426 enabled,
427 read_only,
428 is_active,
429 placeholder,
430 leading_icon,
431 trailing_icon,
432 on_query_change,
433 on_search,
434 on_active_change,
435 shape,
436 colors,
437 results_container_color,
438 tonal_elevation,
439 shadow_elevation,
440 dropdown_shape,
441 dropdown_gap,
442 show_divider,
443 content_padding,
444 controller,
445 content,
446 scheme,
447 );
448 mount_search_bar(args, SearchBarLayoutKind::Docked);
449}
450
451#[tessera]
452fn mount_search_bar(args: SearchBarProps, kind: SearchBarLayoutKind) {
453 let mut args = args;
454 let controller = if let Some(controller) = args.controller {
455 controller
456 } else {
457 remember(|| SearchBarController::new(args.is_active.unwrap_or(false)))
458 };
459 args.controller = Some(controller);
460 let render_args = build_search_bar_props(args, kind);
461 let modifier = render_args.modifier.clone();
462 layout().modifier(modifier).child(move || {
463 search_bar_inner()
464 .kind(render_args.kind)
465 .enabled(render_args.enabled)
466 .read_only(render_args.read_only)
467 .shape(render_args.shape)
468 .colors(render_args.colors)
469 .results_container_color_optional(render_args.results_container_color)
470 .tonal_elevation(render_args.tonal_elevation)
471 .shadow_elevation(render_args.shadow_elevation)
472 .dropdown_shape(render_args.dropdown_shape)
473 .dropdown_gap(render_args.dropdown_gap)
474 .show_divider(render_args.show_divider)
475 .content_padding(render_args.content_padding)
476 .controller(render_args.controller)
477 .content_shared(render_args.content)
478 .on_query_change_shared(render_args.on_query_change)
479 .on_search_shared(render_args.on_search)
480 .on_active_change_shared(render_args.on_active_change)
481 .placeholder_optional(render_args.placeholder.clone())
482 .leading_icon_optional(render_args.leading_icon)
483 .trailing_icon_optional(render_args.trailing_icon);
484 });
485}
486
487fn build_search_bar_props(args: SearchBarProps, kind: SearchBarLayoutKind) -> SearchBarRenderArgs {
488 let content = args.content.unwrap_or_else(RenderSlot::empty);
489 let controller = args
490 .controller
491 .expect("build_search_bar_props requires controller to be set");
492 if let Some(is_active) = args.is_active
493 && is_active != controller.with(|c| c.is_active())
494 {
495 if is_active {
496 controller.with_mut(|c| c.open());
497 } else {
498 controller.with_mut(|c| c.close());
499 }
500 }
501
502 SearchBarRenderArgs {
503 kind,
504 modifier: args.modifier,
505 enabled: args.enabled,
506 read_only: args.read_only,
507 placeholder: args.placeholder,
508 leading_icon: args.leading_icon,
509 trailing_icon: args.trailing_icon,
510 on_query_change: args.on_query_change.unwrap_or_else(CallbackWith::identity),
511 on_search: args.on_search.unwrap_or_else(CallbackWith::default_value),
512 on_active_change: args
513 .on_active_change
514 .unwrap_or_else(CallbackWith::default_value),
515 shape: args.shape,
516 colors: args.colors,
517 results_container_color: args.results_container_color,
518 tonal_elevation: args.tonal_elevation,
519 shadow_elevation: args.shadow_elevation,
520 dropdown_shape: args.dropdown_shape,
521 dropdown_gap: args.dropdown_gap,
522 show_divider: args.show_divider,
523 content_padding: args.content_padding,
524 controller,
525 content,
526 }
527}
528
529#[derive(Clone)]
530struct SearchBarRenderArgs {
531 kind: SearchBarLayoutKind,
532 modifier: Modifier,
533 enabled: bool,
534 read_only: bool,
535 placeholder: Option<String>,
536 leading_icon: Option<RenderSlot>,
537 trailing_icon: Option<RenderSlot>,
538 on_query_change: CallbackWith<String, String>,
539 on_search: CallbackWith<String, ()>,
540 on_active_change: CallbackWith<bool, ()>,
541 shape: Shape,
542 colors: SearchBarColors,
543 results_container_color: Option<Color>,
544 tonal_elevation: Dp,
545 shadow_elevation: Dp,
546 dropdown_shape: Shape,
547 dropdown_gap: Dp,
548 show_divider: bool,
549 content_padding: Dp,
550 controller: State<SearchBarController>,
551 content: RenderSlot,
552}
553
554#[derive(Clone)]
555struct SearchResultsSurfaceArgs {
556 kind: SearchBarLayoutKind,
557 container_color: Color,
558 divider_color: Color,
559 show_divider: bool,
560 dropdown_shape: Shape,
561 tonal_elevation: Dp,
562 shadow_elevation: Dp,
563 content_padding: Dp,
564 content: RenderSlot,
565}
566
567#[derive(Clone, Copy)]
568struct SearchInteractionModifier {
569 enabled: bool,
570 controller: State<SearchBarController>,
571 input_controller: State<TextInputController>,
572 on_active_change: CallbackWith<bool>,
573 on_search: CallbackWith<String>,
574}
575
576impl PointerInputModifierNode for SearchInteractionModifier {
577 fn on_pointer_input(&self, input: PointerInput<'_>) {
578 let within_bounds = input
579 .cursor_position_rel
580 .map(|pos| is_position_inside_bounds(input.computed_data, pos))
581 .unwrap_or(false);
582 if self.enabled
583 && within_bounds
584 && input.pointer_changes.iter().any(|change| {
585 !change.is_consumed()
586 && matches!(
587 change.content,
588 CursorEventContent::Pressed(PressKeyEventType::Left)
589 )
590 })
591 && !self.controller.with(SearchBarController::is_active)
592 {
593 self.controller.with_mut(SearchBarController::open);
594 self.on_active_change.call(true);
595 }
596 }
597}
598
599impl KeyboardInputModifierNode for SearchInteractionModifier {
600 fn on_keyboard_input(&self, mut input: KeyboardInput<'_>) {
601 if !self.enabled || !self.controller.with(SearchBarController::is_active) {
602 return;
603 }
604 let mut handled = false;
605 for event in input.keyboard_events.iter() {
606 if event.state != winit::event::ElementState::Pressed {
607 continue;
608 }
609 match event.physical_key {
610 winit::keyboard::PhysicalKey::Code(winit::keyboard::KeyCode::Escape) => {
611 self.controller.with_mut(SearchBarController::close);
612 self.on_active_change.call(false);
613 handled = true;
614 }
615 winit::keyboard::PhysicalKey::Code(
616 winit::keyboard::KeyCode::Enter | winit::keyboard::KeyCode::NumpadEnter,
617 ) if self
618 .input_controller
619 .with(|input| input.focus_handler().is_focused()) =>
620 {
621 self.on_search
622 .call(self.controller.with(|c| c.query().to_owned()));
623 handled = true;
624 }
625 _ => {}
626 }
627 }
628 if handled {
629 input.block_keyboard();
630 }
631 }
632}
633
634#[tessera]
635fn search_bar_inner(
636 kind: Option<SearchBarLayoutKind>,
637 enabled: Option<bool>,
638 read_only: Option<bool>,
639 #[prop(into)] placeholder: Option<String>,
640 leading_icon: Option<RenderSlot>,
641 trailing_icon: Option<RenderSlot>,
642 on_query_change: Option<CallbackWith<String, String>>,
643 on_search: Option<CallbackWith<String, ()>>,
644 on_active_change: Option<CallbackWith<bool, ()>>,
645 shape: Option<Shape>,
646 colors: Option<SearchBarColors>,
647 results_container_color: Option<Color>,
648 tonal_elevation: Option<Dp>,
649 shadow_elevation: Option<Dp>,
650 dropdown_shape: Option<Shape>,
651 dropdown_gap: Option<Dp>,
652 show_divider: Option<bool>,
653 content_padding: Option<Dp>,
654 controller: Option<State<SearchBarController>>,
655 content: Option<RenderSlot>,
656) {
657 let kind = kind.expect("search_bar_inner requires kind");
658 let enabled = enabled.expect("search_bar_inner requires enabled");
659 let read_only = read_only.expect("search_bar_inner requires read_only");
660 let shape = shape.expect("search_bar_inner requires shape");
661 let colors = colors.expect("search_bar_inner requires colors");
662 let tonal_elevation = tonal_elevation.expect("search_bar_inner requires tonal_elevation");
663 let shadow_elevation = shadow_elevation.expect("search_bar_inner requires shadow_elevation");
664 let dropdown_shape = dropdown_shape.expect("search_bar_inner requires dropdown_shape");
665 let dropdown_gap = dropdown_gap.expect("search_bar_inner requires dropdown_gap");
666 let show_divider = show_divider.expect("search_bar_inner requires show_divider");
667 let content_padding = content_padding.expect("search_bar_inner requires content_padding");
668 let controller = controller.expect("search_bar_inner requires controller");
669 let content = content.expect("search_bar_inner requires content");
670 let on_query_change = on_query_change.expect("search_bar_inner requires on_query_change");
671 let on_search = on_search.expect("search_bar_inner requires on_search");
672 let on_active_change = on_active_change.expect("search_bar_inner requires on_active_change");
673 let theme = use_context::<MaterialTheme>()
674 .expect("MaterialTheme must be provided")
675 .get();
676
677 let font_size = theme.typography.body_large.font_size;
678 let line_height = theme.typography.body_large.line_height;
679 let input_controller = remember(|| TextInputController::new(font_size, line_height));
680 let synced_query = remember(String::new);
681
682 sync_query(&controller, &input_controller, &synced_query);
683
684 let interaction = SearchInteractionModifier {
685 enabled,
686 controller,
687 input_controller,
688 on_active_change,
689 on_search,
690 };
691 let modifier = Modifier::new()
692 .push_pointer_preview_input(interaction)
693 .push_keyboard_preview_input(interaction);
694
695 let results_surface_args = SearchResultsSurfaceArgs {
696 kind,
697 container_color: results_container_color.unwrap_or(colors.container_color),
698 divider_color: colors.divider_color,
699 show_divider,
700 dropdown_shape,
701 tonal_elevation,
702 shadow_elevation,
703 content_padding,
704 content,
705 };
706
707 layout().modifier(modifier).child(move || {
708 let placeholder = placeholder.clone();
709 let leading_icon = leading_icon;
710 let trailing_icon = trailing_icon;
711 let results_surface_args = results_surface_args.clone();
712 column().children(move || {
713 search_field().args(SearchFieldArgs {
714 enabled,
715 read_only,
716 placeholder: placeholder.clone(),
717 leading_icon,
718 trailing_icon,
719 shape,
720 colors,
721 controller,
722 input_controller,
723 synced_query,
724 on_query_change,
725 });
726
727 if controller.with(|c| c.is_active()) {
728 if matches!(kind, SearchBarLayoutKind::Docked) {
729 spacer().modifier(Modifier::new().height(dropdown_gap));
730 }
731
732 let shape = match results_surface_args.kind {
733 SearchBarLayoutKind::FullScreen => Shape::RECTANGLE,
734 SearchBarLayoutKind::Docked => results_surface_args.dropdown_shape,
735 };
736
737 let modifier = match results_surface_args.kind {
738 SearchBarLayoutKind::FullScreen => {
739 Modifier::new().fill_max_width().fill_max_height()
740 }
741 SearchBarLayoutKind::Docked => Modifier::new().fill_max_width(),
742 };
743
744 let content = results_surface_args.content;
745 let divider_color = results_surface_args.divider_color;
746 let show_divider = results_surface_args.show_divider;
747 let content_padding = results_surface_args.content_padding;
748
749 surface()
750 .style(results_surface_args.container_color.into())
751 .shape(shape)
752 .modifier(modifier)
753 .tonal_elevation(results_surface_args.tonal_elevation)
754 .block_input(true)
755 .elevation(results_surface_args.shadow_elevation)
756 .child(move || {
757 let content = content;
758 column().children(move || {
759 if show_divider {
760 horizontal_divider().color(divider_color);
761 }
762 let content = content;
763 layout()
764 .modifier(Modifier::new().padding_all(content_padding))
765 .child(move || {
766 content.render();
767 });
768 });
769 });
770 }
771 });
772 });
773}
774
775#[derive(Clone, PartialEq)]
776struct SearchFieldArgs {
777 enabled: bool,
778 read_only: bool,
779 placeholder: Option<String>,
780 leading_icon: Option<RenderSlot>,
781 trailing_icon: Option<RenderSlot>,
782 shape: Shape,
783 colors: SearchBarColors,
784 controller: State<SearchBarController>,
785 input_controller: State<TextInputController>,
786 synced_query: State<String>,
787 on_query_change: CallbackWith<String, String>,
788}
789
790#[tessera]
791fn search_field(args: Option<SearchFieldArgs>) {
792 let args = args.expect("search_field requires args");
793 let has_icons = args.leading_icon.is_some() || args.trailing_icon.is_some();
794 let scheme = use_context::<MaterialTheme>().unwrap().get().color_scheme;
795 let color = if args.enabled {
796 scheme.on_surface_variant
797 } else {
798 scheme.on_surface.with_alpha(0.38)
799 };
800 surface()
801 .modifier(
802 Modifier::new()
803 .fill_max_width()
804 .height(SearchBarDefaults::INPUT_HEIGHT),
805 )
806 .style(args.colors.container_color.into())
807 .shape(args.shape)
808 .content_color(color)
809 .block_input(!args.enabled)
810 .child(move || {
811 let args = args.clone();
812 row()
813 .modifier(
814 Modifier::new()
815 .fill_max_size()
816 .padding_symmetric(Dp(4.0), Dp::ZERO),
817 )
818 .cross_axis_alignment(CrossAxisAlignment::Center)
819 .children(move || {
820 let trailing_icon = args.trailing_icon;
821 if let Some(leading) = args.leading_icon {
822 boxed()
823 .modifier(
824 Modifier::new()
825 .default_min_size(Dp(48.0), Dp::ZERO)
826 .fill_max_height(),
827 )
828 .alignment(Alignment::Center)
829 .children(move || leading.render());
830 } else if has_icons {
831 spacer().modifier(Modifier::new().width(Dp(8.0)));
832 }
833 let args = args.clone();
834 boxed()
835 .modifier(Modifier::new().weight(1.0).fill_max_height())
836 .children(move || {
837 text_field()
838 .filled()
839 .enabled(args.enabled)
840 .read_only(args.read_only)
841 .modifier(
842 Modifier::new()
843 .fill_max_width()
844 .height(SearchBarDefaults::INPUT_HEIGHT),
845 )
846 .min_width(Dp::ZERO)
847 .min_height(SearchBarDefaults::INPUT_HEIGHT)
848 .shape(args.shape)
849 .background_color(Color::TRANSPARENT)
850 .focus_background_color(Color::TRANSPARENT)
851 .border_width(Dp(0.0))
852 .border_color(Color::TRANSPARENT)
853 .focus_border_width(Dp(0.0))
854 .focus_border_color(Color::TRANSPARENT)
855 .show_indicator(false)
856 .padding(TextFieldDefaults::CONTENT_PADDING)
857 .horizontal_padding(if has_icons { Dp(4.0) } else { Dp(12.0) })
858 .line_limit(TextFieldLineLimit::SingleLine)
859 .on_change(move |text| {
860 let next = args.on_query_change.call(text);
861 args.controller.with_mut(|c| c.set_query(next.clone()));
862 args.synced_query.set(next.clone());
863 next
864 })
865 .controller(args.input_controller)
866 .accessibility_label_optional(args.placeholder.clone())
867 .placeholder_optional(args.placeholder.clone());
868 });
869
870 if let Some(trailing) = trailing_icon {
871 boxed()
872 .modifier(
873 Modifier::new()
874 .default_min_size(Dp(48.0), Dp::ZERO)
875 .fill_max_height(),
876 )
877 .alignment(Alignment::Center)
878 .children(move || trailing.render());
879 } else if has_icons {
880 spacer().modifier(Modifier::new().width(Dp(8.0)));
881 }
882 });
883 });
884}
885
886fn sync_query(
887 controller: &State<SearchBarController>,
888 input: &State<TextInputController>,
889 synced_query: &State<String>,
890) {
891 let needs_sync =
892 controller.with(|c| synced_query.with(|current| current.as_str() != c.query()));
893 if needs_sync {
894 let query = controller.with(|c| c.query().to_owned());
895 input.with_mut(|c| c.set_text(&query));
896 synced_query.set(query);
897 }
898}
899
900#[cfg(test)]
901mod tests {
902 use tessera_ui::{
903 CursorEventContent, GestureState, Modifier, PointerChange, PressKeyEventType, Px,
904 PxPosition, State, remember, tessera,
905 testing::{
906 TestProbe, layout_test, pump_layout_test_session, pump_layout_test_session_with_input,
907 start_layout_test_session,
908 },
909 };
910
911 use crate::{modifier::ModifierExt as _, spacer::spacer, theme::material_theme};
912
913 use super::{SearchBarController, docked_search_bar};
914
915 #[tessera]
916 fn search_fixture(
917 active: Option<bool>,
918 show_divider: Option<bool>,
919 probe: Option<TestProbe<Option<State<SearchBarController>>>>,
920 ) {
921 let controller = remember(SearchBarController::default);
922 if let Some(probe) = probe {
923 probe.set(Some(controller));
924 }
925 material_theme().child(move || {
926 docked_search_bar()
927 .controller(controller)
928 .is_active_optional(active)
929 .show_divider_optional(show_divider)
930 .content(|| {
931 spacer().modifier(
932 Modifier::new()
933 .fill_max_width()
934 .height(tessera_ui::Dp(40.0))
935 .semantics(crate::modifier::SemanticsArgs {
936 test_tag: Some("search-results".into()),
937 ..Default::default()
938 }),
939 );
940 });
941 });
942 }
943
944 #[tessera]
945 fn multiple_search_actions_fixture() {
946 material_theme().child(|| {
947 docked_search_bar()
948 .placeholder("Search")
949 .trailing_icon(|| {
950 crate::row::row().children(|| {
951 for tag in ["first-search-action", "second-search-action"] {
952 spacer().modifier(
953 Modifier::new()
954 .size(tessera_ui::Dp(48.0), tessera_ui::Dp(48.0))
955 .semantics(crate::modifier::SemanticsArgs {
956 test_tag: Some(tag.into()),
957 ..Default::default()
958 }),
959 );
960 }
961 });
962 })
963 .content(|| {});
964 });
965 }
966
967 #[test]
968 fn search_measures_multiple_trailing_actions_without_clipping_their_layout() {
969 let snapshot = layout_test(|| multiple_search_actions_fixture())
970 .viewport_px(400, 100)
971 .run();
972 snapshot
973 .node("first-search-action")
974 .position(300, 4)
975 .size(48, 48);
976 snapshot
977 .node("second-search-action")
978 .position(348, 4)
979 .size(48, 48);
980 }
981
982 #[test]
983 fn contained_search_removes_divider_geometry_while_divided_search_preserves_it() {
984 for (show_divider, content_y) in [(None, 75), (Some(true), 75), (Some(false), 74)] {
985 let snapshot = layout_test(|| {
986 search_fixture()
987 .active(true)
988 .show_divider_optional(show_divider);
989 })
990 .viewport_px(400, 300)
991 .run();
992 assert_eq!(snapshot.node("search-results").bounds().y, Px(content_y));
993 snapshot.node("search-results").size(368, 40);
994 }
995 }
996
997 #[test]
998 fn clicking_search_field_opens_uncontrolled_results_and_preserves_controlled_state() {
999 for (active, expected) in [(None, true), (Some(false), false), (Some(true), true)] {
1000 let probe = TestProbe::default();
1001 let mut session = start_layout_test_session(
1002 layout_test(|| {
1003 search_fixture()
1004 .active_optional(active)
1005 .probe(probe.clone());
1006 })
1007 .viewport_px(400, 300),
1008 );
1009 let _ = pump_layout_test_session(&mut session, 0);
1010 let now = tessera_ui::time::Instant::now();
1011 let cursor = Some(PxPosition::new(Px(100), Px(28)));
1012 for (index, content) in [
1013 CursorEventContent::Pressed(PressKeyEventType::Left),
1014 CursorEventContent::Released(PressKeyEventType::Left),
1015 ]
1016 .into_iter()
1017 .enumerate()
1018 {
1019 let _ = pump_layout_test_session_with_input(
1020 &mut session,
1021 16_666_667 * (index as u64 + 1),
1022 vec![PointerChange::new(
1023 now,
1024 1,
1025 content,
1026 GestureState::TapCandidate,
1027 )],
1028 cursor,
1029 );
1030 }
1031 let _ = pump_layout_test_session(&mut session, 50_000_001);
1032 let controller = probe.get().unwrap();
1033 assert_eq!(
1034 controller.with(SearchBarController::is_active),
1035 expected,
1036 "active={active:?}"
1037 );
1038 }
1039 }
1040}