Skip to main content

frus_widgets/
searchview.rs

1//! [`SearchAnchor`]: the view a [`SearchBar`](crate::SearchBar) opens.
2//!
3//! Milestone 469 built the bar and stopped there, which is where the reference also stops
4//! if you only reach for `SearchBar` — a bar alone is a useful thing. This is the other
5//! half: press the bar and a surface grows out of it holding the same query, a rule, and
6//! whatever the application thinks you meant.
7//!
8//! ```ignore
9//! SearchAnchor::new(app.searching, &app.query)
10//!     .hint("Search mail")
11//!     .on_open(Msg::OpenSearch)
12//!     .on_close(Msg::CloseSearch)
13//!     .on_input(Msg::Query)
14//!     .on_clear(Msg::Query(String::new()))
15//!     .suggestion(ListTile::new("flight to lisbon").on_tap(Msg::Pick(0)))
16//!     .suggestion(ListTile::new("flights from lisbon").on_tap(Msg::Pick(1)))
17//! ```
18//!
19//! ## It is open because the application says so
20//!
21//! The reference pushes a `PopupRoute` and keeps `isOpen` inside a controller, because a
22//! Flutter widget owns its state. Nothing here owns state: the application holds `open` in
23//! its model and gets a message back, the way [`Drawer`](crate::Drawer) has since milestone
24//! 46 and [`ExpansionTile`](crate::ExpansionTile) since 463. So `on_open` is what the bar
25//! emits when pressed, and `on_close` is what the back arrow and the scrim emit — and a
26//! test that wants the view open just says so.
27//!
28//! ## Two views, one widget
29//!
30//! On a phone the view **is** the screen; on a desktop it hangs under the bar. The
31//! reference decides by platform (`search_anchor.dart:554`) and so does this, at compile
32//! time, with [`full_screen`](SearchAnchor::full_screen) to say otherwise. The difference
33//! is four things and no more: how big it is, whether its corners are rounded, how tall its
34//! header is, and whether anything shows around it.
35
36use 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
49/// The floating view's corner (`search_anchor.dart:1949`). A full-screen one is square:
50/// it has no corners to round, being the screen.
51pub const SEARCH_VIEW_RADIUS: f32 = 28.0;
52/// The narrowest a floating view goes, and the shortest (`search_anchor.dart:1958`).
53pub const SEARCH_VIEW_MIN_WIDTH: f32 = 360.0;
54/// See [`SEARCH_VIEW_MIN_WIDTH`].
55pub const SEARCH_VIEW_MIN_HEIGHT: f32 = 240.0;
56/// The header's height once the view is the screen (`search_anchor.dart:1933`).
57///
58/// Sixteen more than a bar's 56, and the sixteen is the point: a header that is the top of
59/// a screen has a thumb reaching past it and needs the room, where one hanging under a bar
60/// on a desktop does not.
61pub const SEARCH_VIEW_FULL_SCREEN_HEADER: f32 = 72.0;
62/// How far off the page the view sits (`search_anchor.dart:1939`).
63const ELEVATION: f32 = 6.0;
64/// The room either side of the header's contents (`search_anchor.dart:1961`).
65const BAR_PADDING_X: f32 = 8.0;
66
67/// **Whether a search view takes the whole screen**, decided the way the reference decides
68/// it (`search_anchor.dart:554`): by the theme's platform — iOS, Android and Fuchsia
69/// open the screen; macOS, Linux and Windows do not (milestone 615).
70///
71/// A phone opens the screen; a desktop hangs a panel under the bar. It is a question about
72/// how much room there is and how a search is got out of, not about taste — but
73/// [`SearchAnchor::full_screen`] overrules it, because a tablet in landscape is a phone
74/// that should be answering *no*.
75const fn full_screen_by_default(platform: frus_core::TargetPlatform) -> bool {
76    use frus_core::TargetPlatform as P;
77    match platform {
78        P::Ios | P::Android | P::Fuchsia => true,
79        P::MacOs | P::Linux | P::Windows => false,
80    }
81}
82
83/// A search bar and the view it opens.
84pub struct SearchAnchor<Msg = crate::callback::Callback> {
85    open: bool,
86    query: String,
87    hint: Option<String>,
88    view_hint: Option<String>,
89    anchor: RefCell<Option<Box<dyn Widget<Msg>>>>,
90    suggestions: RefCell<Vec<Box<dyn Widget<Msg>>>>,
91    view_leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
92    view_trailing: RefCell<Vec<Box<dyn Widget<Msg>>>>,
93    on_open: Option<Msg>,
94    on_close: Option<Msg>,
95    on_clear: Option<Msg>,
96    on_input: Option<Rc<dyn Fn(String) -> Msg>>,
97    on_submit: Option<Msg>,
98    enabled: bool,
99    full_screen: Option<bool>,
100    background: Option<Color>,
101    elevation: Option<f32>,
102    shape: Option<ShapeBorder>,
103    radius: Option<BorderRadius>,
104    side: Option<BorderSide>,
105    divider_color: Option<Color>,
106    header_height: Option<f32>,
107    header_text_style: Option<TextStyle>,
108    header_hint_style: Option<TextStyle>,
109    min_width: Option<f32>,
110    min_height: Option<f32>,
111    bar_padding: Option<Insets>,
112    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
113}
114
115impl<Msg: Clone + 'static> SearchAnchor<Msg> {
116    /// An anchor showing `query`, with its view open or shut.
117    pub fn new(open: bool, query: impl Into<String>) -> Self {
118        Self {
119            open,
120            query: query.into(),
121            hint: None,
122            view_hint: None,
123            anchor: RefCell::new(None),
124            suggestions: RefCell::new(Vec::new()),
125            view_leading: RefCell::new(None),
126            view_trailing: RefCell::new(Vec::new()),
127            on_open: None,
128            on_close: None,
129            on_clear: None,
130            on_input: None,
131            on_submit: None,
132            enabled: true,
133            full_screen: None,
134            background: None,
135            elevation: None,
136            shape: None,
137            radius: None,
138            side: None,
139            divider_color: None,
140            header_height: None,
141            header_text_style: None,
142            header_hint_style: None,
143            min_width: None,
144            min_height: None,
145            bar_padding: None,
146            built: OnceCell::new(),
147        }
148    }
149
150    /// What the **bar** says while it is empty.
151    pub fn hint(mut self, hint: impl Into<String>) -> Self {
152        self.hint = Some(hint.into());
153        self.rebuild();
154        self
155    }
156
157    /// What the **view's header** says, when it differs. Unset, the bar's — a view that
158    /// renamed the thing being searched the moment it opened would be answering a
159    /// different question.
160    pub fn view_hint(mut self, hint: impl Into<String>) -> Self {
161        self.view_hint = Some(hint.into());
162        self.rebuild();
163        self
164    }
165
166    /// The widget in the flow, in place of the [`SearchBar`](crate::SearchBar) this builds
167    /// by default — the reference's `builder` (`search_anchor.dart:151`). A bar is the
168    /// usual anchor and not the only one: an icon button in an app bar is a search anchor
169    /// too.
170    pub fn anchor(self, anchor: impl Widget<Msg> + 'static) -> Self {
171        self.anchor_boxed(Box::new(anchor))
172    }
173
174    /// [`Self::anchor`], for a widget already boxed.
175    pub fn anchor_boxed(mut self, anchor: Box<dyn Widget<Msg>>) -> Self {
176        *self.anchor.borrow_mut() = Some(anchor);
177        self.rebuild();
178        self
179    }
180
181    /// Adds a row to the view — the reference's `suggestionsBuilder`
182    /// (`search_anchor.dart:152`), which is a builder there because the widget owns the
183    /// query and has to re-ask on every keystroke. Here the application owns it, so these
184    /// are simply what it decided the query meant.
185    pub fn suggestion(self, suggestion: impl Widget<Msg> + 'static) -> Self {
186        self.suggestion_boxed(Box::new(suggestion))
187    }
188
189    /// [`Self::suggestion`], for a widget already boxed.
190    pub fn suggestion_boxed(mut self, suggestion: Box<dyn Widget<Msg>>) -> Self {
191        self.suggestions.borrow_mut().push(suggestion);
192        self.rebuild();
193        self
194    }
195
196    /// The widget before the view's field, in place of the back arrow it builds by
197    /// default (`search_anchor.dart:1040`).
198    pub fn view_leading(self, leading: impl Widget<Msg> + 'static) -> Self {
199        self.view_leading_boxed(Box::new(leading))
200    }
201
202    /// [`Self::view_leading`], for a widget already boxed.
203    pub fn view_leading_boxed(mut self, leading: Box<dyn Widget<Msg>>) -> Self {
204        *self.view_leading.borrow_mut() = Some(leading);
205        self.rebuild();
206        self
207    }
208
209    /// Adds a widget after the view's field, in place of the clear cross it builds by
210    /// default.
211    pub fn view_trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
212        self.view_trailing_boxed(Box::new(trailing))
213    }
214
215    /// [`Self::view_trailing`], for a widget already boxed.
216    pub fn view_trailing_boxed(mut self, trailing: Box<dyn Widget<Msg>>) -> Self {
217        self.view_trailing.borrow_mut().push(trailing);
218        self.rebuild();
219        self
220    }
221
222    /// What the bar emits when it is pressed. Without it the anchor opens nothing, which
223    /// is a bar and not an anchor.
224    pub fn on_open(mut self, message: impl Into<Msg>) -> Self {
225        self.on_open = Some(message.into());
226        self.rebuild();
227        self
228    }
229
230    /// What the back arrow and the scrim emit.
231    pub fn on_close(mut self, message: impl Into<Msg>) -> Self {
232        self.on_close = Some(message.into());
233        self.rebuild();
234        self
235    }
236
237    /// What the clear cross emits. **The cross only exists when there is something to
238    /// clear** (`search_anchor.dart:1048`) — and only when there is somewhere for it to
239    /// say so.
240    pub fn on_clear(mut self, message: impl Into<Msg>) -> Self {
241        self.on_clear = Some(message.into());
242        self.rebuild();
243        self
244    }
245
246    /// What the view's field emits on every keystroke.
247    pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
248        mut self,
249        on_input: impl Fn(String) -> R + 'static,
250    ) -> Self {
251        self.on_input = Some(Rc::new(crate::callback::handler1(on_input)));
252        self.rebuild();
253        self
254    }
255
256    /// What it emits when the search is confirmed.
257    pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
258        self.on_submit = Some(message.into());
259        self.rebuild();
260        self
261    }
262
263    /// Whether the anchor can be used at all.
264    pub fn enabled(mut self, enabled: bool) -> Self {
265        self.enabled = enabled;
266        self.rebuild();
267        self
268    }
269
270    /// Whether the view takes the whole screen.
271    ///
272    /// Unset, the platform decides, the way the reference decides it
273    /// (`search_anchor.dart:554`): a phone opens the screen, a desktop hangs a panel under
274    /// the bar. It is a question about how much room there is and how a search is got out
275    /// of, not about taste — but a tablet in landscape is a phone that should be answering
276    /// *no*, which is what this is for.
277    pub fn full_screen(mut self, full_screen: bool) -> Self {
278        self.full_screen = Some(full_screen);
279        self.rebuild();
280        self
281    }
282
283    /// The view's fill. Unset, the scheme's `surface_container_high`
284    /// (`search_anchor.dart:1936`) — the same rung the bar takes, so the one grows out of
285    /// the other rather than changing colour on the way.
286    pub fn view_background_color(mut self, color: Color) -> Self {
287        self.background = Some(color);
288        self.rebuild();
289        self
290    }
291
292    /// How far off the page the view sits. Unset, `6`.
293    pub fn view_elevation(mut self, elevation: f32) -> Self {
294        self.elevation = Some(elevation);
295        self
296    }
297
298    /// The view's shape. Unset, corners of [`SEARCH_VIEW_RADIUS`] floating and square
299    /// full-screen.
300    pub fn view_shape(mut self, shape: ShapeBorder) -> Self {
301        self.shape = Some(shape);
302        self
303    }
304
305    /// Its corners, for the ordinary case. See [`Self::view_shape`].
306    pub fn view_radius(mut self, radius: impl Into<BorderRadius>) -> Self {
307        self.radius = Some(radius.into());
308        self
309    }
310
311    /// An outline around it. Unset, none (`search_anchor.dart:1944`).
312    pub fn view_side(mut self, width: f32, color: Color) -> Self {
313        self.side = Some(BorderSide { width, color });
314        self
315    }
316
317    /// The rule under the header. Unset, the scheme's `outline`
318    /// (`search_anchor.dart:1967`).
319    pub fn divider_color(mut self, color: Color) -> Self {
320        self.divider_color = Some(color);
321        self.rebuild();
322        self
323    }
324
325    /// The header's height. Unset, [`SEARCH_VIEW_FULL_SCREEN_HEADER`] full-screen and the
326    /// bar's own height floating.
327    pub fn header_height(mut self, height: f32) -> Self {
328        self.header_height = Some(height);
329        self.rebuild();
330        self
331    }
332
333    /// The header's type. Unset, `bodyLarge` in `on_surface`.
334    pub fn header_text_style(mut self, style: TextStyle) -> Self {
335        self.header_text_style = Some(style);
336        self.rebuild();
337        self
338    }
339
340    /// The header hint's type. Unset, `bodyLarge` in `on_surface_variant`.
341    pub fn header_hint_style(mut self, style: TextStyle) -> Self {
342        self.header_hint_style = Some(style);
343        self.rebuild();
344        self
345    }
346
347    /// The narrowest the view goes. Unset, [`SEARCH_VIEW_MIN_WIDTH`].
348    pub fn view_min_width(mut self, width: f32) -> Self {
349        self.min_width = Some(width);
350        self.rebuild();
351        self
352    }
353
354    /// The shortest. Unset, [`SEARCH_VIEW_MIN_HEIGHT`].
355    pub fn view_min_height(mut self, height: f32) -> Self {
356        self.min_height = Some(height);
357        self.rebuild();
358        self
359    }
360
361    /// The room either side of the header's contents. Unset, `8`.
362    pub fn view_bar_padding(mut self, padding: Insets) -> Self {
363        self.bar_padding = Some(padding);
364        self.rebuild();
365        self
366    }
367
368    fn rebuild(&mut self) {
369        self.built.take();
370    }
371
372    /// Whether this view is the screen.
373    fn is_full_screen(&self, theme: &Theme) -> bool {
374        self.full_screen
375            .unwrap_or(full_screen_by_default(theme.platform))
376    }
377
378    /// The view's shape, resolved. **A full-screen view has no corners to round**: it is
379    /// the screen, and a rounded screen is a rounded rectangle with the wallpaper showing
380    /// through the corners (`search_anchor.dart:1947`).
381    fn view_outline(&self, theme: &Theme) -> ShapeBorder {
382        let t = &theme.widgets.search_view;
383        let shape = resolve_shape(
384            self.shape,
385            t.shape,
386            self.radius.or(t.radius),
387            if self.is_full_screen(theme) {
388                ShapeBorder::rounded(BorderRadius::ZERO)
389            } else {
390                ShapeBorder::rounded(BorderRadius::uniform(SEARCH_VIEW_RADIUS))
391            },
392        );
393        match self.side.or(t.side) {
394            Some(side) => shape.with_side(side),
395            None => shape,
396        }
397    }
398
399    /// The header's height, resolved.
400    fn header(&self, theme: &Theme) -> f32 {
401        self.header_height
402            .or(theme.widgets.search_view.header_height)
403            .unwrap_or(if self.is_full_screen(theme) {
404                SEARCH_VIEW_FULL_SCREEN_HEADER
405            } else {
406                crate::SEARCH_BAR_HEIGHT
407            })
408    }
409
410    /// The bar in the flow: the caller's, or one this builds.
411    fn build_anchor(&self) -> Box<dyn Widget<Msg>> {
412        if let Some(anchor) = self.anchor.borrow_mut().take() {
413            return anchor;
414        }
415        let mut bar = crate::SearchBar::<Msg>::new(self.query.clone())
416            .enabled(self.enabled)
417            // **The bar an anchor builds does not take typing.** Pressing it opens the
418            // view, and the view is where the words are entered — a bar that took both
419            // would have two fields holding one query, and the reference's own anchor bar
420            // is a tap target for exactly this reason (`search_anchor.dart:577`).
421            .read_only(true);
422        if let Some(hint) = &self.hint {
423            bar = bar.hint(hint.clone());
424        }
425        if let Some(message) = &self.on_open {
426            bar = bar.on_tap(message.clone());
427        }
428        Box::new(bar)
429    }
430
431    /// The view's header: the same field again, on the view's own surface.
432    fn build_header(&self, theme: &Theme) -> Box<dyn Widget<Msg>> {
433        let words = crate::localizations::of();
434        let room = self
435            .bar_padding
436            .or(theme.widgets.search_view.bar_padding)
437            .unwrap_or(Insets::new(0.0, BAR_PADDING_X, 0.0, BAR_PADDING_X));
438        let mut header = crate::SearchBar::<Msg>::new(self.query.clone())
439            // **Transparent and flat.** The view is already a raised surface; a raised
440            // pill inside it would be a second one, with its own shadow falling on the
441            // suggestions. The reference says the same three things
442            // (`search_anchor.dart:1163`).
443            .background_color(Color::TRANSPARENT)
444            .elevation(0.0)
445            .min_width(0.0)
446            .height(self.header(theme))
447            .padding(room);
448        if let Some(hint) = self.view_hint.as_ref().or(self.hint.as_ref()) {
449            header = header.hint(hint.clone());
450        }
451        if let Some(style) = self
452            .header_text_style
453            .or(theme.widgets.search_view.header_text_style)
454        {
455            header = header.text_style(style);
456        }
457        if let Some(style) = self
458            .header_hint_style
459            .or(theme.widgets.search_view.header_hint_style)
460        {
461            header = header.hint_style(style);
462        }
463        header = match self.view_leading.borrow_mut().take() {
464            Some(leading) => header.leading_boxed(leading),
465            None => {
466                let mut back = crate::BackButton::<Msg>::new().icon_size(20.0);
467                if let Some(message) = &self.on_close {
468                    back = back.on_press(message.clone());
469                }
470                header.leading(back)
471            }
472        };
473        let told = std::mem::take(&mut *self.view_trailing.borrow_mut());
474        if told.is_empty() {
475            // **A cross with nothing to clear is a cross that lies.** It appears with the
476            // first character and goes when the last one does (`search_anchor.dart:1048`).
477            if !self.query.is_empty() {
478                if let Some(message) = &self.on_clear {
479                    header = header.trailing(
480                        crate::IconButton::new(crate::Icons::CLOSE)
481                            .icon_size(20.0)
482                            .label(words.clear_button_label())
483                            .on_press(message.clone()),
484                    );
485                }
486            }
487        } else {
488            for trailing in told {
489                header = header.trailing_boxed(trailing);
490            }
491        }
492        Box::new(header)
493    }
494
495    /// The view: the header, the rule, the suggestions.
496    fn build_view(&self, theme: &Theme) -> Box<dyn Widget<Msg>> {
497        let t = &theme.widgets.search_view;
498        let full = self.is_full_screen(theme);
499        let screen = crate::MediaQuery::of().size;
500
501        // **The column fills the view, and the list fills the column.** Two flex factors
502        // for one idea, and both are needed: the view has a floor of 240 that its content
503        // does not reach, so a column sized by its content leaves the list nothing to grow
504        // into and a scroll view with nothing to grow into collapses to nothing and clips
505        // everything in it. The picture was a view with a header, a rule, and a blank.
506        let mut column = Flex::<Msg>::column()
507            .flex(1.0)
508            .child_boxed(self.build_header(theme));
509        column = column.child(
510            crate::Divider::new().height(1.0).color(
511                self.divider_color
512                    .or(t.divider_color)
513                    .unwrap_or(theme.scheme.outline),
514            ),
515        );
516        let mut list = Flex::<Msg>::column();
517        for suggestion in std::mem::take(&mut *self.suggestions.borrow_mut()) {
518            list = list.child_boxed(suggestion);
519        }
520        column = column.child(
521            crate::SingleChildScrollView::<Msg>::new()
522                .flex(1.0)
523                .child(list),
524        );
525
526        Box::new(SearchView {
527            children: vec![Box::new(column)],
528            full,
529            screen: (screen.width, screen.height),
530            min_width: self
531                .min_width
532                .or(t.min_width)
533                .unwrap_or(SEARCH_VIEW_MIN_WIDTH),
534            min_height: self
535                .min_height
536                .or(t.min_height)
537                .unwrap_or(SEARCH_VIEW_MIN_HEIGHT),
538            background: self
539                .background
540                .or(t.background_color)
541                .unwrap_or(theme.scheme.surface_container_high),
542            elevation: self.elevation.or(t.elevation).unwrap_or(ELEVATION),
543            shape: self.view_outline(theme),
544        })
545    }
546
547    /// The whole thing: the anchor, and the view over it while it is open.
548    fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
549        let anchor = self.build_anchor();
550        let mut portal = crate::OverlayPortal::<Msg>::new_boxed(anchor);
551        if self.open && self.enabled {
552            // **Where the view goes.** Under the bar on a desktop, over everything on a
553            // phone — the second one is `Center` and not a placement of its own because a
554            // view that is the size of the screen is centred on it by arithmetic.
555            let placement = if self.is_full_screen(theme) {
556                Placement::Center
557            } else {
558                Placement::Below
559            };
560            portal = portal.overlay_boxed(self.build_view(theme), placement);
561            if let Some(message) = &self.on_close {
562                portal = portal.dismiss(message.clone());
563            }
564        }
565        vec![Box::new(portal)]
566    }
567}
568
569impl<Msg: Clone + 'static> Widget<Msg> for SearchAnchor<Msg> {
570    fn style(&self) -> Style {
571        Style {
572            flex_direction: FlexDirection::Column,
573            ..Default::default()
574        }
575    }
576
577    fn build_themed(&self, theme: &Theme) {
578        let _ = self.built.set(self.assemble(theme));
579    }
580
581    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
582        self.built.get_or_init(|| self.assemble(&Theme::default()))
583    }
584
585    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
586
587    fn on_click(&self) -> Option<Msg> {
588        None
589    }
590}
591
592/// The floating surface itself: a box of the right size, filled and raised.
593///
594/// Separate from [`SearchAnchor`] because the two answer different questions — the anchor
595/// decides *whether* there is a view and what goes in it, and this decides how big it is
596/// and what it looks like. It is private: an application asks the anchor.
597struct SearchView<Msg> {
598    children: Vec<Box<dyn Widget<Msg>>>,
599    full: bool,
600    /// The window, for a full-screen view to fill.
601    screen: (f32, f32),
602    min_width: f32,
603    min_height: f32,
604    background: Color,
605    elevation: f32,
606    shape: ShapeBorder,
607}
608
609impl<Msg: Clone> Widget<Msg> for SearchView<Msg> {
610    /// **The screen, or a panel with a floor.**
611    ///
612    /// A full-screen view asks for the window exactly; a floating one asks for nothing in
613    /// particular and refuses to go below 360 × 240. The reference expresses both as
614    /// constraints on one box (`search_anchor.dart:1115`); here the two are the same two
615    /// numbers said twice.
616    fn style(&self) -> Style {
617        if self.full {
618            Style {
619                width: Dimension::Length(self.screen.0),
620                height: Dimension::Length(self.screen.1),
621                flex_direction: FlexDirection::Column,
622                ..Default::default()
623            }
624        } else {
625            Style {
626                min_width: Dimension::Length(self.min_width),
627                min_height: Dimension::Length(self.min_height),
628                flex_direction: FlexDirection::Column,
629                ..Default::default()
630            }
631        }
632    }
633
634    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
635        &self.children
636    }
637
638    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
639        let o = status.opacity;
640        if self.elevation > 0.0 {
641            // The reference's shadows for this height (milestone 606).
642            frus_core::paint_elevation(
643                scene,
644                bounds,
645                // The corners of the shape it is drawn in: a view shown as a floating panel
646                // is rounded.
647                self.shape
648                    .as_rounded(bounds)
649                    .map(|(_, radius)| radius)
650                    .unwrap_or(BorderRadius::ZERO),
651                self.elevation,
652                theme.scheme.shadow.fade(o),
653            );
654        }
655        scene.draw_shape(bounds, self.shape, self.background.fade(o));
656    }
657
658    fn on_click(&self) -> Option<Msg> {
659        None
660    }
661}
662
663#[cfg(test)]
664mod tests {
665    use super::*;
666
667    /// **The theme's platform decides** (`search_anchor.dart:554`, milestone 615): the
668    /// screen on iOS, Android and Fuchsia, a panel on the three desktops — whatever this
669    /// test runs on — and `full_screen` overrules it either way.
670    #[test]
671    fn the_view_takes_the_screen_where_the_theme_s_platform_does() {
672        use frus_core::TargetPlatform as P;
673        for platform in P::ALL {
674            let theme = Theme::default().with_platform(platform);
675            let phone = matches!(platform, P::Ios | P::Android | P::Fuchsia);
676            let anchor = SearchAnchor::<()>::new(true, "");
677            assert_eq!(anchor.is_full_screen(&theme), phone, "{platform}");
678            let told = SearchAnchor::<()>::new(true, "").full_screen(!phone);
679            assert_eq!(told.is_full_screen(&theme), !phone, "told, {platform}");
680        }
681    }
682    use frus_core::Primitive;
683
684    #[derive(Clone, Debug, PartialEq)]
685    enum Msg {
686        Open,
687        Close,
688        Clear,
689        Query(String),
690    }
691
692    fn anchor(open: bool, query: &str) -> SearchAnchor<Msg> {
693        SearchAnchor::new(open, query)
694            .hint("Search mail")
695            .on_open(Msg::Open)
696            .on_close(Msg::Close)
697            .on_input(Msg::Query)
698    }
699
700    /// The portal the anchor built: `[bar]` shut, `[bar, view]` open.
701    fn portal(anchor: &SearchAnchor<Msg>, theme: &Theme) -> Vec<String> {
702        anchor.build_themed(theme);
703        let built = Widget::<Msg>::children(anchor);
704        assert_eq!(built.len(), 1, "one portal");
705        built[0]
706            .children()
707            .iter()
708            .map(|child| child.debug_name().to_string())
709            .collect()
710    }
711
712    /// Every widget in a built subtree whose short type name matches.
713    fn find<'a>(node: &'a dyn Widget<Msg>, name: &str, out: &mut Vec<&'a dyn Widget<Msg>>) {
714        if node.debug_name() == name {
715            out.push(node);
716        }
717        for child in node.children() {
718            find(&**child, name, out);
719        }
720    }
721
722    fn all<'a>(
723        anchor: &'a SearchAnchor<Msg>,
724        name: &str,
725        theme: &Theme,
726    ) -> Vec<&'a dyn Widget<Msg>> {
727        anchor.build_themed(theme);
728        let mut out = Vec::new();
729        for child in Widget::<Msg>::children(anchor) {
730            find(&**child, name, &mut out);
731        }
732        out
733    }
734
735    /// **It is open because the application says so.** No route, no controller: the model
736    /// holds the flag and the widget draws what the flag says.
737    #[test]
738    fn the_view_is_there_when_the_application_says_it_is() {
739        let theme = Theme::default();
740        assert_eq!(portal(&anchor(false, ""), &theme), vec!["SearchBar"]);
741        assert_eq!(
742            portal(&anchor(true, ""), &theme),
743            vec!["SearchBar", "SearchView"],
744        );
745        assert_eq!(
746            portal(&anchor(true, "").enabled(false), &theme),
747            vec!["SearchBar"],
748            "and an anchor that cannot be used opens nothing, whatever the flag says"
749        );
750    }
751
752    /// **The bar an anchor builds does not take typing.** Pressing it opens the view, and
753    /// the view is where the words go — a bar that took both would be two fields holding
754    /// one query.
755    #[test]
756    fn the_anchors_own_bar_is_a_tap_target() {
757        let theme = Theme::default();
758        let shut = anchor(false, "");
759        let bars = all(&shut, "SearchBar", &theme);
760        assert_eq!(bars.len(), 1);
761        assert_eq!(
762            bars[0].on_click(),
763            Some(Msg::Open),
764            "pressing it opens the view"
765        );
766    }
767
768    /// **A cross with nothing to clear is a cross that lies.** It appears with the first
769    /// character and goes when the last one does.
770    #[test]
771    fn the_clear_cross_comes_and_goes_with_the_query() {
772        let theme = Theme::default();
773        let empty = anchor(true, "").on_clear(Msg::Clear);
774        assert_eq!(
775            all(&empty, "IconButton", &theme).len(),
776            0,
777            "nothing to clear, nothing to press"
778        );
779
780        let typed = anchor(true, "lisbon").on_clear(Msg::Clear);
781        let crosses = all(&typed, "IconButton", &theme);
782        assert_eq!(crosses.len(), 1);
783        assert_eq!(crosses[0].on_click(), Some(Msg::Clear));
784
785        let nowhere = anchor(true, "lisbon");
786        assert_eq!(
787            all(&nowhere, "IconButton", &theme).len(),
788            0,
789            "and a cross with nowhere to say so is not offered either"
790        );
791    }
792
793    /// **A full-screen view has no corners to round**: it is the screen, and a rounded
794    /// screen is a rounded rectangle with the wallpaper showing through the corners.
795    #[test]
796    fn the_screen_is_square_and_the_panel_is_not() {
797        let theme = Theme::default();
798        assert_eq!(
799            anchor(true, "").full_screen(true).view_outline(&theme),
800            ShapeBorder::rounded(BorderRadius::ZERO)
801        );
802        assert_eq!(
803            anchor(true, "").full_screen(false).view_outline(&theme),
804            ShapeBorder::rounded(BorderRadius::uniform(SEARCH_VIEW_RADIUS))
805        );
806    }
807
808    /// And a screen's header is taller than a panel's, by the sixteen a thumb needs.
809    #[test]
810    fn the_screens_header_is_the_taller_one() {
811        let theme = Theme::default();
812        assert_eq!(
813            anchor(true, "").full_screen(true).header(&theme),
814            SEARCH_VIEW_FULL_SCREEN_HEADER
815        );
816        assert_eq!(
817            anchor(true, "").full_screen(false).header(&theme),
818            crate::SEARCH_BAR_HEIGHT
819        );
820        assert_eq!(
821            anchor(true, "").header_height(40.0).header(&theme),
822            40.0,
823            "and a caller still has the last word"
824        );
825    }
826
827    /// The floating view refuses to go below the reference's floor; the full-screen one
828    /// asks for the window exactly.
829    #[test]
830    fn the_panel_has_a_floor_and_the_screen_has_a_size() {
831        let panel = SearchView::<Msg> {
832            children: Vec::new(),
833            full: false,
834            screen: (390.0, 844.0),
835            min_width: SEARCH_VIEW_MIN_WIDTH,
836            min_height: SEARCH_VIEW_MIN_HEIGHT,
837            background: Color::BLACK,
838            elevation: 0.0,
839            shape: ShapeBorder::stadium(),
840        };
841        let style = Widget::<Msg>::style(&panel);
842        assert_eq!(style.min_width, Dimension::Length(SEARCH_VIEW_MIN_WIDTH));
843        assert_eq!(style.min_height, Dimension::Length(SEARCH_VIEW_MIN_HEIGHT));
844        assert_eq!(style.width, Dimension::Auto, "and no width of its own");
845
846        let screen = SearchView::<Msg> {
847            full: true,
848            ..panel
849        };
850        let style = Widget::<Msg>::style(&screen);
851        assert_eq!(style.width, Dimension::Length(390.0));
852        assert_eq!(style.height, Dimension::Length(844.0));
853    }
854
855    /// **A bar with no surface has no state layer.** The view's header is a bar painted
856    /// onto a surface that is already there; lerping from a transparent ground toward
857    /// `on_surface` would put a grey wash over the view rather than a highlight on the bar.
858    #[test]
859    fn a_bar_with_no_surface_does_not_light() {
860        let theme = Theme::default();
861        let hovered = Status {
862            opacity: 1.0,
863            hover_progress: 1.0,
864            ..Default::default()
865        };
866        let fill = |bar: &crate::SearchBar<Msg>, status: Status| {
867            let mut scene = Scene::new();
868            bar.paint(Rect::new(0.0, 0.0, 400.0, 56.0), status, &theme, &mut scene);
869            scene
870                .primitives()
871                .iter()
872                .rev()
873                .find_map(|p| match p {
874                    Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
875                    _ => None,
876                })
877                .expect("it paints its pill")
878        };
879        let bare = crate::SearchBar::<Msg>::new("").background_color(Color::TRANSPARENT);
880        assert_eq!(
881            fill(&bare, hovered),
882            fill(
883                &bare,
884                Status {
885                    opacity: 1.0,
886                    ..Default::default()
887                }
888            ),
889            "there is no ground to light"
890        );
891
892        let solid = crate::SearchBar::<Msg>::new("");
893        assert_ne!(
894            fill(
895                &solid,
896                Status {
897                    opacity: 1.0,
898                    ..Default::default()
899                }
900            ),
901            fill(&solid, hovered),
902            "and one with a surface still answers a pointer"
903        );
904    }
905
906    /// The view's hint falls back to the bar's: a view that renamed the thing being
907    /// searched the moment it opened would be answering a different question.
908    #[test]
909    fn the_views_hint_falls_back_to_the_bars() {
910        let theme = Theme::default();
911        let plain = anchor(true, "");
912        plain.build_themed(&theme);
913        let heard: Vec<String> = all(&plain, "SearchBar", &theme)
914            .iter()
915            .filter_map(|bar| bar.semantics()?.label)
916            .collect();
917        assert_eq!(heard.len(), 2, "the bar and the view's header");
918        assert!(heard.iter().all(|hint| hint == "Search mail"));
919
920        let told = anchor(true, "").view_hint("Search all mail");
921        let heard: Vec<String> = all(&told, "SearchBar", &theme)
922            .iter()
923            .filter_map(|bar| bar.semantics()?.label)
924            .collect();
925        assert_eq!(heard, vec!["Search mail", "Search all mail"]);
926    }
927
928    /// The builders are order-independent, the subtree being thrown away by each of them.
929    #[test]
930    fn saying_it_afterwards_says_it() {
931        let theme = Theme::default();
932        let after = SearchAnchor::<Msg>::new(true, "")
933            .on_close(Msg::Close)
934            .full_screen(true)
935            .hint("Search");
936        assert_eq!(portal(&after, &theme).len(), 2);
937        assert_eq!(after.header(&theme), SEARCH_VIEW_FULL_SCREEN_HEADER);
938    }
939}