1use std::cell::{OnceCell, RefCell};
37use std::rc::Rc;
38
39use frus_core::{BorderRadius, BorderSide, Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
40use frus_layout::{Dimension, FlexDirection, Style};
41
42use crate::flex::Flex;
43use crate::interaction::Status;
44use crate::portal::Placement;
45use crate::theme::Theme;
46use crate::widget::Widget;
47use crate::widgettheme::resolve_shape;
48
49pub const SEARCH_VIEW_RADIUS: f32 = 28.0;
52pub const SEARCH_VIEW_MIN_WIDTH: f32 = 360.0;
54pub const SEARCH_VIEW_MIN_HEIGHT: f32 = 240.0;
56pub const SEARCH_VIEW_FULL_SCREEN_HEADER: f32 = 72.0;
62const ELEVATION: f32 = 6.0;
64const BAR_PADDING_X: f32 = 8.0;
66
67const fn full_screen_by_default() -> bool {
76 #[cfg(any(target_os = "ios", target_os = "android"))]
77 {
78 true
79 }
80 #[cfg(not(any(target_os = "ios", target_os = "android")))]
81 {
82 false
83 }
84}
85
86pub struct SearchAnchor<Msg = crate::callback::Callback> {
88 open: bool,
89 query: String,
90 hint: Option<String>,
91 view_hint: Option<String>,
92 anchor: RefCell<Option<Box<dyn Widget<Msg>>>>,
93 suggestions: RefCell<Vec<Box<dyn Widget<Msg>>>>,
94 view_leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
95 view_trailing: RefCell<Vec<Box<dyn Widget<Msg>>>>,
96 on_open: Option<Msg>,
97 on_close: Option<Msg>,
98 on_clear: Option<Msg>,
99 on_input: Option<Rc<dyn Fn(String) -> Msg>>,
100 on_submit: Option<Msg>,
101 enabled: bool,
102 full_screen: Option<bool>,
103 background: Option<Color>,
104 elevation: Option<f32>,
105 shape: Option<ShapeBorder>,
106 radius: Option<BorderRadius>,
107 side: Option<BorderSide>,
108 divider_color: Option<Color>,
109 header_height: Option<f32>,
110 header_text_style: Option<TextStyle>,
111 header_hint_style: Option<TextStyle>,
112 min_width: Option<f32>,
113 min_height: Option<f32>,
114 bar_padding: Option<Insets>,
115 built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
116}
117
118impl<Msg: Clone + 'static> SearchAnchor<Msg> {
119 pub fn new(open: bool, query: impl Into<String>) -> Self {
121 Self {
122 open,
123 query: query.into(),
124 hint: None,
125 view_hint: None,
126 anchor: RefCell::new(None),
127 suggestions: RefCell::new(Vec::new()),
128 view_leading: RefCell::new(None),
129 view_trailing: RefCell::new(Vec::new()),
130 on_open: None,
131 on_close: None,
132 on_clear: None,
133 on_input: None,
134 on_submit: None,
135 enabled: true,
136 full_screen: None,
137 background: None,
138 elevation: None,
139 shape: None,
140 radius: None,
141 side: None,
142 divider_color: None,
143 header_height: None,
144 header_text_style: None,
145 header_hint_style: None,
146 min_width: None,
147 min_height: None,
148 bar_padding: None,
149 built: OnceCell::new(),
150 }
151 }
152
153 pub fn hint(mut self, hint: impl Into<String>) -> Self {
155 self.hint = Some(hint.into());
156 self.rebuild();
157 self
158 }
159
160 pub fn view_hint(mut self, hint: impl Into<String>) -> Self {
164 self.view_hint = Some(hint.into());
165 self.rebuild();
166 self
167 }
168
169 pub fn anchor(self, anchor: impl Widget<Msg> + 'static) -> Self {
174 self.anchor_boxed(Box::new(anchor))
175 }
176
177 pub fn anchor_boxed(mut self, anchor: Box<dyn Widget<Msg>>) -> Self {
179 *self.anchor.borrow_mut() = Some(anchor);
180 self.rebuild();
181 self
182 }
183
184 pub fn suggestion(self, suggestion: impl Widget<Msg> + 'static) -> Self {
189 self.suggestion_boxed(Box::new(suggestion))
190 }
191
192 pub fn suggestion_boxed(mut self, suggestion: Box<dyn Widget<Msg>>) -> Self {
194 self.suggestions.borrow_mut().push(suggestion);
195 self.rebuild();
196 self
197 }
198
199 pub fn view_leading(self, leading: impl Widget<Msg> + 'static) -> Self {
202 self.view_leading_boxed(Box::new(leading))
203 }
204
205 pub fn view_leading_boxed(mut self, leading: Box<dyn Widget<Msg>>) -> Self {
207 *self.view_leading.borrow_mut() = Some(leading);
208 self.rebuild();
209 self
210 }
211
212 pub fn view_trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
215 self.view_trailing_boxed(Box::new(trailing))
216 }
217
218 pub fn view_trailing_boxed(mut self, trailing: Box<dyn Widget<Msg>>) -> Self {
220 self.view_trailing.borrow_mut().push(trailing);
221 self.rebuild();
222 self
223 }
224
225 pub fn on_open(mut self, message: impl Into<Msg>) -> Self {
228 self.on_open = Some(message.into());
229 self.rebuild();
230 self
231 }
232
233 pub fn on_close(mut self, message: impl Into<Msg>) -> Self {
235 self.on_close = Some(message.into());
236 self.rebuild();
237 self
238 }
239
240 pub fn on_clear(mut self, message: impl Into<Msg>) -> Self {
244 self.on_clear = Some(message.into());
245 self.rebuild();
246 self
247 }
248
249 pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
251 mut self,
252 on_input: impl Fn(String) -> R + 'static,
253 ) -> Self {
254 self.on_input = Some(Rc::new(crate::callback::handler1(on_input)));
255 self.rebuild();
256 self
257 }
258
259 pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
261 self.on_submit = Some(message.into());
262 self.rebuild();
263 self
264 }
265
266 pub fn enabled(mut self, enabled: bool) -> Self {
268 self.enabled = enabled;
269 self.rebuild();
270 self
271 }
272
273 pub fn full_screen(mut self, full_screen: bool) -> Self {
281 self.full_screen = Some(full_screen);
282 self.rebuild();
283 self
284 }
285
286 pub fn view_background_color(mut self, color: Color) -> Self {
290 self.background = Some(color);
291 self.rebuild();
292 self
293 }
294
295 pub fn view_elevation(mut self, elevation: f32) -> Self {
297 self.elevation = Some(elevation);
298 self
299 }
300
301 pub fn view_shape(mut self, shape: ShapeBorder) -> Self {
304 self.shape = Some(shape);
305 self
306 }
307
308 pub fn view_radius(mut self, radius: impl Into<BorderRadius>) -> Self {
310 self.radius = Some(radius.into());
311 self
312 }
313
314 pub fn view_side(mut self, width: f32, color: Color) -> Self {
316 self.side = Some(BorderSide { width, color });
317 self
318 }
319
320 pub fn divider_color(mut self, color: Color) -> Self {
323 self.divider_color = Some(color);
324 self.rebuild();
325 self
326 }
327
328 pub fn header_height(mut self, height: f32) -> Self {
331 self.header_height = Some(height);
332 self.rebuild();
333 self
334 }
335
336 pub fn header_text_style(mut self, style: TextStyle) -> Self {
338 self.header_text_style = Some(style);
339 self.rebuild();
340 self
341 }
342
343 pub fn header_hint_style(mut self, style: TextStyle) -> Self {
345 self.header_hint_style = Some(style);
346 self.rebuild();
347 self
348 }
349
350 pub fn view_min_width(mut self, width: f32) -> Self {
352 self.min_width = Some(width);
353 self.rebuild();
354 self
355 }
356
357 pub fn view_min_height(mut self, height: f32) -> Self {
359 self.min_height = Some(height);
360 self.rebuild();
361 self
362 }
363
364 pub fn view_bar_padding(mut self, padding: Insets) -> Self {
366 self.bar_padding = Some(padding);
367 self.rebuild();
368 self
369 }
370
371 fn rebuild(&mut self) {
372 self.built.take();
373 }
374
375 fn is_full_screen(&self) -> bool {
377 self.full_screen.unwrap_or_else(full_screen_by_default)
378 }
379
380 fn view_outline(&self, theme: &Theme) -> ShapeBorder {
384 let t = &theme.widgets.search_view;
385 let shape = resolve_shape(
386 self.shape,
387 t.shape,
388 self.radius.or(t.radius),
389 if self.is_full_screen() {
390 ShapeBorder::rounded(BorderRadius::ZERO)
391 } else {
392 ShapeBorder::rounded(BorderRadius::uniform(SEARCH_VIEW_RADIUS))
393 },
394 );
395 match self.side.or(t.side) {
396 Some(side) => shape.with_side(side),
397 None => shape,
398 }
399 }
400
401 fn header(&self, theme: &Theme) -> f32 {
403 self.header_height
404 .or(theme.widgets.search_view.header_height)
405 .unwrap_or(if self.is_full_screen() {
406 SEARCH_VIEW_FULL_SCREEN_HEADER
407 } else {
408 crate::SEARCH_BAR_HEIGHT
409 })
410 }
411
412 fn build_anchor(&self) -> Box<dyn Widget<Msg>> {
414 if let Some(anchor) = self.anchor.borrow_mut().take() {
415 return anchor;
416 }
417 let mut bar = crate::SearchBar::<Msg>::new(self.query.clone())
418 .enabled(self.enabled)
419 .read_only(true);
424 if let Some(hint) = &self.hint {
425 bar = bar.hint(hint.clone());
426 }
427 if let Some(message) = &self.on_open {
428 bar = bar.on_tap(message.clone());
429 }
430 Box::new(bar)
431 }
432
433 fn build_header(&self, theme: &Theme) -> Box<dyn Widget<Msg>> {
435 let words = crate::localizations::of();
436 let room = self
437 .bar_padding
438 .or(theme.widgets.search_view.bar_padding)
439 .unwrap_or(Insets::new(0.0, BAR_PADDING_X, 0.0, BAR_PADDING_X));
440 let mut header = crate::SearchBar::<Msg>::new(self.query.clone())
441 .background_color(Color::TRANSPARENT)
446 .elevation(0.0)
447 .min_width(0.0)
448 .height(self.header(theme))
449 .padding(room);
450 if let Some(hint) = self.view_hint.as_ref().or(self.hint.as_ref()) {
451 header = header.hint(hint.clone());
452 }
453 if let Some(style) = self
454 .header_text_style
455 .or(theme.widgets.search_view.header_text_style)
456 {
457 header = header.text_style(style);
458 }
459 if let Some(style) = self
460 .header_hint_style
461 .or(theme.widgets.search_view.header_hint_style)
462 {
463 header = header.hint_style(style);
464 }
465 header = match self.view_leading.borrow_mut().take() {
466 Some(leading) => header.leading_boxed(leading),
467 None => {
468 let mut back = crate::BackButton::<Msg>::new().icon_size(20.0);
469 if let Some(message) = &self.on_close {
470 back = back.on_press(message.clone());
471 }
472 header.leading(back)
473 }
474 };
475 let told = std::mem::take(&mut *self.view_trailing.borrow_mut());
476 if told.is_empty() {
477 if !self.query.is_empty() {
480 if let Some(message) = &self.on_clear {
481 header = header.trailing(
482 crate::IconButton::new(crate::Icons::CLOSE)
483 .icon_size(20.0)
484 .label(words.clear_button_label())
485 .on_press(message.clone()),
486 );
487 }
488 }
489 } else {
490 for trailing in told {
491 header = header.trailing_boxed(trailing);
492 }
493 }
494 Box::new(header)
495 }
496
497 fn build_view(&self, theme: &Theme) -> Box<dyn Widget<Msg>> {
499 let t = &theme.widgets.search_view;
500 let full = self.is_full_screen();
501 let screen = crate::MediaQuery::of().size;
502
503 let mut column = Flex::<Msg>::column()
509 .flex(1.0)
510 .child_boxed(self.build_header(theme));
511 column = column.child(
512 crate::Divider::new().height(1.0).color(
513 self.divider_color
514 .or(t.divider_color)
515 .unwrap_or(theme.scheme.outline),
516 ),
517 );
518 let mut list = Flex::<Msg>::column();
519 for suggestion in std::mem::take(&mut *self.suggestions.borrow_mut()) {
520 list = list.child_boxed(suggestion);
521 }
522 column = column.child(
523 crate::SingleChildScrollView::<Msg>::new()
524 .flex(1.0)
525 .child(list),
526 );
527
528 Box::new(SearchView {
529 children: vec![Box::new(column)],
530 full,
531 screen: (screen.width, screen.height),
532 min_width: self
533 .min_width
534 .or(t.min_width)
535 .unwrap_or(SEARCH_VIEW_MIN_WIDTH),
536 min_height: self
537 .min_height
538 .or(t.min_height)
539 .unwrap_or(SEARCH_VIEW_MIN_HEIGHT),
540 background: self
541 .background
542 .or(t.background_color)
543 .unwrap_or(theme.scheme.surface_container_high),
544 elevation: self.elevation.or(t.elevation).unwrap_or(ELEVATION),
545 shape: self.view_outline(theme),
546 })
547 }
548
549 fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
551 let anchor = self.build_anchor();
552 let mut portal = crate::OverlayPortal::<Msg>::new_boxed(anchor);
553 if self.open && self.enabled {
554 let placement = if self.is_full_screen() {
558 Placement::Center
559 } else {
560 Placement::Below
561 };
562 portal = portal.overlay_boxed(self.build_view(theme), placement);
563 if let Some(message) = &self.on_close {
564 portal = portal.dismiss(message.clone());
565 }
566 }
567 vec![Box::new(portal)]
568 }
569}
570
571impl<Msg: Clone + 'static> Widget<Msg> for SearchAnchor<Msg> {
572 fn style(&self) -> Style {
573 Style {
574 flex_direction: FlexDirection::Column,
575 ..Default::default()
576 }
577 }
578
579 fn build_themed(&self, theme: &Theme) {
580 let _ = self.built.set(self.assemble(theme));
581 }
582
583 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
584 self.built.get_or_init(|| self.assemble(&Theme::default()))
585 }
586
587 fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
588
589 fn on_click(&self) -> Option<Msg> {
590 None
591 }
592}
593
594struct SearchView<Msg> {
600 children: Vec<Box<dyn Widget<Msg>>>,
601 full: bool,
602 screen: (f32, f32),
604 min_width: f32,
605 min_height: f32,
606 background: Color,
607 elevation: f32,
608 shape: ShapeBorder,
609}
610
611impl<Msg: Clone> Widget<Msg> for SearchView<Msg> {
612 fn style(&self) -> Style {
619 if self.full {
620 Style {
621 width: Dimension::Length(self.screen.0),
622 height: Dimension::Length(self.screen.1),
623 flex_direction: FlexDirection::Column,
624 ..Default::default()
625 }
626 } else {
627 Style {
628 min_width: Dimension::Length(self.min_width),
629 min_height: Dimension::Length(self.min_height),
630 flex_direction: FlexDirection::Column,
631 ..Default::default()
632 }
633 }
634 }
635
636 fn children(&self) -> &[Box<dyn Widget<Msg>>] {
637 &self.children
638 }
639
640 fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
641 let o = status.opacity;
642 if self.elevation > 0.0 {
643 let blur = self.elevation * 2.0 + 4.0;
644 scene.shadow(
645 Rect::new(
646 bounds.x - blur,
647 bounds.y + self.elevation * 0.5 - blur,
648 bounds.width + 2.0 * blur,
649 bounds.height + 2.0 * blur,
650 ),
651 theme.scheme.shadow.with_alpha(0.30).fade(o),
652 BorderRadius::uniform(blur),
653 blur,
654 );
655 }
656 scene.draw_shape(bounds, self.shape, self.background.fade(o));
657 }
658
659 fn on_click(&self) -> Option<Msg> {
660 None
661 }
662}
663
664#[cfg(test)]
665mod tests {
666 use super::*;
667 use frus_core::Primitive;
668
669 #[derive(Clone, Debug, PartialEq)]
670 enum Msg {
671 Open,
672 Close,
673 Clear,
674 Query(String),
675 }
676
677 fn anchor(open: bool, query: &str) -> SearchAnchor<Msg> {
678 SearchAnchor::new(open, query)
679 .hint("Search mail")
680 .on_open(Msg::Open)
681 .on_close(Msg::Close)
682 .on_input(Msg::Query)
683 }
684
685 fn portal(anchor: &SearchAnchor<Msg>, theme: &Theme) -> Vec<String> {
687 anchor.build_themed(theme);
688 let built = Widget::<Msg>::children(anchor);
689 assert_eq!(built.len(), 1, "one portal");
690 built[0]
691 .children()
692 .iter()
693 .map(|child| child.debug_name().to_string())
694 .collect()
695 }
696
697 fn find<'a>(node: &'a dyn Widget<Msg>, name: &str, out: &mut Vec<&'a dyn Widget<Msg>>) {
699 if node.debug_name() == name {
700 out.push(node);
701 }
702 for child in node.children() {
703 find(&**child, name, out);
704 }
705 }
706
707 fn all<'a>(
708 anchor: &'a SearchAnchor<Msg>,
709 name: &str,
710 theme: &Theme,
711 ) -> Vec<&'a dyn Widget<Msg>> {
712 anchor.build_themed(theme);
713 let mut out = Vec::new();
714 for child in Widget::<Msg>::children(anchor) {
715 find(&**child, name, &mut out);
716 }
717 out
718 }
719
720 #[test]
723 fn the_view_is_there_when_the_application_says_it_is() {
724 let theme = Theme::default();
725 assert_eq!(portal(&anchor(false, ""), &theme), vec!["SearchBar"]);
726 assert_eq!(
727 portal(&anchor(true, ""), &theme),
728 vec!["SearchBar", "SearchView"],
729 );
730 assert_eq!(
731 portal(&anchor(true, "").enabled(false), &theme),
732 vec!["SearchBar"],
733 "and an anchor that cannot be used opens nothing, whatever the flag says"
734 );
735 }
736
737 #[test]
741 fn the_anchors_own_bar_is_a_tap_target() {
742 let theme = Theme::default();
743 let shut = anchor(false, "");
744 let bars = all(&shut, "SearchBar", &theme);
745 assert_eq!(bars.len(), 1);
746 assert_eq!(
747 bars[0].on_click(),
748 Some(Msg::Open),
749 "pressing it opens the view"
750 );
751 }
752
753 #[test]
756 fn the_clear_cross_comes_and_goes_with_the_query() {
757 let theme = Theme::default();
758 let empty = anchor(true, "").on_clear(Msg::Clear);
759 assert_eq!(
760 all(&empty, "IconButton", &theme).len(),
761 0,
762 "nothing to clear, nothing to press"
763 );
764
765 let typed = anchor(true, "lisbon").on_clear(Msg::Clear);
766 let crosses = all(&typed, "IconButton", &theme);
767 assert_eq!(crosses.len(), 1);
768 assert_eq!(crosses[0].on_click(), Some(Msg::Clear));
769
770 let nowhere = anchor(true, "lisbon");
771 assert_eq!(
772 all(&nowhere, "IconButton", &theme).len(),
773 0,
774 "and a cross with nowhere to say so is not offered either"
775 );
776 }
777
778 #[test]
781 fn the_screen_is_square_and_the_panel_is_not() {
782 let theme = Theme::default();
783 assert_eq!(
784 anchor(true, "").full_screen(true).view_outline(&theme),
785 ShapeBorder::rounded(BorderRadius::ZERO)
786 );
787 assert_eq!(
788 anchor(true, "").full_screen(false).view_outline(&theme),
789 ShapeBorder::rounded(BorderRadius::uniform(SEARCH_VIEW_RADIUS))
790 );
791 }
792
793 #[test]
795 fn the_screens_header_is_the_taller_one() {
796 let theme = Theme::default();
797 assert_eq!(
798 anchor(true, "").full_screen(true).header(&theme),
799 SEARCH_VIEW_FULL_SCREEN_HEADER
800 );
801 assert_eq!(
802 anchor(true, "").full_screen(false).header(&theme),
803 crate::SEARCH_BAR_HEIGHT
804 );
805 assert_eq!(
806 anchor(true, "").header_height(40.0).header(&theme),
807 40.0,
808 "and a caller still has the last word"
809 );
810 }
811
812 #[test]
815 fn the_panel_has_a_floor_and_the_screen_has_a_size() {
816 let panel = SearchView::<Msg> {
817 children: Vec::new(),
818 full: false,
819 screen: (390.0, 844.0),
820 min_width: SEARCH_VIEW_MIN_WIDTH,
821 min_height: SEARCH_VIEW_MIN_HEIGHT,
822 background: Color::BLACK,
823 elevation: 0.0,
824 shape: ShapeBorder::stadium(),
825 };
826 let style = Widget::<Msg>::style(&panel);
827 assert_eq!(style.min_width, Dimension::Length(SEARCH_VIEW_MIN_WIDTH));
828 assert_eq!(style.min_height, Dimension::Length(SEARCH_VIEW_MIN_HEIGHT));
829 assert_eq!(style.width, Dimension::Auto, "and no width of its own");
830
831 let screen = SearchView::<Msg> {
832 full: true,
833 ..panel
834 };
835 let style = Widget::<Msg>::style(&screen);
836 assert_eq!(style.width, Dimension::Length(390.0));
837 assert_eq!(style.height, Dimension::Length(844.0));
838 }
839
840 #[test]
844 fn a_bar_with_no_surface_does_not_light() {
845 let theme = Theme::default();
846 let hovered = Status {
847 opacity: 1.0,
848 hover_progress: 1.0,
849 ..Default::default()
850 };
851 let fill = |bar: &crate::SearchBar<Msg>, status: Status| {
852 let mut scene = Scene::new();
853 bar.paint(Rect::new(0.0, 0.0, 400.0, 56.0), status, &theme, &mut scene);
854 scene
855 .primitives()
856 .iter()
857 .rev()
858 .find_map(|p| match p {
859 Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
860 _ => None,
861 })
862 .expect("it paints its pill")
863 };
864 let bare = crate::SearchBar::<Msg>::new("").background_color(Color::TRANSPARENT);
865 assert_eq!(
866 fill(&bare, hovered),
867 fill(
868 &bare,
869 Status {
870 opacity: 1.0,
871 ..Default::default()
872 }
873 ),
874 "there is no ground to light"
875 );
876
877 let solid = crate::SearchBar::<Msg>::new("");
878 assert_ne!(
879 fill(
880 &solid,
881 Status {
882 opacity: 1.0,
883 ..Default::default()
884 }
885 ),
886 fill(&solid, hovered),
887 "and one with a surface still answers a pointer"
888 );
889 }
890
891 #[test]
894 fn the_views_hint_falls_back_to_the_bars() {
895 let theme = Theme::default();
896 let plain = anchor(true, "");
897 plain.build_themed(&theme);
898 let heard: Vec<String> = all(&plain, "SearchBar", &theme)
899 .iter()
900 .filter_map(|bar| bar.semantics()?.label)
901 .collect();
902 assert_eq!(heard.len(), 2, "the bar and the view's header");
903 assert!(heard.iter().all(|hint| hint == "Search mail"));
904
905 let told = anchor(true, "").view_hint("Search all mail");
906 let heard: Vec<String> = all(&told, "SearchBar", &theme)
907 .iter()
908 .filter_map(|bar| bar.semantics()?.label)
909 .collect();
910 assert_eq!(heard, vec!["Search mail", "Search all mail"]);
911 }
912
913 #[test]
915 fn saying_it_afterwards_says_it() {
916 let theme = Theme::default();
917 let after = SearchAnchor::<Msg>::new(true, "")
918 .on_close(Msg::Close)
919 .full_screen(true)
920 .hint("Search");
921 assert_eq!(portal(&after, &theme).len(), 2);
922 assert_eq!(after.header(&theme), SEARCH_VIEW_FULL_SCREEN_HEADER);
923 }
924}