Skip to main content

frus_widgets/
button.rs

1//! [`Button`]: the reference's five buttons, in one widget with a variant.
2//!
3//! They are one box with one label and differ in **emphasis**: filled for the action a
4//! screen is about, tonal for the one beside it, elevated where the surface underneath is
5//! busy, outlined for a secondary action, and text for the least of all. Everything else —
6//! the 40 px height, the 64 px minimum width, the stadium shape, the `label_large` label,
7//! the 24 px of room either side — is shared, which is why they are a variant here and not
8//! five types.
9//!
10//! ```ignore
11//! Button::new("Save").on_press(Msg::Save)                              // filled
12//! Button::new("Cancel").variant(Variant::Text).on_press(Msg::Cancel)  // text
13//! Button::new("Delete").variant(Variant::Danger).on_press(Msg::Delete) // the error role
14//! ```
15//!
16//! Every measurement and colour is overridable, per call or through
17//! [`ButtonTheme`](crate::ButtonTheme).
18
19use frus_core::{BorderRadius, Color, Point, Rect, Scene, ShapeBorder, TextStyle};
20use frus_layout::{Dimension, Style};
21
22use crate::disabled::{disabled_container, disabled_content};
23use crate::interaction::Status;
24use crate::theme::Theme;
25use crate::widget::Widget;
26
27/// A button's height, and the smallest it will be.
28pub const BUTTON_HEIGHT: f32 = 40.0;
29/// The narrowest a button gets, however short its label.
30pub const BUTTON_MIN_WIDTH: f32 = 64.0;
31/// The room either side of the label.
32pub const BUTTON_PADDING: f32 = 24.0;
33/// The room either side of a **text** button's label, which has no box to fill.
34pub const BUTTON_TEXT_PADDING: f32 = 12.0;
35/// How far an elevated button sits off the surface **at rest** — see [`Button::elevation`]
36/// for the other states.
37pub const BUTTON_ELEVATION: f32 = 1.0;
38/// How far an elevated button rises under a pointer (the reference's elevated button
39/// defaults, line 579).
40const ELEVATED_HOVER_ELEVATION: f32 = 3.0;
41/// How far a filled or tonal button rises under a pointer, from flat (the reference's
42/// filled button defaults, lines 597 and 738).
43const FILLED_HOVER_ELEVATION: f32 = 1.0;
44/// An outlined button's outline.
45pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
46
47/// How much of a screen's attention a button is asking for.
48#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
49pub enum Variant {
50    /// The accent, filled: the one action a screen is about.
51    #[default]
52    Filled,
53    /// A tonal fill — beside a filled button, not competing with it.
54    Tonal,
55    /// A raised surface with a shadow, for a button over busy content.
56    Elevated,
57    /// An outline and no fill.
58    Outlined,
59    /// A label alone.
60    Text,
61    /// Filled in the **error** role — a destructive action.
62    ///
63    /// Not one of the reference's five: there, a destructive button is a filled button
64    /// given the error colours by hand. It is here because saying *this action destroys
65    /// something* is worth a name, and because the alternative is every application
66    /// writing the same two colour overrides.
67    Danger,
68}
69
70impl Variant {
71    /// How far the variant sits off the surface in each state it can be in while enabled.
72    ///
73    /// The reference's, variant by variant: an elevated button rests at 1, rises to 3 under
74    /// a pointer and is back at 1 focused or pressed (its elevated button defaults, lines
75    /// 570–585); a filled or a tonal one is flat except under a pointer, where it rises to 1
76    /// (its filled button defaults, lines 588–603 and 729–744) — and so is a danger one,
77    /// being a filled button in the error colours; an outlined or a text button never
78    /// leaves the surface.
79    const fn heights(self) -> Heights {
80        match self {
81            Variant::Elevated => Heights {
82                rest: BUTTON_ELEVATION,
83                hovered: ELEVATED_HOVER_ELEVATION,
84                focused: BUTTON_ELEVATION,
85                pressed: BUTTON_ELEVATION,
86            },
87            Variant::Filled | Variant::Tonal | Variant::Danger => Heights {
88                rest: 0.0,
89                hovered: FILLED_HOVER_ELEVATION,
90                focused: 0.0,
91                pressed: 0.0,
92            },
93            Variant::Outlined | Variant::Text => Heights::all(0.0),
94        }
95    }
96
97    /// The room either side of the label.
98    const fn padding(self) -> f32 {
99        match self {
100            Variant::Text => BUTTON_TEXT_PADDING,
101            _ => BUTTON_PADDING,
102        }
103    }
104}
105
106/// A button's height in each state it can be in while enabled. Disabled, every button is
107/// flat, and that is decided before these are read.
108#[derive(Clone, Copy, Debug, PartialEq)]
109struct Heights {
110    rest: f32,
111    hovered: f32,
112    focused: f32,
113    pressed: f32,
114}
115
116impl Heights {
117    const fn all(height: f32) -> Self {
118        Self {
119            rest: height,
120            hovered: height,
121            focused: height,
122            pressed: height,
123        }
124    }
125
126    /// The height this frame.
127    ///
128    /// From rest towards the focused height, then the hovered one, then the pressed one,
129    /// each by its own progress. So a press outranks a hover and a hover outranks a focus,
130    /// which is the order the reference resolves the states in, and every change is exactly
131    /// as gradual as the state layer painted from the same status: the reference animates a
132    /// height it resolved outright, and this one is continuous by construction.
133    fn at(self, status: &Status) -> f32 {
134        let toward =
135            |from: f32, to: f32, progress: f32| from + (to - from) * progress.clamp(0.0, 1.0);
136        let height = toward(self.rest, self.focused, status.focus_progress);
137        let height = toward(height, self.hovered, status.hover_progress);
138        toward(height, self.pressed, status.press_progress)
139    }
140}
141
142/// A clickable button.
143pub struct Button<Msg = crate::callback::Callback> {
144    label: String,
145    variant: Variant,
146    on_press: Option<Msg>,
147    /// Enabled? Disabled (`false`): greyed out, no shadow, no click, no focus.
148    enabled: bool,
149    label_style: Option<TextStyle>,
150    color: Option<Color>,
151    label_color: Option<Color>,
152    border_color: Option<Color>,
153    border_width: Option<f32>,
154    shape: Option<ShapeBorder>,
155    padding: Option<f32>,
156    height: Option<f32>,
157    min_width: Option<f32>,
158    elevation: Option<f32>,
159}
160
161impl<Msg> Button<Msg> {
162    /// Creates a button with a label.
163    pub fn new(label: impl Into<String>) -> Self {
164        Self {
165            label: label.into(),
166            variant: Variant::default(),
167            on_press: None,
168            enabled: true,
169            label_style: None,
170            color: None,
171            label_color: None,
172            border_color: None,
173            border_width: None,
174            shape: None,
175            padding: None,
176            height: None,
177            min_width: None,
178            elevation: None,
179        }
180    }
181
182    /// Enables or **disables** the button: disabled, it is greyed out, has no shadow
183    /// and emits nothing at all (neither click nor keyboard focus) — the rendering of
184    /// an unavailable control, e.g. "Next" while a step is invalid.
185    pub fn enabled(mut self, enabled: bool) -> Self {
186        self.enabled = enabled;
187        self
188    }
189
190    /// Overrides the corner radii (uniform via `f32`, per corner via [`BorderRadius`] —
191    /// connected segments, button groups…). Defaults to a **stadium**: the radius is half
192    /// the button's height, whatever that height turns out to be.
193    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
194        self.shape = Some(ShapeBorder::rounded(radius));
195        self
196    }
197
198    /// **What shape it is** (`shape_border.dart`), over the corners named above. The last
199    /// of the two to be called is the one that counts — they are one property, and
200    /// [`radius`](Self::radius) is the shorthand for the common case.
201    #[must_use]
202    pub fn shape(mut self, shape: ShapeBorder) -> Self {
203        self.shape = Some(shape);
204        self
205    }
206
207    /// Chooses the visual variant.
208    pub fn variant(mut self, variant: Variant) -> Self {
209        self.variant = variant;
210        self
211    }
212
213    /// The label's type. Defaults to the theme's `label_large` step.
214    pub fn label_style(mut self, style: TextStyle) -> Self {
215        self.label_style = Some(style);
216        self
217    }
218
219    /// Font size — sugar for [`Button::label_style`] with the label step resized.
220    pub fn size(mut self, size: f32) -> Self {
221        let mut style = self.label_style.unwrap_or(TextStyle::new(size));
222        style.size = Some(size);
223        self.label_style = Some(style);
224        self
225    }
226
227    /// The surface under the label.
228    pub fn color(mut self, color: Color) -> Self {
229        self.color = Some(color);
230        self
231    }
232
233    /// The label's colour.
234    pub fn label_color(mut self, color: Color) -> Self {
235        self.label_color = Some(color);
236        self
237    }
238
239    /// The outline's colour.
240    pub fn border_color(mut self, color: Color) -> Self {
241        self.border_color = Some(color);
242        self
243    }
244
245    /// The outline's thickness; `0.0` removes it.
246    pub fn border_width(mut self, width: f32) -> Self {
247        self.border_width = Some(width);
248        self
249    }
250
251    /// The room either side of the label.
252    pub fn padding(mut self, padding: f32) -> Self {
253        self.padding = Some(padding);
254        self
255    }
256
257    /// The button's height.
258    pub fn height(mut self, height: f32) -> Self {
259        self.height = Some(height);
260        self
261    }
262
263    /// The narrowest it will be, however short its label.
264    pub fn min_width(mut self, width: f32) -> Self {
265        self.min_width = Some(width);
266        self
267    }
268
269    /// How far it sits off the surface, **in every state**. `0.0` is flat whatever the
270    /// pointer does.
271    ///
272    /// Unset, the variant's own heights, and they move with the state as the reference's
273    /// do: an elevated button rests at 1, rises to 3 under a pointer and is back at 1 while
274    /// pressed; a filled, tonal or danger button is flat and rises to 1 under a pointer; an
275    /// outlined or a text button stays flat. A disabled button is flat either way.
276    pub fn elevation(mut self, elevation: f32) -> Self {
277        self.elevation = Some(elevation);
278        self
279    }
280
281    /// Message emitted on click.
282    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
283        self.on_press = Some(message.into());
284        self
285    }
286
287    fn label_style_of(&self, theme: &Theme) -> TextStyle {
288        self.label_style
289            .or(theme.widgets.button.label_style)
290            .unwrap_or(theme.text.label_large)
291    }
292
293    fn padding_of(&self, theme: &Theme) -> f32 {
294        self.padding
295            .or(theme.widgets.button.padding)
296            .unwrap_or(self.variant.padding())
297    }
298
299    fn height_of(&self, theme: &Theme) -> f32 {
300        self.height
301            .or(theme.widgets.button.height)
302            .unwrap_or(BUTTON_HEIGHT)
303    }
304
305    fn min_width_of(&self, theme: &Theme) -> f32 {
306        self.min_width
307            .or(theme.widgets.button.min_width)
308            .unwrap_or(BUTTON_MIN_WIDTH)
309    }
310
311    /// The height this frame: nought while disabled, the caller's or the theme's in every
312    /// state, and otherwise the variant's own for the state the button is in.
313    ///
314    /// It was one number whatever the state, so an elevated button did not rise under a
315    /// pointer and a filled one never left the surface.
316    fn elevation_of(&self, theme: &Theme, status: &Status) -> f32 {
317        if !self.enabled {
318            return 0.0;
319        }
320        match self.elevation.or(theme.widgets.button.elevation) {
321            Some(height) => height,
322            None => self.variant.heights().at(status),
323        }
324    }
325
326    /// **What shape this button is**: its own word, then the theme's shape, then the
327    /// theme's plain radius, then a **stadium** — which is what the reference's buttons
328    /// are (`button_style.dart`).
329    ///
330    /// It used to work the stadium out here as `height / 2`, which is the right number
331    /// for a button wider than it is tall and the wrong one for a button that is not:
332    /// a stadium takes half its **short** side. Saying the word instead of the number
333    /// gets that right at every size, and lets a caller say `circle` or `beveled`.
334    fn shape_of(&self, theme: &Theme) -> ShapeBorder {
335        crate::resolve_shape(
336            self.shape,
337            theme.widgets.button.shape,
338            theme.widgets.button.radius,
339            ShapeBorder::stadium(),
340        )
341    }
342
343    /// `(background, label, outline)` for the variant and the theme, disabled included.
344    ///
345    /// A disabled button loses its accent in every variant — the label goes to `on_surface`
346    /// at 38 % throughout, so unavailable reads as unavailable rather than as a quieter
347    /// version of the variant. What it does **not** do is give every variant a container.
348    ///
349    /// Until milestone 324 it did, and the reference is explicit that it should not: a text
350    /// button's background is `transparent` in every state, and an outlined one's disabled
351    /// state keeps its outline at 12 % rather than trading it for a fill. Flattening all six
352    /// to the same grey pill destroyed any selection a group of buttons was carrying — a
353    /// disabled page strip showed six identical pills with no current page — which is the
354    /// same mistake as fading a selected chip's accent, arrived at from the other side.
355    fn palette(&self, theme: &Theme) -> (Color, Color, Option<Color>) {
356        if !self.enabled {
357            let label = disabled_content(theme);
358            return match self.variant {
359                // No container, disabled or not.
360                Variant::Text => (Color::TRANSPARENT, label, None),
361                // The outline *is* the container here, so it takes the container opacity.
362                Variant::Outlined => (Color::TRANSPARENT, label, Some(disabled_container(theme))),
363                // The variants that genuinely are a filled container flatten to one.
364                _ => (disabled_container(theme), label, None),
365            };
366        }
367        let (background, label, outline) = match self.variant {
368            Variant::Filled => (theme.scheme.primary, theme.scheme.on_primary, None),
369            Variant::Tonal => (
370                theme.scheme.secondary_container,
371                theme.scheme.on_secondary_container,
372                None,
373            ),
374            // `elevated_button.dart:534` — off the page, on the low rung.
375            Variant::Elevated => (
376                theme.scheme.surface_container_low,
377                theme.scheme.primary,
378                None,
379            ),
380            Variant::Outlined => (
381                Color::TRANSPARENT,
382                theme.scheme.primary,
383                Some(theme.scheme.outline),
384            ),
385            Variant::Text => (Color::TRANSPARENT, theme.scheme.primary, None),
386            Variant::Danger => (theme.scheme.error, theme.scheme.on_error, None),
387        };
388        (
389            self.color
390                .or(theme.widgets.button.color)
391                .unwrap_or(background),
392            self.label_color
393                .or(theme.widgets.button.label_color)
394                .unwrap_or(label),
395            self.border_color
396                .or(theme.widgets.button.border_color)
397                .or(outline),
398        )
399    }
400}
401
402impl<Msg: Clone> Widget<Msg> for Button<Msg> {
403    fn style(&self) -> Style {
404        Widget::<Msg>::style_themed(self, &Theme::default())
405    }
406
407    fn style_themed(&self, theme: &Theme) -> Style {
408        let style = self.label_style_of(theme);
409        let measured = frus_text::measure_style(&self.label, style);
410        let width = (measured.width + self.padding_of(theme) * 2.0).max(self.min_width_of(theme));
411        Style {
412            width: Dimension::Length(width.ceil()),
413            height: Dimension::Length(self.height_of(theme).max(measured.height).ceil()),
414            ..Default::default()
415        }
416    }
417
418    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
419        &[]
420    }
421
422    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
423        let o = status.opacity;
424        let (base, on_color, border) = self.palette(theme);
425        let shape = self.shape_of(theme);
426        let radius = shape
427            .as_rounded(bounds)
428            .map(|(_, radius)| radius)
429            .unwrap_or(BorderRadius::ZERO);
430        let elevation = self.elevation_of(theme, &status);
431
432        // At rest the shadow belongs to **one** variant — every enabled button used to cast
433        // one, which is the reference's elevated button drawn five times over — and under a
434        // pointer to the filled three as well.
435        if elevation > 0.0 {
436            let blur = elevation * 4.0 + 8.0;
437            scene.shadow(
438                Rect::new(
439                    bounds.x - blur,
440                    bounds.y + elevation * 2.0 - blur,
441                    bounds.width + 2.0 * blur,
442                    bounds.height + 2.0 * blur,
443                ),
444                theme.scheme.shadow.with_alpha(0.35).fade(o),
445                radius.inflate(blur),
446                blur,
447            );
448        }
449
450        // Hover/press/focus through the theme's baked state layer. A disabled control has
451        // no states to layer, and a transparent surface must stay transparent at rest:
452        // the layer is what tints it under the pointer.
453        let color = if self.enabled {
454            theme.state_layer(base, on_color, &status)
455        } else {
456            base
457        };
458        let (border_width, border_color) = match (
459            self.border_width.or(theme.widgets.button.border_width),
460            border,
461        ) {
462            (Some(width), Some(color)) => (width, color),
463            (Some(width), None) => (width, theme.scheme.outline),
464            (None, Some(color)) => (BUTTON_BORDER_WIDTH, color),
465            (None, None) => (0.0, Color::TRANSPARENT),
466        };
467        scene.draw_shape(
468            bounds,
469            shape.with_side(frus_core::BorderSide::new(
470                border_color.fade(o),
471                border_width,
472            )),
473            color.fade(o),
474        );
475
476        // Centred, both ways: a label pinned to the padding drifts off centre the moment
477        // the button is given a width of its own.
478        let style = self.label_style_of(theme);
479        let resolved = style.resolved();
480        // **A box narrower than the label ellipsises it**, rather than painting the words
481        // straight out of the pill on both sides. A button asks for the width its label
482        // needs and almost always gets it, so this is the rare case — a layout that had to
483        // squeeze it, which is what a bar of actions folding into a column does to the
484        // longest answer at a reader's font size. What it must not do is come apart.
485        let room = bounds.width - self.padding_of(theme) * 2.0;
486        let mut label = self.label.clone();
487        let mut measured = frus_text::measure_style(&label, style);
488        if room > 0.0 && measured.width > room + 0.5 {
489            label = crate::text::ellipsise(&label, &resolved, room);
490            measured = frus_text::measure_resolved(&label, &resolved);
491        }
492        scene.text(
493            Point::new(
494                bounds.x + (bounds.width - measured.width) / 2.0,
495                bounds.y + (bounds.height - measured.height) / 2.0,
496            ),
497            label,
498            &resolved,
499            on_color.fade(o),
500        );
501    }
502
503    fn on_click(&self) -> Option<Msg> {
504        if self.enabled {
505            self.on_press.clone()
506        } else {
507            None
508        }
509    }
510
511    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
512        // A disabled control does not answer a tap, so it does not splash either.
513        if !self.enabled {
514            return None;
515        }
516        // The splash takes the button's **own** label colour: white-ish ink on a filled
517        // button, the accent on a text one. The theme's default (`on_surface`) would
518        // vanish on the first and shout on the second.
519        let (_, on_color, _) = self.palette(theme);
520        // Unless the application has named one: a theme that says what ink looks like has
521        // said it for every surface, not for the plain ones only.
522        let splash = theme
523            .widgets
524            .ink
525            .color
526            .unwrap_or_else(|| on_color.fade(0.16));
527        Some(
528            crate::InkStyle::of(theme)
529                .color(splash)
530                // The ink is clipped to the button, so it takes the corners the shape
531                // resolves to at the button's own height. A width is not known here —
532                // the ripple is placed before layout — so the height stands in, which
533                // is what a stadium's short side is for a button wider than it is tall.
534                .radius(
535                    self.shape_of(theme)
536                        .as_rounded(Rect::new(
537                            0.0,
538                            0.0,
539                            self.height_of(theme),
540                            self.height_of(theme),
541                        ))
542                        .map(|(_, radius)| radius)
543                        .unwrap_or(BorderRadius::ZERO),
544                ),
545        )
546    }
547
548    fn focusable(&self) -> bool {
549        self.enabled
550    }
551
552    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
553        let semantics =
554            frus_core::SemanticsProperties::new(frus_core::Role::Button).label(self.label.clone());
555        // A disabled button does not announce a clickable action.
556        Some(if self.enabled {
557            semantics.clickable()
558        } else {
559            semantics.disabled(true)
560        })
561    }
562}
563
564#[cfg(test)]
565mod tests {
566    use super::*;
567    use frus_core::Primitive;
568
569    #[derive(Clone, Debug, PartialEq)]
570    enum Msg {
571        Pressed,
572    }
573
574    /// What a button paints, in order: the shadow if it has one, then its box, then its
575    /// label.
576    fn painted(button: &Button<Msg>) -> Vec<Primitive> {
577        let mut scene = Scene::new();
578        Widget::<Msg>::paint(
579            button,
580            Rect::new(0.0, 0.0, 120.0, BUTTON_HEIGHT),
581            Status::default(),
582            &Theme::default(),
583            &mut scene,
584        );
585        scene.primitives().to_vec()
586    }
587
588    /// **A box narrower than the label ellipsises it.** A button asks for the width its
589    /// words need and nearly always gets it; when a layout has to squeeze one — a bar of
590    /// actions folding into a column, at a reader's font size — the label used to be
591    /// painted at its full width straight out of both ends of the pill.
592    #[test]
593    fn a_squeezed_label_is_cut_rather_than_painted_out_of_the_pill() {
594        let button = crate::dsl::button("Delete permanently", Msg::Pressed);
595        let drawn = |width: f32| {
596            let mut scene = Scene::new();
597            Widget::<Msg>::paint(
598                &button,
599                Rect::new(0.0, 0.0, width, BUTTON_HEIGHT),
600                Status::default(),
601                &Theme::default(),
602                &mut scene,
603            );
604            scene
605                .primitives()
606                .iter()
607                .find_map(|p| match p {
608                    Primitive::Text { text, position, .. } => Some((text.clone(), *position)),
609                    _ => None,
610                })
611                .expect("a label")
612        };
613        let (whole, _) = drawn(400.0);
614        assert_eq!(whole, "Delete permanently", "room enough: nothing is cut");
615        let (cut, at) = drawn(120.0);
616        assert!(cut.ends_with('…'), "cut short: {cut:?}");
617        assert!(at.x >= 0.0, "and it starts inside the pill: {at:?}");
618    }
619
620    /// **A button is a pill at any size** (`button_style.dart`), which it was not.
621    ///
622    /// The stadium used to be worked out here as `height / 2` — the right number for a
623    /// button wider than it is tall, and the wrong one for a button that is not: a
624    /// stadium takes half its **short** side. Saying the word instead of the number gets
625    /// it right at every size.
626    #[test]
627    fn a_button_is_a_pill_at_any_size() {
628        let corners = |w: f32, h: f32, button: &Button<Msg>| {
629            let mut scene = Scene::new();
630            Widget::<Msg>::paint(
631                button,
632                Rect::new(0.0, 0.0, w, h),
633                Status::default(),
634                &Theme::default(),
635                &mut scene,
636            );
637            // The first crisp rectangle is the box: the shadow before it is blurred, and
638            // a circle's box is **not** the one the button was given, so the search
639            // cannot be narrowed by width.
640            scene.primitives().iter().find_map(|p| match p {
641                Primitive::Rect {
642                    rect, radius, blur, ..
643                } if *blur == 0.0 => Some((*rect, *radius)),
644                _ => None,
645            })
646        };
647        let button = Button::new("OK").on_press(Msg::Pressed);
648
649        let (_, wide) = corners(120.0, 40.0, &button).expect("a box");
650        assert_eq!(
651            wide,
652            BorderRadius::uniform(20.0),
653            "half the height, being shorter"
654        );
655
656        let (_, narrow) = corners(24.0, 80.0, &button).expect("a box");
657        assert_eq!(
658            narrow,
659            BorderRadius::uniform(12.0),
660            "half the *width* here — the old `height / 2` said 40 and turned the box \
661             inside out"
662        );
663
664        // And a caller may say something else entirely.
665        let boxed = Button::new("OK")
666            .on_press(Msg::Pressed)
667            .shape(frus_core::ShapeBorder::rounded(4.0));
668        assert_eq!(
669            corners(120.0, 40.0, &boxed).unwrap().1,
670            BorderRadius::uniform(4.0)
671        );
672
673        // A circle takes a square out of the middle rather than filling the box.
674        let round = Button::new("OK")
675            .on_press(Msg::Pressed)
676            .shape(frus_core::ShapeBorder::circle());
677        let (box_, _) = corners(120.0, 40.0, &round).expect("a box");
678        assert_eq!(box_, Rect::new(40.0, 0.0, 40.0, 40.0));
679    }
680
681    fn surface(button: &Button<Msg>) -> (Color, BorderRadius, f32) {
682        painted(button)
683            .iter()
684            .find_map(|p| match p {
685                Primitive::Rect {
686                    color,
687                    radius,
688                    border_width,
689                    blur,
690                    ..
691                } if *blur == 0.0 => Some((*color, *radius, *border_width)),
692                _ => None,
693            })
694            .expect("a button paints its box")
695    }
696
697    #[test]
698    fn on_click_returns_message() {
699        let button = Button::new("OK").on_press(Msg::Pressed);
700        assert_eq!(Widget::on_click(&button), Some(Msg::Pressed));
701    }
702
703    #[test]
704    fn disabled_button_is_inert_and_unfocusable() {
705        let button = Button::new("Next").on_press(Msg::Pressed).enabled(false);
706        assert_eq!(Widget::on_click(&button), None, "disabled: no message");
707        assert!(
708            !Widget::<Msg>::focusable(&button),
709            "disabled: out of the tab order"
710        );
711        let semantics = Widget::<Msg>::semantics(&button).expect("semantics present");
712        assert!(!semantics.clickable, "disabled: not announced as clickable");
713        assert!(semantics.disabled, "and announced as unavailable");
714        let enabled = Button::new("Next").on_press(Msg::Pressed).enabled(true);
715        assert_eq!(Widget::on_click(&enabled), Some(Msg::Pressed));
716    }
717
718    #[test]
719    fn only_the_elevated_variant_casts_a_shadow() {
720        // Every enabled button used to cast one, which is the reference's *elevated*
721        // button drawn five times over — and an emphasis order in which nothing is quiet.
722        let shadows = |variant: Variant| {
723            painted(&Button::<Msg>::new("Go").variant(variant))
724                .iter()
725                .filter(|p| matches!(p, Primitive::Rect { blur, .. } if *blur > 0.0))
726                .count()
727        };
728        assert_eq!(shadows(Variant::Elevated), 1);
729        for flat in [
730            Variant::Filled,
731            Variant::Tonal,
732            Variant::Outlined,
733            Variant::Text,
734            Variant::Danger,
735        ] {
736            assert_eq!(shadows(flat), 0, "{flat:?} is flat");
737        }
738        assert_eq!(
739            shadows(Variant::Elevated).min(
740                painted(
741                    &Button::<Msg>::new("Go")
742                        .variant(Variant::Elevated)
743                        .enabled(false)
744                )
745                .iter()
746                .filter(|p| matches!(p, Primitive::Rect { blur, .. } if *blur > 0.0))
747                .count()
748            ),
749            0,
750            "and a disabled one does not float"
751        );
752    }
753
754    #[test]
755    fn the_variants_are_told_apart_by_their_surface() {
756        let theme = Theme::default();
757        assert_eq!(surface(&Button::<Msg>::new("Go")).0, theme.scheme.primary);
758        assert_eq!(
759            surface(&Button::<Msg>::new("Go").variant(Variant::Tonal)).0,
760            theme.scheme.secondary_container
761        );
762        assert_eq!(
763            surface(&Button::<Msg>::new("Go").variant(Variant::Danger)).0,
764            theme.scheme.error
765        );
766        // The two quiet ones have no surface at all until something happens to them.
767        assert_eq!(
768            surface(&Button::<Msg>::new("Go").variant(Variant::Text)).0,
769            Color::TRANSPARENT
770        );
771        let outlined = surface(&Button::<Msg>::new("Go").variant(Variant::Outlined));
772        assert_eq!(outlined.0, Color::TRANSPARENT);
773        assert_eq!(
774            outlined.2, BUTTON_BORDER_WIDTH,
775            "an outline, and only there"
776        );
777        assert_eq!(
778            surface(&Button::<Msg>::new("Go").variant(Variant::Text)).2,
779            0.0
780        );
781    }
782
783    #[test]
784    fn a_button_is_a_stadium_whatever_its_height() {
785        // The radius follows the height rather than the theme's corner setting: half of it,
786        // so a button stays a lozenge instead of becoming a box with soft corners.
787        assert_eq!(
788            surface(&Button::<Msg>::new("Go")).1,
789            BorderRadius::uniform(BUTTON_HEIGHT / 2.0)
790        );
791        // And a caller who wants corners can still have them.
792        assert_eq!(
793            surface(&Button::<Msg>::new("Go").radius(4.0)).1,
794            BorderRadius::uniform(4.0)
795        );
796    }
797
798    #[test]
799    fn a_button_is_the_references_size() {
800        let theme = Theme::default();
801        let size = |button: Button<Msg>| {
802            let style = Widget::<Msg>::style_themed(&button, &theme);
803            match (style.width, style.height) {
804                (Dimension::Length(w), Dimension::Length(h)) => (w, h),
805                other => panic!("{other:?}"),
806            }
807        };
808        let (width, height) = size(Button::new("OK"));
809        assert_eq!(height, BUTTON_HEIGHT);
810        // The minimum binds only where the label plus its room falls short of it, which
811        // at 24 px either side means a label of about one character.
812        assert!(width >= BUTTON_MIN_WIDTH);
813        assert_eq!(
814            size(Button::new("I")).0,
815            BUTTON_MIN_WIDTH,
816            "a one-letter label does not give a narrower button"
817        );
818        // A long one grows past the minimum, by the padding on either side.
819        let (wide, _) = size(Button::new("A considerably longer label"));
820        assert!(wide > BUTTON_MIN_WIDTH);
821        // A text button keeps the height and gives back the room.
822        let (narrow, _) = size(Button::new("A considerably longer label").variant(Variant::Text));
823        assert_eq!(
824            wide - narrow,
825            (BUTTON_PADDING - BUTTON_TEXT_PADDING) * 2.0,
826            "a text button's label sits closer to its edges"
827        );
828    }
829
830    /// A disabled button loses its **accent** in every variant — that is what makes
831    /// unavailable read as unavailable rather than as a quieter version of the variant.
832    ///
833    /// It does not lose its **shape**. This test asserted the opposite until milestone 324,
834    /// and the picture is what settled it: flattening all six to the same grey pill left a
835    /// disabled page strip showing six identical pills with no current page, and the
836    /// reference is explicit that a text button's background is transparent in every state.
837    #[test]
838    fn a_disabled_button_drops_its_accent_but_keeps_its_shape() {
839        let theme = Theme::default();
840        let grey = disabled_container(&theme);
841        // The variants that genuinely are a filled container flatten to one.
842        for variant in [
843            Variant::Filled,
844            Variant::Tonal,
845            Variant::Elevated,
846            Variant::Danger,
847        ] {
848            let (fill, _, _) = surface(&Button::<Msg>::new("Go").variant(variant).enabled(false));
849            assert_eq!(fill, grey, "{variant:?} should flatten to a container");
850        }
851        // A text button has no container, disabled or not.
852        let (fill, _, width) = surface(
853            &Button::<Msg>::new("Go")
854                .variant(Variant::Text)
855                .enabled(false),
856        );
857        assert_eq!(fill.a, 0.0, "a disabled text button gains no fill");
858        assert_eq!(width, 0.0, "and no outline");
859        // An outlined one keeps its outline, at the container opacity, and gains no fill.
860        let (fill, _, width) = surface(
861            &Button::<Msg>::new("Go")
862                .variant(Variant::Outlined)
863                .enabled(false),
864        );
865        assert_eq!(fill.a, 0.0, "a disabled outlined button gains no fill");
866        assert!(width > 0.0, "and keeps its outline");
867    }
868
869    /// Whatever the shape, the **label** is the same grey in every variant: that is the
870    /// half of the rule that says "unavailable".
871    #[test]
872    fn a_disabled_label_is_the_same_in_every_variant() {
873        let theme = Theme::default();
874        for variant in [
875            Variant::Filled,
876            Variant::Tonal,
877            Variant::Elevated,
878            Variant::Outlined,
879            Variant::Text,
880            Variant::Danger,
881        ] {
882            let label = painted(&Button::<Msg>::new("Go").variant(variant).enabled(false))
883                .iter()
884                .find_map(|p| match p {
885                    Primitive::Text { color, .. } => Some(*color),
886                    _ => None,
887                })
888                .expect("a button paints its label");
889            assert_eq!(label, disabled_content(&theme), "{variant:?}");
890            assert_ne!(label, theme.scheme.primary, "never the accent: {variant:?}");
891        }
892    }
893
894    #[test]
895    fn every_measurement_is_the_callers_and_then_the_themes() {
896        let mut theme = Theme::default();
897        theme.widgets.button.height = Some(56.0);
898        let height =
899            |button: Button<Msg>, theme: &Theme| match Widget::<Msg>::style_themed(&button, theme)
900                .height
901            {
902                Dimension::Length(h) => h,
903                other => panic!("{other:?}"),
904            };
905        assert_eq!(
906            height(Button::new("Go"), &Theme::default()),
907            BUTTON_HEIGHT,
908            "the framework's"
909        );
910        assert_eq!(height(Button::new("Go"), &theme), 56.0, "the theme's");
911        assert_eq!(
912            height(Button::new("Go").height(30.0), &theme),
913            30.0,
914            "the caller's, over the theme's"
915        );
916    }
917
918    /// **A button's height follows its state**, as the reference's does. An elevated one
919    /// rests at 1, rises to 3 under a pointer and is back at 1 once pressed; a filled, tonal
920    /// or danger one is flat and rises to 1 under a pointer. Every button here held one
921    /// height whatever the pointer did. The height moves by the progress the state layer is
922    /// painted with, so half-way into a hover is half-way up.
923    #[test]
924    fn the_height_follows_the_state() {
925        // The height a painted shadow stands for: its blur is four pixels a step, plus eight.
926        let height = |button: &Button<Msg>, status: Status, theme: &Theme| {
927            let mut scene = Scene::new();
928            Widget::<Msg>::paint(
929                button,
930                Rect::new(0.0, 0.0, 120.0, BUTTON_HEIGHT),
931                status,
932                theme,
933                &mut scene,
934            );
935            scene
936                .primitives()
937                .iter()
938                .find_map(|p| match p {
939                    Primitive::Rect { blur, .. } if *blur > 0.0 => Some((blur - 8.0) / 4.0),
940                    _ => None,
941                })
942                .unwrap_or(0.0)
943        };
944        let theme = Theme::default();
945        let rest = Status::default();
946        let hovered = Status {
947            hover_progress: 1.0,
948            ..Default::default()
949        };
950        let held = Status {
951            hover_progress: 1.0,
952            press_progress: 1.0,
953            ..Default::default()
954        };
955        let focused = Status {
956            focus_progress: 1.0,
957            ..Default::default()
958        };
959        let half = Status {
960            hover_progress: 0.5,
961            ..Default::default()
962        };
963
964        let elevated = Button::new("Go")
965            .variant(Variant::Elevated)
966            .on_press(Msg::Pressed);
967        assert_eq!(height(&elevated, rest, &theme), 1.0);
968        assert_eq!(height(&elevated, hovered, &theme), 3.0);
969        assert_eq!(
970            height(&elevated, held, &theme),
971            1.0,
972            "a press outranks the hover"
973        );
974        assert_eq!(height(&elevated, focused, &theme), 1.0);
975        assert_eq!(
976            height(&elevated, half, &theme),
977            2.0,
978            "it moves, it does not jump"
979        );
980
981        for variant in [Variant::Filled, Variant::Tonal, Variant::Danger] {
982            let button = Button::new("Go").variant(variant).on_press(Msg::Pressed);
983            assert_eq!(height(&button, rest, &theme), 0.0, "{variant:?} rests flat");
984            assert_eq!(height(&button, hovered, &theme), 1.0, "{variant:?} rises");
985            assert_eq!(height(&button, held, &theme), 0.0, "{variant:?} pressed");
986            assert_eq!(height(&button, focused, &theme), 0.0, "{variant:?} focused");
987        }
988        for variant in [Variant::Outlined, Variant::Text] {
989            let button = Button::new("Go").variant(variant).on_press(Msg::Pressed);
990            assert_eq!(
991                height(&button, hovered, &theme),
992                0.0,
993                "{variant:?} stays flat"
994            );
995        }
996        let disabled = Button::new("Go")
997            .variant(Variant::Elevated)
998            .on_press(Msg::Pressed)
999            .enabled(false);
1000        assert_eq!(
1001            height(&disabled, hovered, &theme),
1002            0.0,
1003            "a disabled one does not rise"
1004        );
1005
1006        // A height the caller or the theme names holds in every state.
1007        let told = Button::new("Go").elevation(2.0).on_press(Msg::Pressed);
1008        assert_eq!(height(&told, rest, &theme), 2.0);
1009        assert_eq!(height(&told, held, &theme), 2.0);
1010        let mut flat = Theme::default();
1011        flat.widgets.button.elevation = Some(0.0);
1012        assert_eq!(height(&elevated, hovered, &flat), 0.0, "the theme's word");
1013        assert_eq!(
1014            height(&told, hovered, &flat),
1015            2.0,
1016            "and the caller's over it"
1017        );
1018    }
1019}