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 platform, at compile time here rather than from a
69/// `TargetPlatform` read at run time.
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() -> 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
86/// A search bar and the view it opens.
87pub 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    /// An anchor showing `query`, with its view open or shut.
120    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    /// What the **bar** says while it is empty.
154    pub fn hint(mut self, hint: impl Into<String>) -> Self {
155        self.hint = Some(hint.into());
156        self.rebuild();
157        self
158    }
159
160    /// What the **view's header** says, when it differs. Unset, the bar's — a view that
161    /// renamed the thing being searched the moment it opened would be answering a
162    /// different question.
163    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    /// The widget in the flow, in place of the [`SearchBar`](crate::SearchBar) this builds
170    /// by default — the reference's `builder` (`search_anchor.dart:151`). A bar is the
171    /// usual anchor and not the only one: an icon button in an app bar is a search anchor
172    /// too.
173    pub fn anchor(self, anchor: impl Widget<Msg> + 'static) -> Self {
174        self.anchor_boxed(Box::new(anchor))
175    }
176
177    /// [`Self::anchor`], for a widget already boxed.
178    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    /// Adds a row to the view — the reference's `suggestionsBuilder`
185    /// (`search_anchor.dart:152`), which is a builder there because the widget owns the
186    /// query and has to re-ask on every keystroke. Here the application owns it, so these
187    /// are simply what it decided the query meant.
188    pub fn suggestion(self, suggestion: impl Widget<Msg> + 'static) -> Self {
189        self.suggestion_boxed(Box::new(suggestion))
190    }
191
192    /// [`Self::suggestion`], for a widget already boxed.
193    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    /// The widget before the view's field, in place of the back arrow it builds by
200    /// default (`search_anchor.dart:1040`).
201    pub fn view_leading(self, leading: impl Widget<Msg> + 'static) -> Self {
202        self.view_leading_boxed(Box::new(leading))
203    }
204
205    /// [`Self::view_leading`], for a widget already boxed.
206    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    /// Adds a widget after the view's field, in place of the clear cross it builds by
213    /// default.
214    pub fn view_trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
215        self.view_trailing_boxed(Box::new(trailing))
216    }
217
218    /// [`Self::view_trailing`], for a widget already boxed.
219    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    /// What the bar emits when it is pressed. Without it the anchor opens nothing, which
226    /// is a bar and not an anchor.
227    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    /// What the back arrow and the scrim emit.
234    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    /// What the clear cross emits. **The cross only exists when there is something to
241    /// clear** (`search_anchor.dart:1048`) — and only when there is somewhere for it to
242    /// say so.
243    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    /// What the view's field emits on every keystroke.
250    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    /// What it emits when the search is confirmed.
260    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    /// Whether the anchor can be used at all.
267    pub fn enabled(mut self, enabled: bool) -> Self {
268        self.enabled = enabled;
269        self.rebuild();
270        self
271    }
272
273    /// Whether the view takes the whole screen.
274    ///
275    /// Unset, the platform decides, the way the reference decides it
276    /// (`search_anchor.dart:554`): a phone opens the screen, a desktop hangs a panel under
277    /// the bar. It is a question about how much room there is and how a search is got out
278    /// of, not about taste — but a tablet in landscape is a phone that should be answering
279    /// *no*, which is what this is for.
280    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    /// The view's fill. Unset, the scheme's `surface_container_high`
287    /// (`search_anchor.dart:1936`) — the same rung the bar takes, so the one grows out of
288    /// the other rather than changing colour on the way.
289    pub fn view_background_color(mut self, color: Color) -> Self {
290        self.background = Some(color);
291        self.rebuild();
292        self
293    }
294
295    /// How far off the page the view sits. Unset, `6`.
296    pub fn view_elevation(mut self, elevation: f32) -> Self {
297        self.elevation = Some(elevation);
298        self
299    }
300
301    /// The view's shape. Unset, corners of [`SEARCH_VIEW_RADIUS`] floating and square
302    /// full-screen.
303    pub fn view_shape(mut self, shape: ShapeBorder) -> Self {
304        self.shape = Some(shape);
305        self
306    }
307
308    /// Its corners, for the ordinary case. See [`Self::view_shape`].
309    pub fn view_radius(mut self, radius: impl Into<BorderRadius>) -> Self {
310        self.radius = Some(radius.into());
311        self
312    }
313
314    /// An outline around it. Unset, none (`search_anchor.dart:1944`).
315    pub fn view_side(mut self, width: f32, color: Color) -> Self {
316        self.side = Some(BorderSide { width, color });
317        self
318    }
319
320    /// The rule under the header. Unset, the scheme's `outline`
321    /// (`search_anchor.dart:1967`).
322    pub fn divider_color(mut self, color: Color) -> Self {
323        self.divider_color = Some(color);
324        self.rebuild();
325        self
326    }
327
328    /// The header's height. Unset, [`SEARCH_VIEW_FULL_SCREEN_HEADER`] full-screen and the
329    /// bar's own height floating.
330    pub fn header_height(mut self, height: f32) -> Self {
331        self.header_height = Some(height);
332        self.rebuild();
333        self
334    }
335
336    /// The header's type. Unset, `bodyLarge` in `on_surface`.
337    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    /// The header hint's type. Unset, `bodyLarge` in `on_surface_variant`.
344    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    /// The narrowest the view goes. Unset, [`SEARCH_VIEW_MIN_WIDTH`].
351    pub fn view_min_width(mut self, width: f32) -> Self {
352        self.min_width = Some(width);
353        self.rebuild();
354        self
355    }
356
357    /// The shortest. Unset, [`SEARCH_VIEW_MIN_HEIGHT`].
358    pub fn view_min_height(mut self, height: f32) -> Self {
359        self.min_height = Some(height);
360        self.rebuild();
361        self
362    }
363
364    /// The room either side of the header's contents. Unset, `8`.
365    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    /// Whether this view is the screen.
376    fn is_full_screen(&self) -> bool {
377        self.full_screen.unwrap_or_else(full_screen_by_default)
378    }
379
380    /// The view's shape, resolved. **A full-screen view has no corners to round**: it is
381    /// the screen, and a rounded screen is a rounded rectangle with the wallpaper showing
382    /// through the corners (`search_anchor.dart:1947`).
383    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    /// The header's height, resolved.
402    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    /// The bar in the flow: the caller's, or one this builds.
413    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            // **The bar an anchor builds does not take typing.** Pressing it opens the
420            // view, and the view is where the words are entered — a bar that took both
421            // would have two fields holding one query, and the reference's own anchor bar
422            // is a tap target for exactly this reason (`search_anchor.dart:577`).
423            .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    /// The view's header: the same field again, on the view's own surface.
434    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            // **Transparent and flat.** The view is already a raised surface; a raised
442            // pill inside it would be a second one, with its own shadow falling on the
443            // suggestions. The reference says the same three things
444            // (`search_anchor.dart:1163`).
445            .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            // **A cross with nothing to clear is a cross that lies.** It appears with the
478            // first character and goes when the last one does (`search_anchor.dart:1048`).
479            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    /// The view: the header, the rule, the suggestions.
498    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        // **The column fills the view, and the list fills the column.** Two flex factors
504        // for one idea, and both are needed: the view has a floor of 240 that its content
505        // does not reach, so a column sized by its content leaves the list nothing to grow
506        // into and a scroll view with nothing to grow into collapses to nothing and clips
507        // everything in it. The picture was a view with a header, a rule, and a blank.
508        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    /// The whole thing: the anchor, and the view over it while it is open.
550    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            // **Where the view goes.** Under the bar on a desktop, over everything on a
555            // phone — the second one is `Center` and not a placement of its own because a
556            // view that is the size of the screen is centred on it by arithmetic.
557            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
594/// The floating surface itself: a box of the right size, filled and raised.
595///
596/// Separate from [`SearchAnchor`] because the two answer different questions — the anchor
597/// decides *whether* there is a view and what goes in it, and this decides how big it is
598/// and what it looks like. It is private: an application asks the anchor.
599struct SearchView<Msg> {
600    children: Vec<Box<dyn Widget<Msg>>>,
601    full: bool,
602    /// The window, for a full-screen view to fill.
603    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    /// **The screen, or a panel with a floor.**
613    ///
614    /// A full-screen view asks for the window exactly; a floating one asks for nothing in
615    /// particular and refuses to go below 360 × 240. The reference expresses both as
616    /// constraints on one box (`search_anchor.dart:1115`); here the two are the same two
617    /// numbers said twice.
618    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    /// The portal the anchor built: `[bar]` shut, `[bar, view]` open.
686    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    /// Every widget in a built subtree whose short type name matches.
698    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    /// **It is open because the application says so.** No route, no controller: the model
721    /// holds the flag and the widget draws what the flag says.
722    #[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    /// **The bar an anchor builds does not take typing.** Pressing it opens the view, and
738    /// the view is where the words go — a bar that took both would be two fields holding
739    /// one query.
740    #[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    /// **A cross with nothing to clear is a cross that lies.** It appears with the first
754    /// character and goes when the last one does.
755    #[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    /// **A full-screen view has no corners to round**: it is the screen, and a rounded
779    /// screen is a rounded rectangle with the wallpaper showing through the corners.
780    #[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    /// And a screen's header is taller than a panel's, by the sixteen a thumb needs.
794    #[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    /// The floating view refuses to go below the reference's floor; the full-screen one
813    /// asks for the window exactly.
814    #[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    /// **A bar with no surface has no state layer.** The view's header is a bar painted
841    /// onto a surface that is already there; lerping from a transparent ground toward
842    /// `on_surface` would put a grey wash over the view rather than a highlight on the bar.
843    #[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    /// The view's hint falls back to the bar's: a view that renamed the thing being
892    /// searched the moment it opened would be answering a different question.
893    #[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    /// The builders are order-independent, the subtree being thrown away by each of them.
914    #[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}