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            let blur = depth * 4.0 + 8.0;
398            scene.shadow(
399                Rect::new(
400                    bounds.x - blur,
401                    bounds.y + depth * 2.0 - blur,
402                    bounds.width + 2.0 * blur,
403                    bounds.height + 2.0 * blur,
404                ),
405                theme.scheme.shadow.with_alpha(0.30).fade(o),
406                radius.inflate(blur),
407                blur,
408            );
409        }
410        // The state layer sits **on** the surface, in the content's colour, which is what
411        // keeps a hovered button from looking like a differently-coloured one.
412        let surface = theme.state_layer(fill, ink, &status);
413        scene.draw_shape(bounds, shape, surface.fade(o));
414
415        let glyph = self.glyph_size(theme);
416        let cy = bounds.y + (bounds.height - glyph) * 0.5;
417        match (&self.label, self.icon) {
418            // Extended: the glyph, then the words, both against the leading edge.
419            (Some(label), icon) if self.size == FabSize::Extended => {
420                let mut x = bounds.x + self.lead_pad(theme);
421                if let Some(name) = icon {
422                    let path = name.placed(glyph, x, cy, theme.direction);
423                    scene.fill_path(&path, ink.fade(o));
424                    x += glyph + theme.widgets.fab.extended_gap.unwrap_or(EXTENDED_GAP);
425                }
426                let style = self.text_style(theme);
427                let measured = frus_text::measure_resolved(label, &style);
428                scene.text(
429                    Point::new(x, bounds.y + (bounds.height - measured.height) * 0.5),
430                    label.clone(),
431                    &style,
432                    ink.fade(o),
433                );
434            }
435            // Otherwise: whatever it carries, centred in the square. An icon wins over a
436            // character, since a caller who gave both meant the drawn one.
437            (_, Some(name)) => {
438                let path = name.placed(
439                    glyph,
440                    bounds.x + (bounds.width - glyph) * 0.5,
441                    cy,
442                    theme.direction,
443                );
444                scene.fill_path(&path, ink.fade(o));
445            }
446            (Some(text), None) => {
447                let style = self.text_style(theme);
448                let measured = frus_text::measure_resolved(text, &style);
449                scene.text(
450                    Point::new(
451                        bounds.x + (bounds.width - measured.width) * 0.5,
452                        bounds.y + (bounds.height - measured.height) * 0.5,
453                    ),
454                    text.clone(),
455                    &style,
456                    ink.fade(o),
457                );
458            }
459            (None, None) => {}
460        }
461    }
462
463    fn on_click(&self) -> Option<Msg> {
464        self.enabled.then(|| self.on_press.clone()).flatten()
465    }
466
467    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
468        // Only where there is something to press. Ink on a disabled or inert button
469        // promises an action it does not have.
470        if !self.enabled {
471            return None;
472        }
473        self.on_press.as_ref()?;
474        let radius = self
475            .shape_of(theme)
476            .as_rounded(Rect::new(0.0, 0.0, self.width(theme), self.size.extent()))
477            .map(|(_, r)| r)
478            .unwrap_or(BorderRadius::ZERO);
479        Some(crate::InkStyle::of(theme).radius(radius))
480    }
481
482    fn focusable(&self) -> bool {
483        self.enabled && self.on_press.is_some()
484    }
485
486    /// It says what it does. An extended button has words to read out; a plain one has
487    /// only a glyph, and a glyph is not a name — so the label is empty and the caller has
488    /// to wrap it in a [`Tooltip`](crate::Tooltip), which is what the reference's own
489    /// `tooltip` argument is for.
490    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
491        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
492        let semantics = match &self.label {
493            Some(label) => semantics.label(label.clone()),
494            None => semantics,
495        };
496        Some(if self.enabled && self.on_press.is_some() {
497            semantics.clickable()
498        } else {
499            semantics.disabled(!self.enabled)
500        })
501    }
502}
503
504#[cfg(test)]
505mod tests {
506    use super::*;
507    use crate::icons::Icons;
508    use frus_core::Primitive;
509
510    #[derive(Clone, Debug, PartialEq)]
511    enum Msg {
512        Add,
513    }
514
515    fn painted(
516        fab: &FloatingActionButton<Msg>,
517        theme: &Theme,
518        status: Status,
519    ) -> (Vec<(Rect, Color, BorderRadius)>, usize) {
520        let mut scene = Scene::new();
521        let style = Widget::<Msg>::style_themed(fab, theme);
522        let (Dimension::Length(w), Dimension::Length(h)) = (style.width, style.height) else {
523            panic!("a measured box");
524        };
525        Widget::<Msg>::paint(fab, Rect::new(0.0, 0.0, w, h), status, theme, &mut scene);
526        let rects = scene
527            .primitives()
528            .iter()
529            .filter_map(|p| match p {
530                Primitive::Rect {
531                    rect,
532                    color,
533                    radius,
534                    ..
535                } => Some((*rect, *color, *radius)),
536                _ => None,
537            })
538            .collect();
539        let paths = scene
540            .primitives()
541            .iter()
542            .filter(|p| matches!(p, Primitive::Path { .. }))
543            .count();
544        (rects, paths)
545    }
546
547    /// **The colours were two roles out.** `fab_button` returned a filled
548    /// [`Button`](crate::Button), so the scaffold's floating action button took `primary`
549    /// on `on_primary` where the reference's takes `primary_container` on
550    /// `on_primary_container` (`floating_action_button.dart:809`) — on the most prominent
551    /// control on the screen.
552    #[test]
553    fn a_button_takes_the_container_roles_and_not_the_accent() {
554        let theme = Theme::default();
555        let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
556        let (rects, paths) = painted(&fab, &theme, Status::default());
557        assert_eq!(
558            rects.last().expect("a surface").1,
559            theme.scheme.primary_container,
560            "the container, not the accent"
561        );
562        assert_ne!(
563            theme.scheme.primary_container, theme.primary,
564            "and they differ"
565        );
566        assert_eq!(paths, 1, "the glyph is drawn");
567    }
568
569    /// **The four sizes are one decision, not three.** Every one of the box, the corner
570    /// and the glyph follows from *which* of the four it is
571    /// (`floating_action_button.dart:783`, `:816`, `:824`), so they travel together and a
572    /// caller cannot build a large button with a small one's corner.
573    #[test]
574    fn the_four_sizes_carry_their_own_box_corner_and_glyph() {
575        let theme = Theme::default();
576        for (size, extent, radius, icon) in [
577            (FabSize::Small, 40.0, 12.0, 24.0),
578            (FabSize::Regular, 56.0, 16.0, 24.0),
579            (FabSize::Large, 96.0, 28.0, 36.0),
580        ] {
581            assert_eq!(size.extent(), extent);
582            assert_eq!(size.radius(), radius);
583            assert_eq!(size.icon_size(), icon);
584
585            let fab = FloatingActionButton::<Msg>::new(Icons::ADD).size(size);
586            let style = Widget::<Msg>::style_themed(&fab, &theme);
587            assert_eq!(style.width, Dimension::Length(extent));
588            assert_eq!(style.height, Dimension::Length(extent));
589            let (rects, _) = painted(&fab, &theme, Status::default());
590            assert_eq!(
591                rects.last().expect("a surface").2,
592                BorderRadius::uniform(radius),
593                "{size:?} takes its own corner"
594            );
595        }
596    }
597
598    /// **It is not a circle**, which is what `fab_button` drew. The reference rounds a
599    /// regular button by sixteen and a small one by twelve
600    /// (`floating_action_button.dart:816`); a stadium takes half the short side, so both
601    /// were pills. A caller who *wants* the pill — a docked button, in a round notch —
602    /// says so.
603    #[test]
604    fn it_is_a_rounded_square_and_a_stadium_when_asked() {
605        let theme = Theme::default();
606        let plain = FloatingActionButton::<Msg>::new(Icons::ADD);
607        assert_eq!(
608            painted(&plain, &theme, Status::default())
609                .0
610                .last()
611                .unwrap()
612                .2,
613            BorderRadius::uniform(16.0)
614        );
615        let docked = FloatingActionButton::<Msg>::new(Icons::ADD).shape(ShapeBorder::stadium());
616        assert_eq!(
617            painted(&docked, &theme, Status::default())
618                .0
619                .last()
620                .unwrap()
621                .2,
622            BorderRadius::uniform(28.0),
623            "half the short side"
624        );
625    }
626
627    /// **An extended button is as wide as its words**, with the reference's asymmetric
628    /// room: sixteen before a glyph, twenty before words standing alone, twenty after
629    /// either (`floating_action_button.dart:831`).
630    #[test]
631    fn an_extended_button_is_as_wide_as_what_it_carries() {
632        let theme = Theme::default();
633        let words = FloatingActionButton::<Msg>::extended("New list");
634        let with_icon = FloatingActionButton::<Msg>::extended("New list").icon(Icons::ADD);
635
636        let width = |fab: &FloatingActionButton<Msg>| match Widget::<Msg>::style_themed(fab, &theme)
637            .width
638        {
639            Dimension::Length(w) => w,
640            other => panic!("a measured width, not {other:?}"),
641        };
642        let text =
643            frus_text::measure_resolved("New list", &theme.text.label_large.resolved()).width;
644        assert_eq!(width(&words), EXTENDED_PAD + text + EXTENDED_PAD);
645        assert_eq!(
646            width(&with_icon),
647            EXTENDED_PAD_WITH_ICON + 24.0 + EXTENDED_GAP + text + EXTENDED_PAD,
648            "a glyph shortens the leading room, as the reference's does"
649        );
650        assert_eq!(
651            Widget::<Msg>::style_themed(&words, &theme).height,
652            Dimension::Length(56.0),
653            "and it is still a regular button's height"
654        );
655
656        let (_, paths) = painted(&with_icon, &theme, Status::default());
657        assert_eq!(paths, 1, "the glyph before the words");
658        assert_eq!(painted(&words, &theme, Status::default()).1, 0);
659    }
660
661    /// **A disabled button casts no shadow.** The reference's `disabledElevation` is
662    /// zero, and a control still floating while refusing to be pressed is telling two
663    /// different stories at once. It also stops answering, stops taking focus and stops
664    /// splashing.
665    #[test]
666    fn a_disabled_button_stops_floating() {
667        let theme = Theme::default();
668        let live = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
669        let (rects, _) = painted(&live, &theme, Status::default());
670        assert_eq!(rects.len(), 2, "a shadow under the surface: {rects:#?}");
671
672        let dead = FloatingActionButton::new(Icons::ADD)
673            .on_press(Msg::Add)
674            .enabled(false);
675        let (rects, _) = painted(&dead, &theme, Status::default());
676        assert_eq!(rects.len(), 1, "the surface alone");
677        assert_eq!(rects[0].1, disabled_container(&theme));
678        assert_eq!(Widget::<Msg>::on_click(&dead), None);
679        assert!(!Widget::<Msg>::focusable(&dead));
680        assert!(Widget::<Msg>::ink(&dead, &theme).is_none());
681
682        // And one with nothing to say does not float either: it is inert, not disabled,
683        // so it keeps its live colours and loses its shadow.
684        let inert = FloatingActionButton::<Msg>::new(Icons::ADD);
685        let (rects, _) = painted(&inert, &theme, Status::default());
686        assert_eq!(rects.len(), 1);
687        assert_eq!(rects[0].1, theme.scheme.primary_container);
688    }
689
690    /// **It rises under a pointer**, six to eight (`floating_action_button.dart:778`),
691    /// which is the whole of what a floating button's hover state is.
692    #[test]
693    fn it_rises_under_a_pointer() {
694        let theme = Theme::default();
695        let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
696        let shadow_of = |hover: f32| {
697            let status = Status {
698                hover_progress: hover,
699                ..Default::default()
700            };
701            painted(&fab, &theme, status).0[0].0.width
702        };
703        assert!(
704            shadow_of(1.0) > shadow_of(0.0),
705            "a wider shadow is a higher button"
706        );
707    }
708
709    /// Every part of it answers to a theme, and to a caller over the theme.
710    #[test]
711    fn a_theme_answers_for_every_button() {
712        let mut theme = Theme::default();
713        theme.widgets.fab.background = Some(Color::rgb(0.2, 0.4, 0.6));
714        theme.widgets.fab.foreground = Some(Color::rgb(0.9, 0.9, 0.9));
715        theme.widgets.fab.radius = Some(3.0);
716        theme.widgets.fab.icon_size = Some(11.0);
717
718        let fab = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
719        let (rects, _) = painted(&fab, &theme, Status::default());
720        let surface = rects.last().unwrap();
721        assert_eq!(surface.1, Color::rgb(0.2, 0.4, 0.6));
722        assert_eq!(surface.2, BorderRadius::uniform(3.0));
723
724        let told = FloatingActionButton::new(Icons::ADD)
725            .on_press(Msg::Add)
726            .background(Color::rgb(0.1, 0.1, 0.1))
727            .radius(9.0);
728        let (rects, _) = painted(&told, &theme, Status::default());
729        let surface = rects.last().unwrap();
730        assert_eq!(surface.1, Color::rgb(0.1, 0.1, 0.1));
731        assert_eq!(surface.2, BorderRadius::uniform(9.0));
732    }
733
734    /// **A character is not an extended button.** `fab_button` hands the scaffold a
735    /// plus, a tick or an arrow — one mark, in a square, centred like a glyph — and the
736    /// widget has to be able to say that without growing to fit the words it does not
737    /// have. `IconButton::glyph` is the same escape hatch for the same reason.
738    #[test]
739    fn a_character_sits_in_the_square_like_a_glyph() {
740        let theme = Theme::default();
741        let plus = FloatingActionButton::<Msg>::glyph("+");
742        let style = Widget::<Msg>::style_themed(&plus, &theme);
743        assert_eq!(
744            style.width,
745            Dimension::Length(56.0),
746            "a square, not a lozenge"
747        );
748        assert_eq!(style.height, Dimension::Length(56.0));
749
750        // And the scaffold's shorthand is that, rounded to fit the notch a bottom bar
751        // cuts for it — which is the one place the reference's own corner is wrong here.
752        let docked = crate::fab_button::<Msg>("+", Msg::Add);
753        let (rects, _) = painted(&docked, &theme, Status::default());
754        assert_eq!(
755            rects.last().unwrap().2,
756            BorderRadius::uniform(28.0),
757            "round, for a round notch"
758        );
759        assert_eq!(
760            rects.last().unwrap().1,
761            theme.scheme.primary_container,
762            "and it takes the button's colours now, not a filled button's"
763        );
764    }
765
766    /// **A glyph is not a name.** An extended button reads out its words; a plain one has
767    /// nothing to read, so it says so by having no label rather than by inventing one —
768    /// and a caller who wants it spoken wraps it in a
769    /// [`Tooltip`](crate::Tooltip), which is what the reference's own `tooltip` argument
770    /// is for.
771    #[test]
772    fn an_extended_button_has_something_to_read_out() {
773        let words = FloatingActionButton::extended("New list").on_press(Msg::Add);
774        let semantics = Widget::<Msg>::semantics(&words).expect("a button");
775        assert_eq!(semantics.label.as_deref(), Some("New list"));
776        assert!(semantics.clickable);
777
778        let glyph = FloatingActionButton::new(Icons::ADD).on_press(Msg::Add);
779        assert_eq!(Widget::<Msg>::semantics(&glyph).unwrap().label, None);
780    }
781
782    /// **Each height is resolved on its own** (the reference's build, lines 512 and 516).
783    /// The hovered one was the default's eight or the resting height if that was higher,
784    /// with no caller's word for it: a button told `elevation(0.0)` rose under a pointer and
785    /// could not be told to stay down, and one told twelve stayed at twelve. The reference's
786    /// rises to eight from nothing, comes down to eight from twelve, and takes a hovered
787    /// height of its own.
788    #[test]
789    fn the_hovered_height_is_resolved_on_its_own() {
790        let theme = Theme::default();
791        let hovered = Status {
792            hover_progress: 1.0,
793            ..Default::default()
794        };
795        // The shadow's width, when there is one: the box grown by the blur either side,
796        // and the blur grows with the height.
797        let shadow = |fab: FloatingActionButton<Msg>, theme: &Theme, status: Status| {
798            let (rects, _) = painted(&fab.on_press(Msg::Add), theme, status);
799            (rects.len() == 2).then(|| rects[0].0.width)
800        };
801        let fab = || FloatingActionButton::new(Icons::ADD);
802        let eight = shadow(fab(), &theme, hovered).expect("a hovered button floats");
803
804        assert_eq!(
805            shadow(fab().elevation(0.0), &theme, Status::default()),
806            None
807        );
808        assert_eq!(
809            shadow(fab().elevation(0.0), &theme, hovered),
810            Some(eight),
811            "flat at rest still rises to eight, as the reference's does"
812        );
813        assert_eq!(
814            shadow(fab().elevation(0.0).hover_elevation(0.0), &theme, hovered),
815            None,
816            "and one told both stays down"
817        );
818        let twelve = shadow(fab().elevation(12.0), &theme, Status::default()).unwrap();
819        assert_eq!(
820            shadow(fab().elevation(12.0), &theme, hovered),
821            Some(eight),
822            "a hovered height is not the resting one's floor"
823        );
824        assert!(twelve > eight);
825
826        let mut themed = Theme::default();
827        themed.widgets.fab.hover_elevation = Some(0.0);
828        assert_eq!(shadow(fab(), &themed, hovered), None, "the theme's word");
829        assert_eq!(
830            shadow(fab().hover_elevation(8.0), &themed, hovered),
831            Some(eight),
832            "and the caller's over it"
833        );
834    }
835}