Skip to main content

frus_widgets/
searchbar.rs

1//! [`SearchBar`]: the raised pill an application searches from.
2//!
3//! It is a [`TextField`](crate::TextField) with no container of its own inside a container
4//! of the framework's — which is the whole design. A search bar is not a form field: it
5//! does not float a label, it does not sit on a line, and it is raised off the page rather
6//! than sunk into it, because it is a control that goes *over* the content it filters
7//! rather than one more row of a form.
8//!
9//! ```ignore
10//! SearchBar::new(&app.query)
11//!     .hint("Search mail")
12//!     .leading(Icon::new(Icons::MENU))
13//!     .trailing(IconButton::new(Icons::CLOSE).on_press(Msg::Clear))
14//!     .on_input(Msg::Query)
15//!     .on_submit(Msg::Search)
16//! ```
17//!
18//! Everything it paints is a default and nothing is a rule: the fill, the shadow and how
19//! far off the page it sits, the outline, the shape, the room inside it, the two type
20//! steps, and the box it asks for — each resolved from the instance, then
21//! [`SearchBarTheme`](crate::widgettheme::SearchBarTheme), then the scheme's role.
22
23use std::cell::{OnceCell, RefCell};
24use std::rc::Rc;
25
26use frus_core::{BorderRadius, BorderSide, Color, Insets, Rect, Scene, ShapeBorder, TextStyle};
27use frus_layout::{Align, Dimension, Style};
28
29use crate::flex::Flex;
30use crate::interaction::Status;
31use crate::theme::Theme;
32use crate::widget::Widget;
33use crate::widgettheme::resolve_shape;
34
35/// The box a search bar asks for (`search_anchor.dart:1908`).
36///
37/// The floor is the interesting one: **360 wide**, which is a phone's width and not a
38/// coincidence — a search bar narrower than that is a text field wearing a shadow. A
39/// caller who has less room says so; the widget does not quietly become something else.
40pub const SEARCH_BAR_MIN_WIDTH: f32 = 360.0;
41/// And the ceiling: past this a search bar stops being a control and becomes a banner.
42pub const SEARCH_BAR_MAX_WIDTH: f32 = 800.0;
43/// Its height (`search_anchor.dart:1908`).
44pub const SEARCH_BAR_HEIGHT: f32 = 56.0;
45/// How far off the page it sits (`search_anchor.dart:1863`).
46const ELEVATION: f32 = 6.0;
47/// The room inside it, either side (`search_anchor.dart:1896`).
48///
49/// The reference applies it **twice** — once around the whole row and once around the
50/// field inside it — so the gap between a leading icon and the first letter is sixteen,
51/// and the gap from the bar's edge to that icon is eight. Both are that one number.
52const PADDING_X: f32 = 8.0;
53/// What a disabled search bar fades to (`search_anchor.dart:48`).
54///
55/// This is the one place the framework's own rule gives way. A disabled control here
56/// [flattens rather than fades](crate::disabled) — but the reference dims the whole bar,
57/// icons and shadow and all, and there is nothing here to flatten *to*: a raised pill's
58/// unavailability is the raising going away, not a grey rectangle.
59const DISABLED_OPACITY: f32 = 0.38;
60
61/// A raised pill holding a search field, with a slot before it and any number after.
62pub struct SearchBar<Msg = crate::callback::Callback> {
63    value: String,
64    hint: Option<String>,
65    leading: RefCell<Option<Box<dyn Widget<Msg>>>>,
66    trailing: RefCell<Vec<Box<dyn Widget<Msg>>>>,
67    on_input: Option<Rc<dyn Fn(String) -> Msg>>,
68    on_submit: Option<Msg>,
69    on_tap: Option<Msg>,
70    enabled: bool,
71    read_only: bool,
72    background: Option<Color>,
73    shadow_color: Option<Color>,
74    elevation: Option<f32>,
75    shape: Option<ShapeBorder>,
76    radius: Option<BorderRadius>,
77    side: Option<BorderSide>,
78    padding: Option<Insets>,
79    min_width: Option<f32>,
80    max_width: Option<f32>,
81    height: Option<f32>,
82    text_style: Option<TextStyle>,
83    hint_style: Option<TextStyle>,
84    built: OnceCell<Vec<Box<dyn Widget<Msg>>>>,
85}
86
87impl SearchBar<crate::callback::Callback> {
88    /// Drives the bar from `controller`: it shows the controller's text, and what is typed
89    /// in it is written to the controller.
90    ///
91    /// This sets the bar's text and its `on_input`, so it replaces both.
92    pub fn controller(mut self, controller: &crate::TextEditingController) -> Self {
93        self.value = controller.text();
94        let write = controller.clone();
95        self.on_input(move |text| write.set_text(text))
96    }
97}
98
99impl<Msg: Clone + 'static> SearchBar<Msg> {
100    /// A search bar showing `value`.
101    pub fn new(value: impl Into<String>) -> Self {
102        Self {
103            value: value.into(),
104            hint: None,
105            leading: RefCell::new(None),
106            trailing: RefCell::new(Vec::new()),
107            on_input: None,
108            on_submit: None,
109            on_tap: None,
110            enabled: true,
111            read_only: false,
112            background: None,
113            shadow_color: None,
114            elevation: None,
115            shape: None,
116            radius: None,
117            side: None,
118            padding: None,
119            min_width: None,
120            max_width: None,
121            height: None,
122            text_style: None,
123            hint_style: None,
124            built: OnceCell::new(),
125        }
126    }
127
128    /// What the bar says while it is empty (`search_anchor.dart:1454`).
129    pub fn hint(mut self, hint: impl Into<String>) -> Self {
130        self.hint = Some(hint.into());
131        self.rebuild();
132        self
133    }
134
135    /// The widget before the field — usually a menu button, a back arrow or a magnifier.
136    pub fn leading(self, leading: impl Widget<Msg> + 'static) -> Self {
137        self.leading_boxed(Box::new(leading))
138    }
139
140    /// [`Self::leading`], for a widget already boxed.
141    pub fn leading_boxed(mut self, leading: Box<dyn Widget<Msg>>) -> Self {
142        *self.leading.borrow_mut() = Some(leading);
143        self.rebuild();
144        self
145    }
146
147    /// Adds a widget **after** the field. The reference asks for no more than two
148    /// (`search_anchor.dart:1465`) and does not enforce it; neither does this, because a
149    /// bar that silently dropped the third would be harder to debug than a crowded one.
150    pub fn trailing(self, trailing: impl Widget<Msg> + 'static) -> Self {
151        self.trailing_boxed(Box::new(trailing))
152    }
153
154    /// [`Self::trailing`], for a widget already boxed.
155    pub fn trailing_boxed(mut self, trailing: Box<dyn Widget<Msg>>) -> Self {
156        self.trailing.borrow_mut().push(trailing);
157        self.rebuild();
158        self
159    }
160
161    /// What to emit on every keystroke.
162    pub fn on_input<R: crate::callback::IntoMsg<Msg>>(
163        mut self,
164        on_input: impl Fn(String) -> R + 'static,
165    ) -> Self {
166        self.on_input = Some(Rc::new(crate::callback::handler1(on_input)));
167        self.rebuild();
168        self
169    }
170
171    /// What to emit when the search is confirmed.
172    pub fn on_submit(mut self, message: impl Into<Msg>) -> Self {
173        self.on_submit = Some(message.into());
174        self.rebuild();
175        self
176    }
177
178    /// What to emit when the bar itself is pressed — how a bar that only *looks* like a
179    /// field opens the thing that really searches (`search_anchor.dart:1470`). It goes
180    /// with [`Self::read_only`].
181    pub fn on_tap(mut self, message: impl Into<Msg>) -> Self {
182        self.on_tap = Some(message.into());
183        self.rebuild();
184        self
185    }
186
187    /// Whether the bar can be used at all. A disabled one is inert and fades.
188    pub fn enabled(mut self, enabled: bool) -> Self {
189        self.enabled = enabled;
190        self.rebuild();
191        self
192    }
193
194    /// The bar takes no typing, only presses — a bar whose real search happens somewhere
195    /// else. It is **not** [`disabled`](Self::enabled): it still lights, still answers a
196    /// press, and still reads as available, because it is.
197    pub fn read_only(mut self, read_only: bool) -> Self {
198        self.read_only = read_only;
199        self.rebuild();
200        self
201    }
202
203    /// The bar's fill. Unset, the scheme's `surface_container_high`
204    /// (`search_anchor.dart:1859`).
205    pub fn background_color(mut self, color: Color) -> Self {
206        self.background = Some(color);
207        self
208    }
209
210    /// The colour its shadow is cast in, used **as it is given**, alpha included — so
211    /// [`Color::TRANSPARENT`] casts none. Unset, the scheme's `shadow` at 30 %.
212    pub fn shadow_color(mut self, color: Color) -> Self {
213        self.shadow_color = Some(color);
214        self
215    }
216
217    /// How far off the page it sits. Unset, `6` — and `0.0` is the flat bar an
218    /// application that separates by colour wants.
219    pub fn elevation(mut self, elevation: f32) -> Self {
220        self.elevation = Some(elevation);
221        self
222    }
223
224    /// Its shape. Unset, a stadium (`search_anchor.dart:1892`).
225    pub fn shape(mut self, shape: ShapeBorder) -> Self {
226        self.shape = Some(shape);
227        self
228    }
229
230    /// Its corners, for the ordinary case of a rounded rectangle. See [`Self::shape`].
231    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
232        self.radius = Some(radius.into());
233        self
234    }
235
236    /// An outline around it. Unset, none at all — the reference has no default side
237    /// (`search_anchor.dart:1888`), a raised surface being told apart by its shadow.
238    pub fn side(mut self, width: f32, color: Color) -> Self {
239        self.side = Some(BorderSide { width, color });
240        self
241    }
242
243    /// The room inside it. Unset, `8` either side.
244    pub fn padding(mut self, padding: Insets) -> Self {
245        self.padding = Some(padding);
246        self.rebuild();
247        self
248    }
249
250    /// The narrowest it will go. Unset, [`SEARCH_BAR_MIN_WIDTH`].
251    pub fn min_width(mut self, width: f32) -> Self {
252        self.min_width = Some(width);
253        self
254    }
255
256    /// The widest. Unset, [`SEARCH_BAR_MAX_WIDTH`].
257    pub fn max_width(mut self, width: f32) -> Self {
258        self.max_width = Some(width);
259        self
260    }
261
262    /// Its height. Unset, [`SEARCH_BAR_HEIGHT`].
263    pub fn height(mut self, height: f32) -> Self {
264        self.height = Some(height);
265        self
266    }
267
268    /// The value's type. Unset, `bodyLarge` in `on_surface`
269    /// (`search_anchor.dart:1900`).
270    pub fn text_style(mut self, style: TextStyle) -> Self {
271        self.text_style = Some(style);
272        self.rebuild();
273        self
274    }
275
276    /// The hint's type. Unset, the value's, then `bodyLarge` in `on_surface_variant`
277    /// (`search_anchor.dart:1903`) — **the reference falls back to the value's style
278    /// before its own default**, so a bar told to use one type does not say its hint in
279    /// another.
280    pub fn hint_style(mut self, style: TextStyle) -> Self {
281        self.hint_style = Some(style);
282        self.rebuild();
283        self
284    }
285
286    /// Throws the assembled subtree away, so the builders stay order-independent.
287    fn rebuild(&mut self) {
288        self.built.take();
289    }
290
291    /// The room inside it, resolved.
292    fn room(&self, theme: &Theme) -> Insets {
293        self.padding
294            .or(theme.widgets.search_bar.padding)
295            .unwrap_or(Insets::new(0.0, PADDING_X, 0.0, PADDING_X))
296    }
297
298    /// Its shape, resolved.
299    fn outline(&self, theme: &Theme) -> ShapeBorder {
300        let t = &theme.widgets.search_bar;
301        let shape = resolve_shape(
302            self.shape,
303            t.shape,
304            self.radius.or(t.radius),
305            ShapeBorder::stadium(),
306        );
307        match self.side.or(t.side) {
308            Some(side) => shape.with_side(side),
309            None => shape,
310        }
311    }
312
313    /// The value's type, resolved.
314    fn value_style(&self, theme: &Theme) -> TextStyle {
315        self.text_style
316            .or(theme.widgets.search_bar.text_style)
317            .unwrap_or_else(|| crate::theme::type_scale(Some(theme)).body_large)
318    }
319
320    /// **The hint's type, resolved on five rungs rather than three.**
321    ///
322    /// The caller's hint style, then the theme's hint style, then the caller's *value*
323    /// style, then the theme's value style, then the default
324    /// (`search_anchor.dart:1727`). The two middle rungs are the point: a bar told to say
325    /// its value in one type would otherwise say its hint in another, which is the kind of
326    /// thing nobody notices until the field is empty.
327    fn hint_style_of(&self, theme: &Theme) -> TextStyle {
328        let t = &theme.widgets.search_bar;
329        self.hint_style
330            .or(t.hint_style)
331            .or(self.text_style)
332            .or(t.text_style)
333            .unwrap_or_else(|| crate::theme::type_scale(Some(theme)).body_large)
334    }
335
336    /// The bar's subtree: the leading slot, the field, the trailing ones.
337    fn assemble(&self, theme: &Theme) -> Vec<Box<dyn Widget<Msg>>> {
338        let text_style = self.value_style(theme);
339        let hint_style = self.hint_style_of(theme);
340
341        let mut field = crate::TextField::<Msg>::new(self.value.clone())
342            .borderless()
343            .dense(true)
344            .enabled(self.enabled)
345            .style(crate::TextFieldStyle {
346                text_color: text_style.color.or(Some(theme.scheme.on_surface)),
347                // A field's hint and its floating label are one colour here, and a search
348                // bar has no label — so this is the hint's colour and nothing else's.
349                label_color: hint_style.color.or(Some(theme.scheme.on_surface_variant)),
350                // The reference pads the field **again**, inside the row it already padded
351                // (`search_anchor.dart:1790`), so the gap from a leading icon to the first
352                // letter is twice the gap from the bar's edge to that icon.
353                padding_x: Some(self.room(theme).left),
354                ..Default::default()
355            });
356        if let Some(size) = text_style.size {
357            field = field.size(size);
358        }
359        if let Some(hint) = &self.hint {
360            field = field.placeholder(hint.clone());
361        }
362        if self.read_only {
363            field = field.read_only();
364        }
365        if let Some(on_input) = &self.on_input {
366            let on_input = on_input.clone();
367            field = field.on_input(move |value| on_input(value));
368        }
369        if let Some(message) = &self.on_submit {
370            field = field.on_submit(message.clone());
371        }
372        let mut row = Flex::<Msg>::row().align(Align::Center);
373        if let Some(leading) = self.leading.borrow_mut().take() {
374            row = row.child_boxed(leading);
375        }
376        row = row.child(crate::Expanded::new(field));
377        for trailing in std::mem::take(&mut *self.trailing.borrow_mut()) {
378            row = row.child_boxed(trailing);
379        }
380        vec![Box::new(row)]
381    }
382}
383
384impl<Msg: Clone + 'static> Widget<Msg> for SearchBar<Msg> {
385    fn style(&self) -> Style {
386        Style {
387            height: Dimension::Length(self.height.unwrap_or(SEARCH_BAR_HEIGHT)),
388            min_width: Dimension::Length(self.min_width.unwrap_or(SEARCH_BAR_MIN_WIDTH)),
389            max_width: Dimension::Length(self.max_width.unwrap_or(SEARCH_BAR_MAX_WIDTH)),
390            align: Align::Center,
391            padding: Insets::new(0.0, PADDING_X, 0.0, PADDING_X),
392            ..Default::default()
393        }
394    }
395
396    fn style_themed(&self, theme: &Theme) -> Style {
397        let t = &theme.widgets.search_bar;
398        let room = self.room(theme);
399        Style {
400            height: Dimension::Length(self.height.or(t.height).unwrap_or(SEARCH_BAR_HEIGHT)),
401            min_width: Dimension::Length(
402                self.min_width
403                    .or(t.min_width)
404                    .unwrap_or(SEARCH_BAR_MIN_WIDTH),
405            ),
406            max_width: Dimension::Length(
407                self.max_width
408                    .or(t.max_width)
409                    .unwrap_or(SEARCH_BAR_MAX_WIDTH),
410            ),
411            padding: room,
412            ..Widget::<Msg>::style(self)
413        }
414    }
415
416    fn build_themed(&self, theme: &Theme) {
417        let _ = self.built.set(self.assemble(theme));
418    }
419
420    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
421        self.built.get_or_init(|| self.assemble(&Theme::default()))
422    }
423
424    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
425        // **A disabled bar fades, shadow and all.** See [`DISABLED_OPACITY`].
426        let o = status.opacity * if self.enabled { 1.0 } else { DISABLED_OPACITY };
427        let t = &theme.widgets.search_bar;
428
429        let depth = self.elevation.or(t.elevation).unwrap_or(ELEVATION);
430        // A named colour is the colour: its alpha used to be overwritten with the default's
431        // 30 %, so a caller asking for a transparent shadow still got a black one.
432        let shadow = self
433            .shadow_color
434            .or(t.shadow_color)
435            .unwrap_or(theme.scheme.shadow);
436        if depth > 0.0 && shadow.a > 0.0 {
437            // The reference's shadows for this height (milestone 606).
438            frus_core::paint_elevation(
439                scene,
440                bounds,
441                BorderRadius::uniform(bounds.height * 0.5),
442                depth,
443                shadow.fade(o),
444            );
445        }
446
447        let base = self
448            .background
449            .or(t.background_color)
450            .unwrap_or(theme.scheme.surface_container_high);
451        // Nothing lights on a bar that cannot be used. **Nor on one with no surface**: a
452        // state layer is a lerp *from the ground* toward the ink, and a transparent ground
453        // lerped toward `on_surface` is a grey wash over whatever is behind the bar rather
454        // than a highlight on the bar. That is the case a search **view**'s header is
455        // — a bar painted onto a surface that is already there — and it is why the
456        // reference sets that header's overlay to transparent (`search_anchor.dart:1166`)
457        // rather than leaving it to the default.
458        //
459        // Otherwise the framework's one rule: a lerp from the ground toward the ink,
460        // resolved opaquely, answering hover, focus and press at once
461        // (`search_anchor.dart:1874`).
462        let fill = if self.enabled && base.a > 0.0 {
463            theme.state_layer(base, theme.scheme.on_surface, &status)
464        } else {
465            base
466        };
467        scene.draw_shape(bounds, self.outline(theme), fill.fade(o));
468    }
469
470    /// A press on the bar is a press on the bar, wherever it lands — the reference wraps
471    /// the whole thing in an ink well rather than only the field
472    /// (`search_anchor.dart:1772`), so that the eight pixels beside a leading icon are
473    /// not dead.
474    fn on_click(&self) -> Option<Msg> {
475        self.enabled.then(|| self.on_tap.clone()).flatten()
476    }
477
478    fn focusable(&self) -> bool {
479        self.enabled
480    }
481
482    /// **Announced as a search field**, which is a role in its own right on both
483    /// platforms — the reference says so explicitly (`search_anchor.dart:1792`) rather
484    /// than letting a text input inside a pill be read as a text input.
485    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
486        let mut props = frus_core::SemanticsProperties::new(frus_core::Role::TextInput)
487            .value(self.value.clone())
488            .disabled(!self.enabled);
489        if let Some(hint) = &self.hint {
490            props = props.label(hint.clone());
491        }
492        if self.enabled {
493            props = props.clickable();
494        }
495        Some(props)
496    }
497}
498
499#[cfg(test)]
500mod tests {
501    use super::*;
502    use frus_core::Primitive;
503
504    #[derive(Clone, Debug, PartialEq)]
505    enum Msg {
506        Open,
507        Query(String),
508    }
509
510    fn bar() -> SearchBar<Msg> {
511        SearchBar::new("").hint("Search mail")
512    }
513
514    fn scene_of(widget: &dyn Widget<Msg>, status: Status, theme: &Theme) -> Scene {
515        let mut scene = Scene::new();
516        widget.paint(
517            Rect::new(0.0, 0.0, 400.0, SEARCH_BAR_HEIGHT),
518            status,
519            theme,
520            &mut scene,
521        );
522        scene
523    }
524
525    fn lit(status: Status) -> Status {
526        Status {
527            opacity: 1.0,
528            ..status
529        }
530    }
531
532    /// **The box the reference asks for**, floor and ceiling both. The floor is the one
533    /// that surprises people: a search bar will not go below 360 unless it is told to,
534    /// because a search bar narrower than a phone is a text field wearing a shadow.
535    #[test]
536    fn it_asks_for_the_box_the_reference_asks_for() {
537        let theme = Theme::default();
538        let style = Widget::<Msg>::style_themed(&bar(), &theme);
539        assert_eq!(style.min_width, Dimension::Length(SEARCH_BAR_MIN_WIDTH));
540        assert_eq!(style.max_width, Dimension::Length(SEARCH_BAR_MAX_WIDTH));
541        assert_eq!(style.height, Dimension::Length(SEARCH_BAR_HEIGHT));
542
543        let narrow = Widget::<Msg>::style_themed(&bar().min_width(240.0).height(40.0), &theme);
544        assert_eq!(narrow.min_width, Dimension::Length(240.0));
545        assert_eq!(narrow.height, Dimension::Length(40.0));
546    }
547
548    /// **The hint's type falls back through the value's.** Five rungs, and the two in the
549    /// middle are the whole point: a bar told to say its value in one type must not say
550    /// its hint in another.
551    #[test]
552    fn the_hint_takes_the_values_type_before_its_own_default() {
553        let theme = Theme::default();
554        let plain = bar();
555        assert_eq!(
556            plain.hint_style_of(&theme),
557            crate::theme::type_scale(Some(&theme)).body_large,
558            "with nothing said, both are the default step"
559        );
560
561        let told = bar().text_style(TextStyle::new(22.0));
562        assert_eq!(
563            told.hint_style_of(&theme).size,
564            Some(22.0),
565            "the hint follows the value it stands in for"
566        );
567
568        let both = bar()
569            .text_style(TextStyle::new(22.0))
570            .hint_style(TextStyle::new(12.0));
571        assert_eq!(
572            both.hint_style_of(&theme).size,
573            Some(12.0),
574            "and a hint told its own type keeps it"
575        );
576        assert_eq!(both.value_style(&theme).size, Some(22.0));
577    }
578
579    /// A disabled bar is inert to the tap and to the tab, announced as unavailable, and
580    /// **fades rather than flattening** — the one place the framework's own disabled rule
581    /// gives way, because a raised pill's unavailability is the raising going away.
582    #[test]
583    fn a_disabled_bar_is_inert_and_faded() {
584        let theme = Theme::default();
585        let off = bar().on_tap(Msg::Open).enabled(false);
586        assert_eq!(Widget::<Msg>::on_click(&off), None);
587        assert!(!Widget::<Msg>::focusable(&off));
588        let semantics = Widget::<Msg>::semantics(&off).expect("still announced");
589        assert!(semantics.disabled);
590        assert!(!semantics.clickable);
591
592        let on = bar().on_tap(Msg::Open);
593        assert_eq!(Widget::<Msg>::on_click(&on), Some(Msg::Open));
594        assert!(Widget::<Msg>::focusable(&on));
595
596        // And the pill is painted at the reference's 38 %.
597        let alpha = |bar: &SearchBar<Msg>| {
598            scene_of(bar, lit(Status::default()), &theme)
599                .primitives()
600                .iter()
601                .rev()
602                .find_map(|p| match p {
603                    Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(color.a),
604                    _ => None,
605                })
606                .expect("the bar paints its pill")
607        };
608        assert!(
609            (alpha(&off) - DISABLED_OPACITY).abs() < 0.01,
610            "{}",
611            alpha(&off)
612        );
613        assert!((alpha(&on) - 1.0).abs() < 0.01);
614    }
615
616    /// Nothing lights on a bar that cannot be used: a state layer is the promise of an
617    /// interaction, and there is none.
618    #[test]
619    fn a_disabled_bar_does_not_light_under_a_pointer() {
620        let theme = Theme::default();
621        let hovered = Status {
622            opacity: 1.0,
623            hover_progress: 1.0,
624            ..Default::default()
625        };
626        let fill = |bar: &SearchBar<Msg>, status: Status| {
627            scene_of(bar, status, &theme)
628                .primitives()
629                .iter()
630                .rev()
631                .find_map(|p| match p {
632                    Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
633                    _ => None,
634                })
635                .expect("the bar paints its pill")
636        };
637        let resting = fill(&bar(), lit(Status::default()));
638        assert_ne!(
639            fill(&bar(), hovered),
640            resting,
641            "an available bar answers a pointer"
642        );
643        let off = bar().enabled(false);
644        assert_eq!(
645            fill(&off, hovered).fade(1.0),
646            fill(&off, lit(Status::default())).fade(1.0),
647            "an unavailable one has nothing to say to it"
648        );
649    }
650
651    /// **The field inside has no container of its own.** Two containers deep is what a
652    /// search bar looks like when the field keeps its own box: the pill's corners cut by a
653    /// rounded rectangle sitting inside them.
654    #[test]
655    fn the_field_inside_paints_no_container() {
656        let theme = Theme::default();
657        let boxed = crate::TextField::<Msg>::new("x").filled();
658        let bare = crate::TextField::<Msg>::new("x").borderless();
659        let rects = |field: &crate::TextField<Msg>| {
660            let mut scene = Scene::new();
661            field.paint(
662                Rect::new(0.0, 0.0, 200.0, 48.0),
663                lit(Status::default()),
664                &theme,
665                &mut scene,
666            );
667            scene
668                .primitives()
669                .iter()
670                .filter(|p| matches!(p, Primitive::Rect { .. }))
671                .count()
672        };
673        assert!(rects(&boxed) > 0, "a filled field draws its container");
674        assert_eq!(rects(&bare), 0, "a borderless one draws none of it");
675    }
676
677    /// A press anywhere on the bar is a press on the bar — the reference wraps the whole
678    /// pill rather than only the field, so the eight pixels beside a leading icon are not
679    /// dead.
680    #[test]
681    fn the_whole_pill_answers_a_press() {
682        let bar = bar().read_only(true).on_tap(Msg::Open);
683        assert_eq!(Widget::<Msg>::on_click(&bar), Some(Msg::Open));
684        assert_eq!(
685            Widget::<Msg>::on_click(&SearchBar::<Msg>::new("")),
686            None,
687            "and a bar with nothing to open answers nothing"
688        );
689    }
690
691    /// It is announced as a field with a value, not as a pill.
692    #[test]
693    fn it_announces_its_value() {
694        let bar = SearchBar::<Msg>::new("mail").hint("Search mail");
695        let semantics = Widget::<Msg>::semantics(&bar).expect("announced");
696        assert_eq!(semantics.role, frus_core::Role::TextInput);
697        assert_eq!(semantics.value.as_deref(), Some("mail"));
698        assert_eq!(semantics.label.as_deref(), Some("Search mail"));
699    }
700
701    /// The builders are order-independent: the subtree is thrown away by each of them.
702    #[test]
703    fn saying_it_afterwards_says_it() {
704        let theme = Theme::default();
705        let after = SearchBar::<Msg>::new("")
706            .on_input(Msg::Query)
707            .hint("Search");
708        after.build_themed(&theme);
709        let mut found = 0;
710        fn walk<Msg: Clone + 'static>(node: &dyn Widget<Msg>, found: &mut usize) {
711            if node.debug_name() == "TextField" {
712                *found += 1;
713            }
714            for child in node.children() {
715                walk(&**child, found);
716            }
717        }
718        for child in Widget::<Msg>::children(&after) {
719            walk(&**child, &mut found);
720        }
721        assert_eq!(found, 1, "one field, built once");
722    }
723
724    /// Its shape carries the outline, so a bar told to have one is still whatever shape it
725    /// was told to be.
726    #[test]
727    fn an_outline_rides_on_the_shape() {
728        let theme = Theme::default();
729        assert_eq!(bar().outline(&theme), ShapeBorder::stadium());
730        let edged = bar().side(2.0, Color::rgb8(200, 0, 0));
731        assert_eq!(edged.outline(&theme).side().width, 2.0);
732        let squared = bar().radius(4.0).side(1.0, Color::rgb8(0, 0, 0));
733        assert!(matches!(
734            squared.outline(&theme),
735            ShapeBorder::RoundedRectangle { .. }
736        ));
737    }
738
739    /// **A shadow colour is used as it is given** — alpha included. The paint used to
740    /// overwrite the alpha with the default's 30 %, so `shadow_color(TRANSPARENT)` still
741    /// cast a black shadow and a half-strength colour came out at 30 %. The colour is the
742    /// shadow's at full strength, which the three shadows share (milestone 606).
743    #[test]
744    fn a_named_shadow_colour_is_the_colour() {
745        let theme = Theme::default();
746        let shadows = |bar: &SearchBar<Msg>, theme: &Theme| {
747            crate::shadowprobe::colour(scene_of(bar, lit(Status::default()), theme).primitives())
748        };
749        assert_eq!(
750            shadows(&bar(), &theme),
751            Some(theme.scheme.shadow),
752            "the default, unchanged"
753        );
754        assert!(
755            shadows(&bar().shadow_color(Color::TRANSPARENT), &theme).is_none(),
756            "a transparent shadow casts none"
757        );
758        let half = Color::rgba(0.2, 0.0, 0.4, 0.5);
759        assert_eq!(shadows(&bar().shadow_color(half), &theme), Some(half));
760        let mut themed = Theme::default();
761        themed.widgets.search_bar.shadow_color = Some(half);
762        assert_eq!(
763            shadows(&bar(), &themed),
764            Some(half),
765            "the theme's, likewise"
766        );
767    }
768}