Skip to main content

frus_widgets/
fab.rs

1//! [`FloatingActionButton`]: the one action a screen is **for**, sitting above it.
2
3use frus_core::{BorderRadius, Color, Point, Rect, Scene, ShapeBorder, TextStyle};
4use frus_layout::{Dimension, Style};
5
6use crate::disabled::{disabled_container, disabled_content};
7use crate::icons::IconData;
8use crate::interaction::Status;
9use crate::theme::Theme;
10use crate::widget::Widget;
11
12/// The four sizes the reference draws (`floating_action_button.dart:783`), each with its
13/// own box, its own corner and its own icon.
14///
15/// They are one enum rather than four constructors' worth of fields because every one of
16/// those three numbers follows from **which** of the four it is, and a size that took
17/// them separately would let a caller build a large button with a small button's corner.
18#[derive(Copy, Clone, Debug, Default, PartialEq, Eq, Hash)]
19pub enum FabSize {
20    /// 56 across, 16 at the corners, a 24 icon. The one meant by "the button".
21    #[default]
22    Regular,
23    /// 40 and 12, with the same 24 icon — for a screen that already has a bigger one, or
24    /// a surface too small to give up 56.
25    Small,
26    /// 96 and 28, with a 36 icon: a single action on a large screen.
27    Large,
28    /// 56 tall and as wide as its words, 16 at the corners. An icon **and** a label,
29    /// for an action whose glyph would not say what it does.
30    Extended,
31}
32
33impl FabSize {
34    /// The button's side, or its height when it is extended.
35    pub const fn extent(self) -> f32 {
36        match self {
37            FabSize::Small => 40.0,
38            FabSize::Large => 96.0,
39            FabSize::Regular | FabSize::Extended => 56.0,
40        }
41    }
42
43    /// The corner the reference gives it (`floating_action_button.dart:816`).
44    pub const fn radius(self) -> f32 {
45        match self {
46            FabSize::Small => 12.0,
47            FabSize::Large => 28.0,
48            FabSize::Regular | FabSize::Extended => 16.0,
49        }
50    }
51
52    /// How big the glyph inside is (`floating_action_button.dart:824`).
53    pub const fn icon_size(self) -> f32 {
54        match self {
55            FabSize::Large => 36.0,
56            _ => 24.0,
57        }
58    }
59}
60
61/// The room either side of an extended button's content
62/// (`floating_action_button.dart:831`): twenty at the end, and sixteen at the start when
63/// there is a glyph before the words.
64const EXTENDED_PAD: f32 = 20.0;
65const EXTENDED_PAD_WITH_ICON: f32 = 16.0;
66/// The gap between that glyph and the words (`floating_action_button.dart:798`).
67const EXTENDED_GAP: f32 = 8.0;
68
69/// How far off the page the button sits, at rest and under a pointer
70/// (`floating_action_button.dart:778`).
71const ELEVATION: f32 = 6.0;
72const HOVER_ELEVATION: f32 = 8.0;
73
74/// **The one action a screen is for.**
75///
76/// ```
77/// # use frus_widgets::{FloatingActionButton, Icons};
78/// # #[derive(Clone)] enum Msg { Add }
79/// FloatingActionButton::<Msg>::new(Icons::ADD).on_press(Msg::Add);
80/// FloatingActionButton::<Msg>::extended("New list").icon(Icons::ADD).on_press(Msg::Add);
81/// ```
82///
83/// This framework had no such widget. It had `fab_button`, a **helper returning a
84/// [`Button`](crate::Button)** — which meant the scaffold's floating action button was a
85/// filled button in disguise, and took a filled button's colours: `primary` on
86/// `on_primary` where the reference's takes `primary_container` on `on_primary_container`
87/// (`floating_action_button.dart:809`). Two roles out, on the most prominent control on
88/// the screen.
89pub struct FloatingActionButton<Msg = crate::callback::Callback> {
90    icon: Option<IconData>,
91    label: Option<String>,
92    size: FabSize,
93    enabled: bool,
94    on_press: Option<Msg>,
95    background: Option<Color>,
96    foreground: Option<Color>,
97    elevation: Option<f32>,
98    hover_elevation: Option<f32>,
99    shape: Option<ShapeBorder>,
100    icon_size: Option<f32>,
101    label_style: Option<TextStyle>,
102}
103
104impl<Msg: Clone> FloatingActionButton<Msg> {
105    /// A button carrying a glyph.
106    pub fn new(icon: IconData) -> Self {
107        Self {
108            icon: Some(icon),
109            label: None,
110            size: FabSize::Regular,
111            enabled: true,
112            on_press: None,
113            background: None,
114            foreground: None,
115            elevation: None,
116            hover_elevation: None,
117            shape: None,
118            icon_size: None,
119            label_style: None,
120        }
121    }
122
123    /// A button carrying **one character** rather than a drawn icon — a plus, a tick, an
124    /// arrow. The same escape hatch [`IconButton::glyph`](crate::IconButton::glyph)
125    /// offers, and for the same reason: not every mark an application wants is in
126    /// [`crate::Icons`], and waiting for one to be added is not an answer.
127    ///
128    /// It is a **regular** button, not an extended one: the character is centred in the
129    /// square the way a glyph would be, and the box does not grow to fit it.
130    pub fn glyph(text: impl Into<String>) -> Self {
131        Self {
132            label: Some(text.into()),
133            ..Self::extended("")
134        }
135        .size(FabSize::Regular)
136    }
137
138    /// An **extended** button: words, and optionally a glyph before them through
139    /// [`icon`](Self::icon). For an action whose glyph would not say what it does.
140    pub fn extended(label: impl Into<String>) -> Self {
141        Self {
142            icon: None,
143            label: Some(label.into()),
144            size: FabSize::Extended,
145            enabled: true,
146            on_press: None,
147            background: None,
148            foreground: None,
149            elevation: None,
150            hover_elevation: None,
151            shape: None,
152            icon_size: None,
153            label_style: None,
154        }
155    }
156
157    /// The glyph, over whatever was there. On an extended button it goes before the
158    /// words.
159    #[must_use]
160    pub fn icon(mut self, icon: IconData) -> Self {
161        self.icon = Some(icon);
162        self
163    }
164
165    /// **The smaller box**: 40 across, cornered at 12, with the same glyph.
166    #[must_use]
167    pub fn small(mut self) -> Self {
168        self.size = FabSize::Small;
169        self
170    }
171
172    /// **The larger box**: 96 across, cornered at 28, with a 36 glyph.
173    #[must_use]
174    pub fn large(mut self) -> Self {
175        self.size = FabSize::Large;
176        self
177    }
178
179    /// Which of the four this is, said outright.
180    #[must_use]
181    pub fn size(mut self, size: FabSize) -> Self {
182        self.size = size;
183        self
184    }
185
186    /// What it does. A button with no message is **inert**, which is not the same as
187    /// disabled: see [`enabled`](Self::enabled).
188    #[must_use]
189    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
190        self.on_press = Some(message.into());
191        self
192    }
193
194    /// Whether it can be pressed. Disabled, it flattens to `on_surface` at 12 % under a
195    /// glyph at 38 % and **casts no shadow** — the reference's `disabledElevation` is
196    /// zero, and a thing that is still floating while refusing to be pressed is telling
197    /// two different stories.
198    #[must_use]
199    pub fn enabled(mut self, enabled: bool) -> Self {
200        self.enabled = enabled;
201        self
202    }
203
204    /// **The surface**, over the theme's and `primary_container`.
205    #[must_use]
206    pub fn background(mut self, color: Color) -> Self {
207        self.background = Some(color);
208        self
209    }
210
211    /// **What is drawn on it**, over the theme's and `on_primary_container`.
212    #[must_use]
213    pub fn foreground(mut self, color: Color) -> Self {
214        self.foreground = Some(color);
215        self
216    }
217
218    /// How far off the page it sits at rest, in pixels. Six by default.
219    ///
220    /// Only at rest: under a pointer it takes [`hover_elevation`](Self::hover_elevation)
221    /// whatever this says, as the reference's does, so a button made flat here still rises
222    /// when hovered. A button that should never float says both.
223    #[must_use]
224    pub fn elevation(mut self, elevation: f32) -> Self {
225        self.elevation = Some(elevation);
226        self
227    }
228
229    /// How far off the page it sits under a pointer, over the theme's and eight
230    /// (the reference's defaults, line 780).
231    #[must_use]
232    pub fn hover_elevation(mut self, elevation: f32) -> Self {
233        self.hover_elevation = Some(elevation);
234        self
235    }
236
237    /// **What shape it is**, over the theme's and the corner its size asks for.
238    #[must_use]
239    pub fn shape(mut self, shape: ShapeBorder) -> Self {
240        self.shape = Some(shape);
241        self
242    }
243
244    /// The shorthand for a rounded rectangle. `shape(ShapeBorder::stadium())` is the
245    /// round button a docked one needs — see the note on [`Self::shape_of`].
246    #[must_use]
247    pub fn radius(self, radius: impl Into<BorderRadius>) -> Self {
248        self.shape(ShapeBorder::rounded(radius.into()))
249    }
250
251    /// How big the glyph is, over the theme's and the size's own.
252    #[must_use]
253    pub fn icon_size(mut self, size: f32) -> Self {
254        self.icon_size = Some(size);
255        self
256    }
257
258    /// An extended button's type, over the theme's and `label_large`
259    /// (`floating_action_button.dart:832`).
260    #[must_use]
261    pub fn label_style(mut self, style: TextStyle) -> Self {
262        self.label_style = Some(style);
263        self
264    }
265
266    /// **What shape it is**: the caller's word, then the theme's, then the corner its
267    /// size asks for.
268    ///
269    /// The framework's own is the reference's per-size rounding and **not** a circle.
270    /// A docked button, sitting in the circular notch a
271    /// [`BottomAppBar`](crate::BottomAppBar) cuts for it, wants
272    /// [`ShapeBorder::stadium`] instead — the notch is round, and a rounded square in it
273    /// leaves four corners hanging over the bar. `Scaffold` cannot pick for the caller,
274    /// because it cuts the notch from the button's *bounds* and never sees its shape.
275    /// See the roadmap.
276    pub fn shape_of(&self, theme: &Theme) -> ShapeBorder {
277        crate::resolve_shape(
278            self.shape,
279            theme.widgets.fab.shape,
280            theme.widgets.fab.radius.map(BorderRadius::uniform),
281            ShapeBorder::rounded(self.size.radius()),
282        )
283    }
284
285    fn glyph_size(&self, theme: &Theme) -> f32 {
286        self.icon_size
287            .or(theme.widgets.fab.icon_size)
288            .unwrap_or(self.size.icon_size())
289    }
290
291    fn text_style(&self, theme: &Theme) -> frus_core::ResolvedTextStyle {
292        self.label_style
293            .or(theme.widgets.fab.label_style)
294            .unwrap_or(theme.text.label_large)
295            .resolved()
296    }
297
298    /// The room before the content: sixteen when a glyph leads the words, twenty when
299    /// the words stand alone (`floating_action_button.dart:831`).
300    fn lead_pad(&self, theme: &Theme) -> f32 {
301        theme.widgets.fab.extended_padding.unwrap_or(
302            if self.icon.is_some() && self.size == FabSize::Extended {
303                EXTENDED_PAD_WITH_ICON
304            } else {
305                EXTENDED_PAD
306            },
307        )
308    }
309
310    /// How wide it comes out: its own side, unless it is extended, where it is as wide as
311    /// what it carries.
312    fn width(&self, theme: &Theme) -> f32 {
313        if self.size != FabSize::Extended {
314            return self.size.extent();
315        }
316        let lead = self.lead_pad(theme);
317        let glyph = match self.icon {
318            Some(_) => {
319                self.glyph_size(theme) + theme.widgets.fab.extended_gap.unwrap_or(EXTENDED_GAP)
320            }
321            None => 0.0,
322        };
323        let words = match &self.label {
324            Some(label) => frus_text::measure_resolved(label, &self.text_style(theme)).width,
325            None => 0.0,
326        };
327        lead + glyph + words + EXTENDED_PAD
328    }
329
330    /// The elevation this frame: none while disabled, the hovered value under a pointer,
331    /// the resting one otherwise.
332    ///
333    /// Each is resolved **on its own** — the caller's, the theme's, the default — as the
334    /// reference's build resolves them (lines 512 and 516). The hovered one
335    /// used to be the default's eight *or the resting height if that was higher*, and there
336    /// was no caller's word for it at all, so a button flattened with `elevation(0.0)` had
337    /// no way to stay flat under a pointer.
338    fn depth(&self, status: &Status, theme: &Theme) -> f32 {
339        if !self.enabled || self.on_press.is_none() {
340            return 0.0;
341        }
342        let rest = self
343            .elevation
344            .or(theme.widgets.fab.elevation)
345            .unwrap_or(ELEVATION);
346        let hovered = self
347            .hover_elevation
348            .or(theme.widgets.fab.hover_elevation)
349            .unwrap_or(HOVER_ELEVATION);
350        rest + (hovered - rest) * status.hover_progress.clamp(0.0, 1.0)
351    }
352}
353
354impl<Msg: Clone> Widget<Msg> for FloatingActionButton<Msg> {
355    fn style(&self) -> Style {
356        Widget::<Msg>::style_themed(self, &Theme::default())
357    }
358
359    fn style_themed(&self, theme: &Theme) -> Style {
360        Style {
361            width: Dimension::Length(self.width(theme)),
362            height: Dimension::Length(self.size.extent()),
363            ..Default::default()
364        }
365    }
366
367    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
368        &[]
369    }
370
371    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
372        let o = status.opacity;
373        let shape = self.shape_of(theme);
374        let radius = shape
375            .as_rounded(bounds)
376            .map(|(_, r)| r)
377            .unwrap_or(BorderRadius::ZERO);
378
379        let (fill, ink) = if self.enabled {
380            (
381                self.background
382                    .or(theme.widgets.fab.background)
383                    .unwrap_or(theme.scheme.primary_container),
384                self.foreground
385                    .or(theme.widgets.fab.foreground)
386                    .unwrap_or(theme.scheme.on_primary_container),
387            )
388        } else {
389            // The same flattening `Button` and `Chip` do, for the same reason: the
390            // reference collapses every variant to `on_surface` at 12 % under content at
391            // 38 %, so unavailable reads as unavailable and not as a quieter button.
392            (disabled_container(theme), disabled_content(theme))
393        };
394
395        let depth = self.depth(&status, theme);
396        if depth > 0.0 {
397            // The reference's shadows for this height (milestone 606).
398            frus_core::paint_elevation(scene, bounds, radius, depth, theme.scheme.shadow.fade(o));
399        }
400        // The state layer sits **on** the surface, in the content's colour, which is what
401        // keeps a hovered button from looking like a differently-coloured one.
402        let surface = theme.state_layer(fill, ink, &status);
403        scene.draw_shape(bounds, shape, surface.fade(o));
404
405        let glyph = self.glyph_size(theme);
406        let cy = bounds.y + (bounds.height - glyph) * 0.5;
407        match (&self.label, self.icon) {
408            // Extended: the glyph, then the words, both against the leading edge.
409            (Some(label), icon) if self.size == FabSize::Extended => {
410                let mut x = bounds.x + self.lead_pad(theme);
411                if let Some(name) = icon {
412                    let path = name.placed(glyph, x, cy, theme.direction);
413                    scene.fill_path(&path, ink.fade(o));
414                    x += glyph + theme.widgets.fab.extended_gap.unwrap_or(EXTENDED_GAP);
415                }
416                let style = self.text_style(theme);
417                let measured = frus_text::measure_resolved(label, &style);
418                scene.text(
419                    Point::new(x, bounds.y + (bounds.height - measured.height) * 0.5),
420                    label.clone(),
421                    &style,
422                    ink.fade(o),
423                );
424            }
425            // Otherwise: whatever it carries, centred in the square. An icon wins over a
426            // character, since a caller who gave both meant the drawn one.
427            (_, Some(name)) => {
428                let path = name.placed(
429                    glyph,
430                    bounds.x + (bounds.width - glyph) * 0.5,
431                    cy,
432                    theme.direction,
433                );
434                scene.fill_path(&path, ink.fade(o));
435            }
436            (Some(text), None) => {
437                let style = self.text_style(theme);
438                let measured = frus_text::measure_resolved(text, &style);
439                scene.text(
440                    Point::new(
441                        bounds.x + (bounds.width - measured.width) * 0.5,
442                        bounds.y + (bounds.height - measured.height) * 0.5,
443                    ),
444                    text.clone(),
445                    &style,
446                    ink.fade(o),
447                );
448            }
449            (None, None) => {}
450        }
451    }
452
453    fn on_click(&self) -> Option<Msg> {
454        self.enabled.then(|| self.on_press.clone()).flatten()
455    }
456
457    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
458        // Only where there is something to press. Ink on a disabled or inert button
459        // promises an action it does not have.
460        if !self.enabled {
461            return None;
462        }
463        self.on_press.as_ref()?;
464        let radius = self
465            .shape_of(theme)
466            .as_rounded(Rect::new(0.0, 0.0, self.width(theme), self.size.extent()))
467            .map(|(_, r)| r)
468            .unwrap_or(BorderRadius::ZERO);
469        Some(crate::InkStyle::of(theme).radius(radius))
470    }
471
472    fn focusable(&self) -> bool {
473        self.enabled && self.on_press.is_some()
474    }
475
476    /// It says what it does. An extended button has words to read out; a plain one has
477    /// only a glyph, and a glyph is not a name — so the label is empty and the caller has
478    /// to wrap it in a [`Tooltip`](crate::Tooltip), which is what the reference's own
479    /// `tooltip` argument is for.
480    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
481        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
482        let semantics = match &self.label {
483            Some(label) => semantics.label(label.clone()),
484            None => semantics,
485        };
486        Some(if self.enabled && self.on_press.is_some() {
487            semantics.clickable()
488        } else {
489            semantics.disabled(!self.enabled)
490        })
491    }
492}
493
494#[cfg(test)]
495mod tests {
496    use super::*;
497    use crate::icons::Icons;
498    use frus_core::Primitive;
499
500    #[derive(Clone, Debug, PartialEq)]
501    enum Msg {
502        Add,
503    }
504
505    fn painted(
506        fab: &FloatingActionButton<Msg>,
507        theme: &Theme,
508        status: Status,
509    ) -> (Vec<(Rect, Color, BorderRadius)>, usize) {
510        let mut scene = Scene::new();
511        let style = Widget::<Msg>::style_themed(fab, theme);
512        let (Dimension::Length(w), Dimension::Length(h)) = (style.width, style.height) else {
513            panic!("a measured box");
514        };
515        Widget::<Msg>::paint(fab, Rect::new(0.0, 0.0, w, h), status, theme, &mut scene);
516        let rects = scene
517            .primitives()
518            .iter()
519            .filter_map(|p| match p {
520                Primitive::Rect {
521                    rect,
522                    color,
523                    radius,
524                    ..
525                } => Some((*rect, *color, *radius)),
526                _ => None,
527            })
528            .collect();
529        let paths = scene
530            .primitives()
531            .iter()
532            .filter(|p| matches!(p, Primitive::Path { .. }))
533            .count();
534        (rects, paths)
535    }
536
537    /// **The colours were two roles out.** `fab_button` returned a filled
538    /// [`Button`](crate::Button), so the scaffold's floating action button took `primary`
539    /// on `on_primary` where the reference's takes `primary_container` on
540    /// `on_primary_container` (`floating_action_button.dart:809`) — on the most prominent
541    /// control on the screen.
542    #[test]
543    fn a_button_takes_the_container_roles_and_not_the_accent() {
544        let theme = Theme::default();
545        let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
546        let (rects, paths) = painted(&fab, &theme, Status::default());
547        assert_eq!(
548            rects.last().expect("a surface").1,
549            theme.scheme.primary_container,
550            "the container, not the accent"
551        );
552        assert_ne!(
553            theme.scheme.primary_container, theme.primary,
554            "and they differ"
555        );
556        assert_eq!(paths, 1, "the glyph is drawn");
557    }
558
559    /// **The four sizes are one decision, not three.** Every one of the box, the corner
560    /// and the glyph follows from *which* of the four it is
561    /// (`floating_action_button.dart:783`, `:816`, `:824`), so they travel together and a
562    /// caller cannot build a large button with a small one's corner.
563    #[test]
564    fn the_four_sizes_carry_their_own_box_corner_and_glyph() {
565        let theme = Theme::default();
566        for (size, extent, radius, icon) in [
567            (FabSize::Small, 40.0, 12.0, 24.0),
568            (FabSize::Regular, 56.0, 16.0, 24.0),
569            (FabSize::Large, 96.0, 28.0, 36.0),
570        ] {
571            assert_eq!(size.extent(), extent);
572            assert_eq!(size.radius(), radius);
573            assert_eq!(size.icon_size(), icon);
574
575            let fab = FloatingActionButton::<Msg>::new(Icons::ADD).size(size);
576            let style = Widget::<Msg>::style_themed(&fab, &theme);
577            assert_eq!(style.width, Dimension::Length(extent));
578            assert_eq!(style.height, Dimension::Length(extent));
579            let (rects, _) = painted(&fab, &theme, Status::default());
580            assert_eq!(
581                rects.last().expect("a surface").2,
582                BorderRadius::uniform(radius),
583                "{size:?} takes its own corner"
584            );
585        }
586    }
587
588    /// **It is not a circle**, which is what `fab_button` drew. The reference rounds a
589    /// regular button by sixteen and a small one by twelve
590    /// (`floating_action_button.dart:816`); a stadium takes half the short side, so both
591    /// were pills. A caller who *wants* the pill — a docked button, in a round notch —
592    /// says so.
593    #[test]
594    fn it_is_a_rounded_square_and_a_stadium_when_asked() {
595        let theme = Theme::default();
596        let plain = FloatingActionButton::<Msg>::new(Icons::ADD);
597        assert_eq!(
598            painted(&plain, &theme, Status::default())
599                .0
600                .last()
601                .unwrap()
602                .2,
603            BorderRadius::uniform(16.0)
604        );
605        let docked = FloatingActionButton::<Msg>::new(Icons::ADD).shape(ShapeBorder::stadium());
606        assert_eq!(
607            painted(&docked, &theme, Status::default())
608                .0
609                .last()
610                .unwrap()
611                .2,
612            BorderRadius::uniform(28.0),
613            "half the short side"
614        );
615    }
616
617    /// **An extended button is as wide as its words**, with the reference's asymmetric
618    /// room: sixteen before a glyph, twenty before words standing alone, twenty after
619    /// either (`floating_action_button.dart:831`).
620    #[test]
621    fn an_extended_button_is_as_wide_as_what_it_carries() {
622        let theme = Theme::default();
623        let words = FloatingActionButton::<Msg>::extended("New list");
624        let with_icon = FloatingActionButton::<Msg>::extended("New list").icon(Icons::ADD);
625
626        let width = |fab: &FloatingActionButton<Msg>| match Widget::<Msg>::style_themed(fab, &theme)
627            .width
628        {
629            Dimension::Length(w) => w,
630            other => panic!("a measured width, not {other:?}"),
631        };
632        let text =
633            frus_text::measure_resolved("New list", &theme.text.label_large.resolved()).width;
634        assert_eq!(width(&words), EXTENDED_PAD + text + EXTENDED_PAD);
635        assert_eq!(
636            width(&with_icon),
637            EXTENDED_PAD_WITH_ICON + 24.0 + EXTENDED_GAP + text + EXTENDED_PAD,
638            "a glyph shortens the leading room, as the reference's does"
639        );
640        assert_eq!(
641            Widget::<Msg>::style_themed(&words, &theme).height,
642            Dimension::Length(56.0),
643            "and it is still a regular button's height"
644        );
645
646        let (_, paths) = painted(&with_icon, &theme, Status::default());
647        assert_eq!(paths, 1, "the glyph before the words");
648        assert_eq!(painted(&words, &theme, Status::default()).1, 0);
649    }
650
651    /// **A disabled button casts no shadow.** The reference's `disabledElevation` is
652    /// zero, and a control still floating while refusing to be pressed is telling two
653    /// different stories at once. It also stops answering, stops taking focus and stops
654    /// splashing.
655    #[test]
656    fn a_disabled_button_stops_floating() {
657        let theme = Theme::default();
658        let live = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
659        let (rects, _) = painted(&live, &theme, Status::default());
660        assert_eq!(
661            rects.len(),
662            4,
663            "three shadows under the surface: {rects:#?}"
664        );
665
666        let dead = FloatingActionButton::new(Icons::ADD)
667            .on_press(Msg::Add)
668            .enabled(false);
669        let (rects, _) = painted(&dead, &theme, Status::default());
670        assert_eq!(rects.len(), 1, "the surface alone");
671        assert_eq!(rects[0].1, disabled_container(&theme));
672        assert_eq!(Widget::<Msg>::on_click(&dead), None);
673        assert!(!Widget::<Msg>::focusable(&dead));
674        assert!(Widget::<Msg>::ink(&dead, &theme).is_none());
675
676        // And one with nothing to say does not float either: it is inert, not disabled,
677        // so it keeps its live colours and loses its shadow.
678        let inert = FloatingActionButton::<Msg>::new(Icons::ADD);
679        let (rects, _) = painted(&inert, &theme, Status::default());
680        assert_eq!(rects.len(), 1);
681        assert_eq!(rects[0].1, theme.scheme.primary_container);
682    }
683
684    /// **It rises under a pointer**, six to eight (`floating_action_button.dart:778`),
685    /// which is the whole of what a floating button's hover state is.
686    #[test]
687    fn it_rises_under_a_pointer() {
688        let theme = Theme::default();
689        let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
690        let shadow_of = |hover: f32| {
691            let status = Status {
692                hover_progress: hover,
693                ..Default::default()
694            };
695            // The middle one: the reference's widest shadow is narrower at eight than at
696            // six, and the middle one grows with the height all the way up.
697            painted(&fab, &theme, status).0[1].0.width
698        };
699        assert!(
700            shadow_of(1.0) > shadow_of(0.0),
701            "a wider shadow is a higher button"
702        );
703    }
704
705    /// Every part of it answers to a theme, and to a caller over the theme.
706    #[test]
707    fn a_theme_answers_for_every_button() {
708        let mut theme = Theme::default();
709        theme.widgets.fab.background = Some(Color::rgb(0.2, 0.4, 0.6));
710        theme.widgets.fab.foreground = Some(Color::rgb(0.9, 0.9, 0.9));
711        theme.widgets.fab.radius = Some(3.0);
712        theme.widgets.fab.icon_size = Some(11.0);
713
714        let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
715        let (rects, _) = painted(&fab, &theme, Status::default());
716        let surface = rects.last().unwrap();
717        assert_eq!(surface.1, Color::rgb(0.2, 0.4, 0.6));
718        assert_eq!(surface.2, BorderRadius::uniform(3.0));
719
720        let told = FloatingActionButton::new(Icons::ADD)
721            .on_press(Msg::Add)
722            .background(Color::rgb(0.1, 0.1, 0.1))
723            .radius(9.0);
724        let (rects, _) = painted(&told, &theme, Status::default());
725        let surface = rects.last().unwrap();
726        assert_eq!(surface.1, Color::rgb(0.1, 0.1, 0.1));
727        assert_eq!(surface.2, BorderRadius::uniform(9.0));
728    }
729
730    /// **A character is not an extended button.** `fab_button` hands the scaffold a
731    /// plus, a tick or an arrow — one mark, in a square, centred like a glyph — and the
732    /// widget has to be able to say that without growing to fit the words it does not
733    /// have. `IconButton::glyph` is the same escape hatch for the same reason.
734    #[test]
735    fn a_character_sits_in_the_square_like_a_glyph() {
736        let theme = Theme::default();
737        let plus = FloatingActionButton::<Msg>::glyph("+");
738        let style = Widget::<Msg>::style_themed(&plus, &theme);
739        assert_eq!(
740            style.width,
741            Dimension::Length(56.0),
742            "a square, not a lozenge"
743        );
744        assert_eq!(style.height, Dimension::Length(56.0));
745
746        // And the scaffold's shorthand is that, rounded to fit the notch a bottom bar
747        // cuts for it — which is the one place the reference's own corner is wrong here.
748        let docked = crate::fab_button::<Msg>("+", Msg::Add);
749        let (rects, _) = painted(&docked, &theme, Status::default());
750        assert_eq!(
751            rects.last().unwrap().2,
752            BorderRadius::uniform(28.0),
753            "round, for a round notch"
754        );
755        assert_eq!(
756            rects.last().unwrap().1,
757            theme.scheme.primary_container,
758            "and it takes the button's colours now, not a filled button's"
759        );
760    }
761
762    /// **A glyph is not a name.** An extended button reads out its words; a plain one has
763    /// nothing to read, so it says so by having no label rather than by inventing one —
764    /// and a caller who wants it spoken wraps it in a
765    /// [`Tooltip`](crate::Tooltip), which is what the reference's own `tooltip` argument
766    /// is for.
767    #[test]
768    fn an_extended_button_has_something_to_read_out() {
769        let words = FloatingActionButton::extended("New list").on_press(Msg::Add);
770        let semantics = Widget::<Msg>::semantics(&words).expect("a button");
771        assert_eq!(semantics.label.as_deref(), Some("New list"));
772        assert!(semantics.clickable);
773
774        let glyph = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
775        assert_eq!(Widget::<Msg>::semantics(&glyph).unwrap().label, None);
776    }
777
778    /// **Each height is resolved on its own** (the reference's build, lines 512 and 516).
779    /// The hovered one was the default's eight or the resting height if that was higher,
780    /// with no caller's word for it: a button told `elevation(0.0)` rose under a pointer and
781    /// could not be told to stay down, and one told twelve stayed at twelve. The reference's
782    /// rises to eight from nothing, comes down to eight from twelve, and takes a hovered
783    /// height of its own.
784    #[test]
785    fn the_hovered_height_is_resolved_on_its_own() {
786        let theme = Theme::default();
787        let hovered = Status {
788            hover_progress: 1.0,
789            ..Default::default()
790        };
791        // The shadow's width, when there is one: the box grown by the blur either side,
792        // and the blur grows with the height.
793        let shadow = |fab: FloatingActionButton<Msg>, theme: &Theme, status: Status| {
794            let (rects, _) = painted(&fab.on_press(Msg::Add), theme, status);
795            (rects.len() == 4).then(|| rects[2].0.width)
796        };
797        let fab = || FloatingActionButton::new(Icons::ADD);
798        let eight = shadow(fab(), &theme, hovered).expect("a hovered button floats");
799
800        assert_eq!(
801            shadow(fab().elevation(0.0), &theme, Status::default()),
802            None
803        );
804        assert_eq!(
805            shadow(fab().elevation(0.0), &theme, hovered),
806            Some(eight),
807            "flat at rest still rises to eight, as the reference's does"
808        );
809        assert_eq!(
810            shadow(fab().elevation(0.0).hover_elevation(0.0), &theme, hovered),
811            None,
812            "and one told both stays down"
813        );
814        let twelve = shadow(fab().elevation(12.0), &theme, Status::default()).unwrap();
815        assert_eq!(
816            shadow(fab().elevation(12.0), &theme, hovered),
817            Some(eight),
818            "a hovered height is not the resting one's floor"
819        );
820        assert!(twelve > eight);
821
822        let mut themed = Theme::default();
823        themed.widgets.fab.hover_elevation = Some(0.0);
824        assert_eq!(shadow(fab(), &themed, hovered), None, "the theme's word");
825        assert_eq!(
826            shadow(fab().hover_elevation(8.0), &themed, hovered),
827            Some(eight),
828            "and the caller's over it"
829        );
830    }
831}