Skip to main content

frus_widgets/
button.rs

1//! [`Button`]: the reference's buttons — elevated, filled, filled tonal, outlined and text
2//! (`material/button_style_button.dart`), in one widget with a variant.
3//!
4//! They share everything but their colours and their elevation: any widget as their
5//! content, or a label, with an icon beside it if asked; a minimum size of 64 × 40 moved by
6//! the theme's visual density; padding that shrinks as the reader's text grows; a 48 px
7//! touch target around a smaller button; a stadium; and a style resolved **per state** —
8//! the caller's [`ButtonStyle`], then the theme's for that kind of button, then the
9//! reference's defaults (milestone 632).
10//!
11//! ```ignore
12//! Button::new("Save").on_press(Msg::Save)                              // filled
13//! Button::new("Cancel").variant(Variant::Text).on_press(Msg::Cancel)  // text
14//! Button::with_child(row![...]).variant(Variant::Outlined)             // any content
15//! Button::new("Send").icon(Icon::new(Icons::SEND)).on_press(Msg::Send) // icon and label
16//! ```
17
18use frus_core::{
19    Alignment, BorderRadius, BorderSide, Color, Insets, Rect, Scene, ShapeBorder, Size, TextStyle,
20};
21use frus_layout::{Align, Dimension, FlexDirection, Justify, Style};
22
23use crate::interaction::Status;
24use crate::theme::{TapTarget, Theme};
25use crate::widget::Widget;
26use crate::widgetstate::{WidgetState, WidgetStateProperty, WidgetStates};
27
28/// A button's minimum height at the standard density (`elevated_button.dart:593`).
29pub const BUTTON_HEIGHT: f32 = 40.0;
30/// The narrowest a button gets at the standard density, however short its label.
31pub const BUTTON_MIN_WIDTH: f32 = 64.0;
32/// The room either side of the label (`elevated_button.dart:458`).
33pub const BUTTON_PADDING: f32 = 24.0;
34/// The room either side of a **text** button's label (`text_button.dart:442`).
35pub const BUTTON_TEXT_PADDING: f32 = 12.0;
36/// How far an elevated button sits off the surface at rest.
37pub const BUTTON_ELEVATION: f32 = 1.0;
38/// An outlined button's outline.
39pub const BUTTON_BORDER_WIDTH: f32 = 1.0;
40/// An icon's side inside a button (`elevated_button.dart:599`).
41pub const BUTTON_ICON_SIZE: f32 = 18.0;
42
43/// How much of a screen's attention a button is asking for: which of the reference's
44/// buttons it is.
45#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
46pub enum Variant {
47    /// The accent, filled — the reference's `FilledButton`: the one action a screen is
48    /// about.
49    #[default]
50    Filled,
51    /// A tonal fill — `FilledButton.tonal`: beside a filled button, not competing with it.
52    Tonal,
53    /// A raised surface with a shadow — `ElevatedButton`, for a button over busy content.
54    Elevated,
55    /// An outline and no fill — `OutlinedButton`.
56    Outlined,
57    /// A label alone — `TextButton`.
58    Text,
59    /// Filled in the **error** role — a destructive action.
60    ///
61    /// Not one of the reference's five: there, a destructive button is a filled button
62    /// given the error colours by hand. It is here because saying *this action destroys
63    /// something* is worth a name.
64    Danger,
65}
66
67/// **Which side of the label an icon goes** (`button_style_button.dart:55`).
68#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
69pub enum IconAlignment {
70    /// Before the label: left in a left-to-right layout.
71    #[default]
72    Start,
73    /// After it.
74    End,
75}
76
77/// **How a button looks in each state** — the reference's `ButtonStyle`
78/// (`material/button_style.dart`).
79///
80/// Every field is optional: what is unset falls through to the theme's style for that
81/// kind of button ([`ButtonTheme`](crate::ButtonTheme)), then to the reference's defaults.
82/// A [`WidgetStateProperty`] that has no answer for a state falls through the same way,
83/// so a style that only says what a button looks like while enabled keeps the default
84/// disabled look.
85#[derive(Clone, Debug, Default, PartialEq)]
86pub struct ButtonStyle {
87    /// The label's type; its colour is the foreground's.
88    pub text_style: Option<WidgetStateProperty<TextStyle>>,
89    /// The surface.
90    pub background_color: Option<WidgetStateProperty<Color>>,
91    /// The label's colour, and the icon's unless `icon_color` says otherwise.
92    pub foreground_color: Option<WidgetStateProperty<Color>>,
93    /// The highlight under a pointer or the keyboard's focus, and the ripple of a press.
94    pub overlay_color: Option<WidgetStateProperty<Color>>,
95    /// The shadow's colour.
96    pub shadow_color: Option<WidgetStateProperty<Color>>,
97    /// How far it sits off the surface.
98    pub elevation: Option<WidgetStateProperty<f32>>,
99    /// The room between the edge and the content.
100    pub padding: Option<WidgetStateProperty<Insets>>,
101    /// The smallest it is, before the visual density.
102    pub minimum_size: Option<WidgetStateProperty<Size>>,
103    /// The size it is, within the minimum and maximum. An infinite side is left free.
104    pub fixed_size: Option<WidgetStateProperty<Size>>,
105    /// The largest it is.
106    pub maximum_size: Option<WidgetStateProperty<Size>>,
107    /// The icon's colour.
108    pub icon_color: Option<WidgetStateProperty<Color>>,
109    /// The icon's side.
110    pub icon_size: Option<WidgetStateProperty<f32>>,
111    /// The outline.
112    pub side: Option<WidgetStateProperty<BorderSide>>,
113    /// The shape.
114    pub shape: Option<WidgetStateProperty<ShapeBorder>>,
115    /// How compact it is. Unset, the theme's.
116    pub visual_density: Option<crate::VisualDensity>,
117    /// How much room it reserves for a finger. Unset, the theme's.
118    pub tap_target: Option<TapTarget>,
119    /// Where the content sits inside it.
120    pub alignment: Option<Alignment>,
121    /// Which side of the label an icon goes.
122    pub icon_alignment: Option<IconAlignment>,
123}
124
125impl ButtonStyle {
126    /// A style that says nothing.
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// The same, its surface `property`.
132    #[must_use]
133    pub fn background_color(mut self, property: WidgetStateProperty<Color>) -> Self {
134        self.background_color = Some(property);
135        self
136    }
137
138    /// The same, its label `property`.
139    #[must_use]
140    pub fn foreground_color(mut self, property: WidgetStateProperty<Color>) -> Self {
141        self.foreground_color = Some(property);
142        self
143    }
144
145    /// The same, its highlight and ripple `property`.
146    #[must_use]
147    pub fn overlay_color(mut self, property: WidgetStateProperty<Color>) -> Self {
148        self.overlay_color = Some(property);
149        self
150    }
151
152    /// The same, its elevation `property`.
153    #[must_use]
154    pub fn elevation(mut self, property: WidgetStateProperty<f32>) -> Self {
155        self.elevation = Some(property);
156        self
157    }
158
159    /// The same, its padding `property`.
160    #[must_use]
161    pub fn padding(mut self, property: WidgetStateProperty<Insets>) -> Self {
162        self.padding = Some(property);
163        self
164    }
165
166    /// The same, at least `size`.
167    #[must_use]
168    pub fn minimum_size(mut self, size: Size) -> Self {
169        self.minimum_size = Some(WidgetStateProperty::all(size));
170        self
171    }
172
173    /// The same, `size` exactly where it is finite.
174    #[must_use]
175    pub fn fixed_size(mut self, size: Size) -> Self {
176        self.fixed_size = Some(WidgetStateProperty::all(size));
177        self
178    }
179
180    /// The same, at most `size`.
181    #[must_use]
182    pub fn maximum_size(mut self, size: Size) -> Self {
183        self.maximum_size = Some(WidgetStateProperty::all(size));
184        self
185    }
186
187    /// The same, its outline `property`.
188    #[must_use]
189    pub fn side(mut self, property: WidgetStateProperty<BorderSide>) -> Self {
190        self.side = Some(property);
191        self
192    }
193
194    /// The same, shaped `shape` in every state.
195    #[must_use]
196    pub fn shape(mut self, shape: ShapeBorder) -> Self {
197        self.shape = Some(WidgetStateProperty::all(shape));
198        self
199    }
200
201    /// The same, its icon `property`.
202    #[must_use]
203    pub fn icon_color(mut self, property: WidgetStateProperty<Color>) -> Self {
204        self.icon_color = Some(property);
205        self
206    }
207
208    /// The same, its icon `size` on a side.
209    #[must_use]
210    pub fn icon_size(mut self, size: f32) -> Self {
211        self.icon_size = Some(WidgetStateProperty::all(size));
212        self
213    }
214
215    /// The same, its label in `style`.
216    #[must_use]
217    pub fn text_style(mut self, style: TextStyle) -> Self {
218        self.text_style = Some(WidgetStateProperty::all(style));
219        self
220    }
221
222    /// The same, at `density`.
223    #[must_use]
224    pub fn visual_density(mut self, density: crate::VisualDensity) -> Self {
225        self.visual_density = Some(density);
226        self
227    }
228
229    /// The same, reserving `target` for a finger.
230    #[must_use]
231    pub fn tap_target(mut self, target: TapTarget) -> Self {
232        self.tap_target = Some(target);
233        self
234    }
235
236    /// The same, its content at `alignment`.
237    #[must_use]
238    pub fn alignment(mut self, alignment: Alignment) -> Self {
239        self.alignment = Some(alignment);
240        self
241    }
242
243    /// The same, its icon on the `alignment` side.
244    #[must_use]
245    pub fn icon_alignment(mut self, alignment: IconAlignment) -> Self {
246        self.icon_alignment = Some(alignment);
247        self
248    }
249}
250
251/// `value` while enabled, and nothing while disabled: what a caller's plain colour or
252/// number means, so that a disabled button keeps the disabled look (the reference's
253/// `styleFrom`).
254fn enabled_only<T>(value: T) -> WidgetStateProperty<T> {
255    WidgetStateProperty::new().when(!crate::StateFilter::from(WidgetState::Disabled), value)
256}
257
258/// Puts `value` in `slot` if nothing is there yet.
259fn fill<T>(slot: &mut Option<T>, value: Option<T>) {
260    if slot.is_none() {
261        *slot = value;
262    }
263}
264
265/// The reference's `scaledPadding` (`button_style_button.dart:294`): the padding at the
266/// standard text size, at twice it, at three times it, and in between.
267fn scaled_padding(one: Insets, two: Insets, three: Insets, scale: f32) -> Insets {
268    let lerp = |a: Insets, b: Insets, t: f32| {
269        Insets::new(
270            a.top + (b.top - a.top) * t,
271            a.right + (b.right - a.right) * t,
272            a.bottom + (b.bottom - a.bottom) * t,
273            a.left + (b.left - a.left) * t,
274        )
275    };
276    if scale <= 1.0 {
277        one
278    } else if scale < 2.0 {
279        lerp(one, two, scale - 1.0)
280    } else if scale < 3.0 {
281        lerp(two, three, scale - 2.0)
282    } else {
283        three
284    }
285}
286
287/// Padding given start and end, laid out for the reading direction.
288fn directional(start: f32, top: f32, end: f32, bottom: f32, rtl: bool) -> Insets {
289    if rtl {
290        Insets::new(top, start, bottom, end)
291    } else {
292        Insets::new(top, end, bottom, start)
293    }
294}
295
296/// **The reference's defaults for each kind of button**, Material 3
297/// (`elevated_button.dart:513`, `filled_button.dart:531` and `:672`,
298/// `outlined_button.dart:460`, `text_button.dart:493`), with the padding for a button that
299/// has an icon (`elevated_button.dart:421`, `text_button.dart:412`).
300fn default_style(variant: Variant, theme: &Theme, with_icon: bool) -> ButtonStyle {
301    let c = &theme.scheme;
302    let disabled = WidgetState::Disabled;
303    // The reference's `onSurface` at 12 % and 38 %, resolved over the surface rather than
304    // drawn translucent: the GPU blends in linear light, where 12 % paints like a third
305    // (see `disabled.rs`).
306    let disabled_fill = crate::disabled::disabled_container(theme);
307    let disabled_ink = crate::disabled::disabled_content(theme);
308    let (background, foreground, overlay): (Option<Color>, Color, Color) = match variant {
309        Variant::Elevated => (Some(c.surface_container_low), c.primary, c.primary),
310        Variant::Filled => (Some(c.primary), c.on_primary, c.on_primary),
311        Variant::Tonal => (
312            Some(c.secondary_container),
313            c.on_secondary_container,
314            c.on_secondary_container,
315        ),
316        Variant::Danger => (Some(c.error), c.on_error, c.on_error),
317        Variant::Outlined | Variant::Text => (None, c.primary, c.primary),
318    };
319    // Raised at rest, higher under a pointer; flat and raised a step under a pointer; or
320    // flat throughout.
321    let (rest, hovered) = match variant {
322        Variant::Elevated => (1.0, 3.0),
323        Variant::Filled | Variant::Tonal | Variant::Danger => (0.0, 1.0),
324        Variant::Outlined | Variant::Text => (0.0, 0.0),
325    };
326    let scale = text_size_scale(theme);
327    let rtl = theme.direction == frus_core::TextDirection::Rtl;
328    let padding = match (variant, with_icon) {
329        (Variant::Text, false) => scaled_padding(
330            Insets::new(8.0, 12.0, 8.0, 12.0),
331            Insets::new(0.0, 8.0, 0.0, 8.0),
332            Insets::new(0.0, 4.0, 0.0, 4.0),
333            scale,
334        ),
335        (Variant::Text, true) => scaled_padding(
336            directional(12.0, 8.0, 16.0, 8.0, rtl),
337            Insets::new(0.0, 4.0, 0.0, 4.0),
338            Insets::new(0.0, 4.0, 0.0, 4.0),
339            scale,
340        ),
341        (_, false) => scaled_padding(
342            Insets::new(0.0, BUTTON_PADDING, 0.0, BUTTON_PADDING),
343            Insets::new(0.0, 12.0, 0.0, 12.0),
344            Insets::new(0.0, 6.0, 0.0, 6.0),
345            scale,
346        ),
347        (_, true) => scaled_padding(
348            directional(16.0, 0.0, 24.0, 0.0, rtl),
349            directional(8.0, 0.0, 12.0, 0.0, rtl),
350            directional(4.0, 0.0, 6.0, 0.0, rtl),
351            scale,
352        ),
353    };
354    let mut style = ButtonStyle {
355        text_style: Some(WidgetStateProperty::all(theme.text.label_large)),
356        background_color: background.map(|bg| {
357            WidgetStateProperty::new()
358                .when(disabled, disabled_fill)
359                .otherwise(bg)
360        }),
361        foreground_color: Some(
362            WidgetStateProperty::new()
363                .when(disabled, disabled_ink)
364                .otherwise(foreground),
365        ),
366        // The ripple of a press, the highlight under a pointer, and the keyboard's focus
367        // (`elevated_button.dart:547`): ten, eight and ten percent.
368        overlay_color: Some(
369            WidgetStateProperty::new()
370                .when(WidgetState::Pressed, overlay.with_alpha(0.1))
371                .when(WidgetState::Hovered, overlay.with_alpha(0.08))
372                .when(WidgetState::Focused, overlay.with_alpha(0.1)),
373        ),
374        shadow_color: Some(WidgetStateProperty::all(c.shadow)),
375        elevation: Some(
376            WidgetStateProperty::new()
377                .when(disabled, 0.0)
378                .when(WidgetState::Pressed, rest)
379                .when(WidgetState::Hovered, hovered)
380                .otherwise(rest),
381        ),
382        padding: Some(WidgetStateProperty::all(padding)),
383        minimum_size: Some(WidgetStateProperty::all(Size::new(
384            BUTTON_MIN_WIDTH,
385            BUTTON_HEIGHT,
386        ))),
387        fixed_size: None,
388        maximum_size: Some(WidgetStateProperty::all(Size::new(
389            f32::INFINITY,
390            f32::INFINITY,
391        ))),
392        icon_color: None,
393        icon_size: Some(WidgetStateProperty::all(BUTTON_ICON_SIZE)),
394        side: None,
395        shape: Some(WidgetStateProperty::all(ShapeBorder::stadium())),
396        visual_density: Some(theme.visual_density()),
397        tap_target: Some(theme.tap_target),
398        alignment: Some(Alignment::CENTER),
399        icon_alignment: Some(IconAlignment::Start),
400    };
401    if variant == Variant::Outlined {
402        // The outline: faint while disabled, the accent while focused, the outline role
403        // otherwise (`outlined_button.dart:553`).
404        style.side = Some(
405            WidgetStateProperty::new()
406                .when(
407                    disabled,
408                    BorderSide::new(disabled_fill, BUTTON_BORDER_WIDTH),
409                )
410                .when(
411                    WidgetState::Focused,
412                    BorderSide::new(c.primary, BUTTON_BORDER_WIDTH),
413                )
414                .otherwise(BorderSide::new(c.outline, BUTTON_BORDER_WIDTH)),
415        );
416    }
417    style
418}
419
420/// How much larger than the standard the reader's label is: the label's size at the
421/// reader's text scale over 14 (`elevated_button.dart:460`).
422fn text_size_scale(theme: &Theme) -> f32 {
423    let size = theme.text.label_large.size.unwrap_or(14.0);
424    size * frus_core::text_scale() / 14.0
425}
426
427/// Resolves one property for `states`: the button's, the theme's, the default's.
428fn resolve<T: Clone>(
429    states: WidgetStates,
430    get: impl Fn(&ButtonStyle) -> Option<&WidgetStateProperty<T>>,
431    styles: [Option<&ButtonStyle>; 3],
432) -> Option<T> {
433    styles
434        .into_iter()
435        .flatten()
436        .find_map(|style| get(style).and_then(|p| p.resolve(states)).cloned())
437}
438
439/// Resolves one state-independent setting: the button's, the theme's, the default's.
440fn setting<T: Copy>(
441    get: impl Fn(&ButtonStyle) -> Option<T>,
442    styles: [Option<&ButtonStyle>; 3],
443) -> Option<T> {
444    styles.into_iter().flatten().find_map(get)
445}
446
447/// The content a button was given, kept until the walk first asks for its children: the
448/// icon beside it is put together then, once and for all.
449struct Parts<Msg> {
450    content: Box<dyn Widget<Msg>>,
451    icon: Option<Box<dyn Widget<Msg>>>,
452}
453
454/// A clickable button: the reference's `ElevatedButton`, `FilledButton`,
455/// `FilledButton.tonal`, `OutlinedButton` and `TextButton`, chosen by [`Variant`].
456pub struct Button<Msg = crate::callback::Callback> {
457    label: Option<String>,
458    parts: std::cell::RefCell<Option<Parts<Msg>>>,
459    built: std::cell::OnceCell<Vec<Box<dyn Widget<Msg>>>>,
460    has_icon: bool,
461    variant: Variant,
462    on_press: Option<Msg>,
463    on_long_press: Option<Msg>,
464    /// `false` turns the button off whatever its actions.
465    enabled: bool,
466    style: ButtonStyle,
467    /// The older builders' word on the minimum width and the fixed height, which name one
468    /// side of a size the style holds whole.
469    min_width: Option<f32>,
470    height: Option<f32>,
471}
472
473impl<Msg: Clone + 'static> Button<Msg> {
474    /// A button reading `label`.
475    pub fn new(label: impl Into<String>) -> Self {
476        let label = label.into();
477        // The button's own node says the label; the words drawn inside it say nothing,
478        // so a reader hears it once.
479        let mut button = Self::with_child(crate::ExcludeSemantics::new(crate::Text::new(
480            label.clone(),
481        )));
482        button.label = Some(label);
483        button
484    }
485
486    /// A button holding `child` — any widget, as the reference's buttons hold any widget.
487    /// The child's text takes the button's label style and colour, and its icons the
488    /// button's icon colour and size.
489    pub fn with_child(child: impl Widget<Msg> + 'static) -> Self {
490        Self {
491            label: None,
492            parts: std::cell::RefCell::new(Some(Parts {
493                content: Box::new(child),
494                icon: None,
495            })),
496            built: std::cell::OnceCell::new(),
497            has_icon: false,
498            variant: Variant::default(),
499            on_press: None,
500            on_long_press: None,
501            enabled: true,
502            style: ButtonStyle::default(),
503            min_width: None,
504            height: None,
505        }
506    }
507
508    /// **An icon beside the content** — the reference's `.icon` constructors: before the
509    /// label unless [`icon_alignment`](Self::icon_alignment) says after, 8 px from it
510    /// (4 at twice the standard text size), and the padding becomes the reference's for
511    /// a button with an icon.
512    pub fn icon(mut self, icon: impl Widget<Msg> + 'static) -> Self {
513        if let Some(parts) = self.parts.get_mut().as_mut() {
514            parts.icon = Some(Box::new(icon));
515            self.has_icon = true;
516        }
517        self
518    }
519}
520
521impl<Msg> Button<Msg> {
522    /// Enables or **disables** the button. A button is also disabled when it has neither
523    /// a press nor a long press to answer, as the reference's is.
524    pub fn enabled(mut self, enabled: bool) -> Self {
525        self.enabled = enabled;
526        self
527    }
528
529    /// Is it on: told so, and with something to do?
530    fn is_enabled(&self) -> bool {
531        self.enabled && (self.on_press.is_some() || self.on_long_press.is_some())
532    }
533
534    /// **How it looks, state by state** — over the theme's style and the reference's
535    /// defaults. The builders below each set one property of it.
536    #[must_use]
537    pub fn style(mut self, style: ButtonStyle) -> Self {
538        self.style = style;
539        self
540    }
541
542    /// The corner radii (uniform via `f32`, per corner via [`BorderRadius`]). Unset, a
543    /// **stadium**.
544    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
545        self.style.shape = Some(WidgetStateProperty::all(ShapeBorder::rounded(radius)));
546        self
547    }
548
549    /// **What shape it is** (`shape_border.dart`).
550    #[must_use]
551    pub fn shape(mut self, shape: ShapeBorder) -> Self {
552        self.style.shape = Some(WidgetStateProperty::all(shape));
553        self
554    }
555
556    /// Which of the reference's buttons it is.
557    pub fn variant(mut self, variant: Variant) -> Self {
558        self.variant = variant;
559        self
560    }
561
562    /// The label's type. Unset, the theme's `label_large`.
563    pub fn label_style(mut self, style: TextStyle) -> Self {
564        self.style.text_style = Some(WidgetStateProperty::all(style));
565        self
566    }
567
568    /// Font size — sugar for [`Button::label_style`] with the label step resized.
569    pub fn size(mut self, size: f32) -> Self {
570        self.style.text_style = Some(WidgetStateProperty::all(TextStyle::new(size)));
571        self
572    }
573
574    /// The surface while enabled.
575    pub fn color(mut self, color: Color) -> Self {
576        self.style.background_color = Some(enabled_only(color));
577        self
578    }
579
580    /// The label's colour while enabled.
581    pub fn label_color(mut self, color: Color) -> Self {
582        self.style.foreground_color = Some(enabled_only(color));
583        self
584    }
585
586    /// The outline's colour, `1` px thick unless [`border_width`](Self::border_width) says.
587    pub fn border_color(mut self, color: Color) -> Self {
588        let width = self.border_width_said().unwrap_or(BUTTON_BORDER_WIDTH);
589        self.style.side = Some(enabled_only(BorderSide::new(color, width)));
590        self
591    }
592
593    /// The outline's thickness; `0.0` removes it.
594    pub fn border_width(mut self, width: f32) -> Self {
595        let color = self
596            .style
597            .side
598            .as_ref()
599            .and_then(|s| s.resolve(WidgetStates::EMPTY))
600            .map(|s| s.color);
601        self.style.side = Some(match color {
602            Some(color) => enabled_only(BorderSide::new(color, width)),
603            None => WidgetStateProperty::all(BorderSide::new(Color::TRANSPARENT, width)),
604        });
605        self
606    }
607
608    fn border_width_said(&self) -> Option<f32> {
609        self.style
610            .side
611            .as_ref()
612            .and_then(|s| s.resolve(WidgetStates::EMPTY))
613            .map(|s| s.width)
614    }
615
616    /// The room either side of the content.
617    pub fn padding(mut self, padding: f32) -> Self {
618        self.style.padding = Some(WidgetStateProperty::all(Insets::new(
619            0.0, padding, 0.0, padding,
620        )));
621        self
622    }
623
624    /// **The button's height**, exactly: the reference's fixed height, which the visual
625    /// density does not move.
626    pub fn height(mut self, height: f32) -> Self {
627        self.height = Some(height);
628        self
629    }
630
631    /// The narrowest it will be, before the visual density.
632    pub fn min_width(mut self, width: f32) -> Self {
633        self.min_width = Some(width);
634        self
635    }
636
637    /// How far it sits off the surface **in every state** while enabled. Unset, the
638    /// variant's: an elevated button rests at 1 and rises to 3 under a pointer; a filled
639    /// or tonal one is flat and rises to 1; an outlined or text one stays flat.
640    pub fn elevation(mut self, elevation: f32) -> Self {
641        self.style.elevation = Some(enabled_only(elevation));
642        self
643    }
644
645    /// Which side of the label an icon goes.
646    #[must_use]
647    pub fn icon_alignment(mut self, alignment: IconAlignment) -> Self {
648        self.style.icon_alignment = Some(alignment);
649        self
650    }
651
652    /// Message emitted on a press.
653    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
654        self.on_press = Some(message.into());
655        self
656    }
657
658    /// Message emitted on a **long press**, which takes the place of the press.
659    #[must_use]
660    pub fn on_long_press(mut self, message: impl Into<Msg>) -> Self {
661        self.on_long_press = Some(message.into());
662        self
663    }
664
665    /// The theme's style for this kind of button: its own style for the kind, with the
666    /// theme's plain fields under it.
667    fn theme_style(&self, theme: &Theme) -> ButtonStyle {
668        themed_style(theme, self.variant)
669    }
670}
671
672/// The theme's style for `variant`: the style it gives that kind of button, with its plain
673/// fields under it.
674fn themed_style(theme: &Theme, variant: Variant) -> ButtonStyle {
675    let t = &theme.widgets.button;
676    let mut style = match variant {
677        Variant::Elevated => t.elevated_style.clone(),
678        Variant::Filled | Variant::Tonal | Variant::Danger => t.filled_style.clone(),
679        Variant::Outlined => t.outlined_style.clone(),
680        Variant::Text => t.text_button_style.clone(),
681    }
682    .unwrap_or_default();
683    fill(&mut style.background_color, t.color.map(enabled_only));
684    fill(&mut style.foreground_color, t.label_color.map(enabled_only));
685    fill(
686        &mut style.text_style,
687        t.label_style.map(WidgetStateProperty::all),
688    );
689    fill(
690        &mut style.shape,
691        t.shape
692            .or(t.radius.map(ShapeBorder::rounded))
693            .map(WidgetStateProperty::all),
694    );
695    fill(
696        &mut style.padding,
697        t.padding
698            .map(|p| WidgetStateProperty::all(Insets::new(0.0, p, 0.0, p))),
699    );
700    fill(&mut style.elevation, t.elevation.map(enabled_only));
701    if style.side.is_none() && (t.border_color.is_some() || t.border_width.is_some()) {
702        style.side = Some(enabled_only(BorderSide::new(
703            t.border_color.unwrap_or(theme.scheme.outline),
704            t.border_width.unwrap_or(BUTTON_BORDER_WIDTH),
705        )));
706    }
707    style
708}
709
710impl<Msg> Button<Msg> {
711    /// The states it is in, disabled included.
712    fn states(&self, status: &Status) -> WidgetStates {
713        if self.is_enabled() {
714            status.states()
715        } else {
716            WidgetStates::of(WidgetState::Disabled)
717        }
718    }
719
720    /// Everything about it, resolved for `states`.
721    fn resolved(&self, theme: &Theme, states: WidgetStates) -> Resolved {
722        let own = &self.style;
723        let themed = self.theme_style(theme);
724        let default = default_style(self.variant, theme, self.has_icon);
725        let styles = [Some(own), Some(&themed), Some(&default)];
726        let r =
727            |f: fn(&ButtonStyle) -> Option<&WidgetStateProperty<Color>>| resolve(states, f, styles);
728        let foreground = r(|s| s.foreground_color.as_ref()).unwrap_or(theme.scheme.primary);
729        // The icon's colour: the icon colour said by the button or the theme, then their
730        // foreground, then the default's icon colour, then its foreground
731        // (`button_style_button.dart:395`).
732        let icon_color = resolve(
733            states,
734            |s| s.icon_color.as_ref(),
735            [Some(own), Some(&themed), None],
736        )
737        .or_else(|| {
738            resolve(
739                states,
740                |s| s.foreground_color.as_ref(),
741                [Some(own), Some(&themed), None],
742            )
743        })
744        .or_else(|| {
745            resolve(
746                states,
747                |s| s.icon_color.as_ref(),
748                [Some(&default), None, None],
749            )
750        })
751        .unwrap_or(foreground);
752        let mut minimum = resolve(states, |s| s.minimum_size.as_ref(), styles)
753            .unwrap_or(Size::new(BUTTON_MIN_WIDTH, BUTTON_HEIGHT));
754        if let Some(w) = self.min_width {
755            minimum.width = w;
756        }
757
758        Resolved {
759            text_style: resolve(states, |s| s.text_style.as_ref(), styles)
760                .unwrap_or(theme.text.label_large),
761            background: r(|s| s.background_color.as_ref()),
762            foreground,
763            overlay: r(|s| s.overlay_color.as_ref()),
764            shadow: r(|s| s.shadow_color.as_ref()).unwrap_or(theme.scheme.shadow),
765            elevation: resolve(states, |s| s.elevation.as_ref(), styles).unwrap_or(0.0),
766            padding: resolve(states, |s| s.padding.as_ref(), styles).unwrap_or(Insets::ZERO),
767            minimum,
768            fixed: match (
769                resolve(states, |s| s.fixed_size.as_ref(), styles),
770                self.height,
771            ) {
772                (fixed, None) => fixed,
773                (Some(size), Some(h)) => Some(Size::new(size.width, h)),
774                (None, Some(h)) => Some(Size::new(f32::INFINITY, h)),
775            },
776            maximum: resolve(states, |s| s.maximum_size.as_ref(), styles)
777                .unwrap_or(Size::new(f32::INFINITY, f32::INFINITY)),
778            icon_color,
779            icon_size: resolve(states, |s| s.icon_size.as_ref(), styles)
780                .unwrap_or(BUTTON_ICON_SIZE),
781            side: resolve(states, |s| s.side.as_ref(), styles),
782            shape: resolve(states, |s| s.shape.as_ref(), styles).unwrap_or(ShapeBorder::stadium()),
783            density: setting(|s| s.visual_density, styles).unwrap_or(theme.visual_density()),
784            tap_target: setting(|s| s.tap_target, styles).unwrap_or(theme.tap_target),
785            alignment: setting(|s| s.alignment, styles).unwrap_or(Alignment::CENTER),
786        }
787    }
788
789    /// The box laid out and the button drawn inside it: the room the touch target adds
790    /// around the visible button on each axis, and the visible button's minimum size
791    /// (`button_style_button.dart:473`, `:578`).
792    fn geometry(&self, r: &Resolved) -> Geometry {
793        let (dx, dy) = r.density.base_size_adjustment();
794        let (mut min_w, mut min_h) = r.density.effective_min_size(
795            (r.minimum.width, r.minimum.height),
796            (r.maximum.width, r.maximum.height),
797        );
798        let mut fixed = (None, None);
799        if let Some(size) = r.fixed {
800            if size.width.is_finite() {
801                let w = size.width.clamp(min_w, r.maximum.width.max(min_w));
802                min_w = w;
803                fixed.0 = Some(w);
804            }
805            if size.height.is_finite() {
806                let h = size.height.clamp(min_h, r.maximum.height.max(min_h));
807                min_h = h;
808                fixed.1 = Some(h);
809            }
810        }
811        let tap = match r.tap_target {
812            TapTarget::Padded => (crate::MIN_TAP_TARGET + dx, crate::MIN_TAP_TARGET + dy),
813            TapTarget::ShrinkWrap => (0.0, 0.0),
814        };
815        let extra = (
816            ((tap.0 - min_w) * 0.5).max(0.0),
817            ((tap.1 - min_h) * 0.5).max(0.0),
818        );
819        // The density moves the padding too: across never inwards, down either way, and
820        // never below nothing (`button_style_button.dart:501`).
821        let p = r.padding;
822        let ddx = dx.max(0.0);
823        let padding = Insets::new(
824            (p.top + dy).max(0.0),
825            (p.right + ddx).max(0.0),
826            (p.bottom + dy).max(0.0),
827            (p.left + ddx).max(0.0),
828        );
829        Geometry {
830            extra,
831            min: (min_w, min_h),
832            fixed,
833            padding,
834        }
835    }
836}
837
838/// A button's style, resolved for one set of states.
839struct Resolved {
840    text_style: TextStyle,
841    background: Option<Color>,
842    foreground: Color,
843    overlay: Option<Color>,
844    shadow: Color,
845    elevation: f32,
846    padding: Insets,
847    minimum: Size,
848    fixed: Option<Size>,
849    maximum: Size,
850    icon_color: Color,
851    icon_size: f32,
852    side: Option<BorderSide>,
853    shape: ShapeBorder,
854    density: crate::VisualDensity,
855    tap_target: TapTarget,
856    alignment: Alignment,
857}
858
859/// Where a button's parts go.
860struct Geometry {
861    /// The touch target's room around the visible button, across and down, on each side.
862    extra: (f32, f32),
863    /// The visible button's minimum size.
864    min: (f32, f32),
865    /// Its fixed width and height, where it has them.
866    fixed: (Option<f32>, Option<f32>),
867    /// The room between its edge and its content.
868    padding: Insets,
869}
870
871/// `top` over `base` at `amount` of its own opacity.
872fn over(base: Color, top: Color, amount: f32) -> Color {
873    let a = (top.a * amount).clamp(0.0, 1.0);
874    if a <= 0.0 {
875        return base;
876    }
877    if base.a <= 0.0 {
878        return top.with_alpha(a);
879    }
880    base.lerp(top.with_alpha(base.a), a)
881}
882
883/// Where along an axis an alignment puts the content.
884fn place(v: f32) -> (Justify, Align) {
885    let j = if v < -0.5 {
886        Justify::Start
887    } else if v > 0.5 {
888        Justify::End
889    } else {
890        Justify::Center
891    };
892    let a = match j {
893        Justify::Start => Align::Start,
894        Justify::End => Align::End,
895        _ => Align::Center,
896    };
897    (j, a)
898}
899
900/// An icon beside a label, as the reference's `.icon` buttons lay them out
901/// (`elevated_button.dart:470`): 8 px apart at the standard text size, closing to 4 at
902/// twice it.
903struct IconLabel<Msg> {
904    children: Vec<Box<dyn Widget<Msg>>>,
905    /// The button's own word on the icon's side.
906    own: Option<IconAlignment>,
907    /// Which kind of button, for the theme's word.
908    variant: Variant,
909}
910
911impl<Msg> Widget<Msg> for IconLabel<Msg> {
912    fn style(&self) -> Style {
913        self.style_themed(&Theme::default())
914    }
915
916    fn style_themed(&self, theme: &Theme) -> Style {
917        let scale = (text_size_scale(theme).clamp(1.0, 2.0)) - 1.0;
918        Style {
919            flex_direction: if self
920                .own
921                .or_else(|| themed_style(theme, self.variant).icon_alignment)
922                .unwrap_or_default()
923                == IconAlignment::End
924            {
925                FlexDirection::RowReverse
926            } else {
927                FlexDirection::Row
928            },
929            align: Align::Center,
930            gap: 8.0 + (4.0 - 8.0) * scale,
931            ..Default::default()
932        }
933    }
934
935    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
936        &self.children
937    }
938
939    fn paint(&self, _bounds: Rect, _status: Status, _theme: &Theme, _scene: &mut Scene) {}
940
941    fn on_click(&self) -> Option<Msg> {
942        None
943    }
944}
945
946impl<Msg: Clone + 'static> Widget<Msg> for Button<Msg> {
947    fn style(&self) -> Style {
948        Widget::<Msg>::style_themed(self, &Theme::default())
949    }
950
951    fn style_themed(&self, theme: &Theme) -> Style {
952        let r = self.resolved(theme, self.states(&Status::default()));
953        let g = self.geometry(&r);
954        let (justify, _) = place(r.alignment.x);
955        let (_, align) = place(r.alignment.y);
956        // A labelled button knows its width before it is laid out — the label, the
957        // padding, the icon and its gap, within the minimum and the maximum — and says it,
958        // so that a bar deciding which actions fit can ask. Any other content is measured
959        // by the layout.
960        let natural = self.label.as_ref().map(|label| {
961            let text = frus_text::measure_style(label, r.text_style).width;
962            let icon = if self.has_icon {
963                let scale = text_size_scale(theme).clamp(1.0, 2.0) - 1.0;
964                r.icon_size + 8.0 + (4.0 - 8.0) * scale
965            } else {
966                0.0
967            };
968            (text + icon + g.padding.left + g.padding.right)
969                .ceil()
970                .clamp(g.min.0, r.maximum.width.max(g.min.0))
971        });
972        let width = match (g.fixed.0, natural) {
973            (Some(w), _) | (None, Some(w)) => Dimension::Length(w + 2.0 * g.extra.0),
974            (None, None) => Dimension::Auto,
975        };
976        // Down, a labelled button knows its height too: the line and the padding, at
977        // least the minimum. Its box is that and the touch target's room, **up to what its
978        // parent allows**: in a slot 40 px tall the room gives way and the button is
979        // 40 px, as the reference's input padding is constrained by its parent
980        // (`button_style_button.dart:696`). Any other content is measured by the layout,
981        // its room always kept.
982        let visible_height = self.label.as_ref().map(|label| {
983            let line = frus_text::measure_style(label, r.text_style).height;
984            (line + g.padding.top + g.padding.bottom)
985                .ceil()
986                .clamp(g.min.1, r.maximum.height.max(g.min.1))
987        });
988        let (height, min_height, max_height) = match (g.fixed.1, visible_height) {
989            (Some(h), _) => (
990                Dimension::Length(h + 2.0 * g.extra.1),
991                Dimension::Length(g.min.1 + 2.0 * g.extra.1),
992                None,
993            ),
994            (None, Some(h)) if g.extra.1 > 0.0 => (
995                Dimension::Length(h + 2.0 * g.extra.1),
996                Dimension::Length(h),
997                Some(Dimension::Percent(1.0)),
998            ),
999            (None, Some(h)) => (Dimension::Length(h), Dimension::Auto, None),
1000            (None, None) => (
1001                Dimension::Auto,
1002                Dimension::Length(g.min.1 + 2.0 * g.extra.1),
1003                None,
1004            ),
1005        };
1006        let length = |v: f32| {
1007            if v.is_finite() {
1008                Dimension::Length(v)
1009            } else {
1010                Dimension::Auto
1011            }
1012        };
1013        Style {
1014            width,
1015            height,
1016            // A width already said holds the minimum. Saying both, equal, made the layout
1017            // measure the label at no width at all: "Edit" one letter a line, and a
1018            // button 85 px tall inside a 56 px bar.
1019            min_width: if matches!(width, Dimension::Length(_)) {
1020                Dimension::Auto
1021            } else {
1022                Dimension::Length(g.min.0 + 2.0 * g.extra.0)
1023            },
1024            min_height,
1025            max_width: length(r.maximum.width + 2.0 * g.extra.0),
1026            max_height: max_height.unwrap_or(length(r.maximum.height + 2.0 * g.extra.1)),
1027            flex_direction: FlexDirection::Row,
1028            justify,
1029            align,
1030            padding: Insets::new(
1031                g.padding.top + g.extra.1,
1032                g.padding.right + g.extra.0,
1033                g.padding.bottom + g.extra.1,
1034                g.padding.left + g.extra.0,
1035            ),
1036            ..Default::default()
1037        }
1038    }
1039
1040    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
1041        self.built.get_or_init(|| {
1042            let Some(Parts { content, icon }) = self.parts.borrow_mut().take() else {
1043                return Vec::new();
1044            };
1045            let node: Box<dyn Widget<Msg>> = match icon {
1046                None => content,
1047                Some(icon) => Box::new(IconLabel {
1048                    children: vec![icon, content],
1049                    own: self.style.icon_alignment,
1050                    variant: self.variant,
1051                }),
1052            };
1053            vec![node]
1054        })
1055    }
1056
1057    /// The content's text takes the button's label style in its foreground colour, and
1058    /// its icons the button's icon colour and size, as the reference hands them down
1059    /// through its `Material` and its `IconTheme` (`button_style_button.dart:550`, `:601`).
1060    fn theme_override(&self, inherited: &Theme) -> Option<Box<Theme>> {
1061        let r = self.resolved(inherited, self.states(&Status::default()));
1062        let mut theme = Box::new(inherited.clone());
1063        // In place of the text style around it, not merged into it: inside an app bar the
1064        // style around is the title's, and a label that took its spacing would no longer
1065        // be the width the button measured (the reference's `Material` sets its text style
1066        // outright).
1067        theme.widgets.text =
1068            crate::DefaultTextStyle::from_text_style(r.text_style.color(r.foreground));
1069        theme.widgets.icon.color = Some(r.icon_color);
1070        theme.widgets.icon.size = Some(r.icon_size);
1071        Some(theme)
1072    }
1073
1074    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
1075        let o = status.opacity;
1076        let states = self.states(&status);
1077        let base = if self.is_enabled() {
1078            WidgetStates::EMPTY
1079        } else {
1080            WidgetStates::of(WidgetState::Disabled)
1081        };
1082        let r = self.resolved(theme, states);
1083        let g = self.geometry(&r);
1084        // The touch target's room is what the box has beyond the visible button, up to
1085        // the room asked for: a parent that gave less has taken it back.
1086        let room = |given: f32, least: f32, asked: f32| ((given - least) * 0.5).clamp(0.0, asked);
1087        let (ex, ey) = (
1088            room(bounds.width, g.min.0, g.extra.0),
1089            room(bounds.height, g.min.1, g.extra.1),
1090        );
1091        let visible = Rect::new(
1092            bounds.x + ex,
1093            bounds.y + ey,
1094            (bounds.width - 2.0 * ex).max(0.0),
1095            (bounds.height - 2.0 * ey).max(0.0),
1096        );
1097        let radius = r
1098            .shape
1099            .as_rounded(visible)
1100            .map(|(_, radius)| radius)
1101            .unwrap_or(BorderRadius::ZERO);
1102
1103        // The elevation in each state, eased between by the same progress the highlight
1104        // fades by: a press outranks a hover, a hover a focus.
1105        let at = |extra: Option<WidgetState>| {
1106            let s = extra.map_or(base, |e| base.set(e, true));
1107            self.resolved(theme, s).elevation
1108        };
1109        let elevation = if self.is_enabled() {
1110            let toward = |from: f32, to: f32, p: f32| from + (to - from) * p.clamp(0.0, 1.0);
1111            let e = toward(
1112                at(None),
1113                at(Some(WidgetState::Focused)),
1114                status.focus_progress,
1115            );
1116            let e = toward(e, at(Some(WidgetState::Hovered)), status.hover_progress);
1117            toward(e, at(Some(WidgetState::Pressed)), status.press_progress)
1118        } else {
1119            r.elevation
1120        };
1121        if elevation > 0.0 {
1122            frus_core::paint_elevation(scene, visible, radius, elevation, r.shadow.fade(o));
1123        }
1124
1125        // The surface, with the focus and hover highlights over it, each fading in by its
1126        // own progress (`ink_well.dart` highlights). The press is the ripple's, not a
1127        // highlight: its overlay colour is the splash (see `ink`).
1128        let mut fill = r.background.unwrap_or(Color::TRANSPARENT);
1129        if self.is_enabled() {
1130            let overlay = |s: WidgetState| self.resolved(theme, base.set(s, true)).overlay;
1131            if let Some(focus) = overlay(WidgetState::Focused) {
1132                fill = over(fill, focus, status.focus_progress);
1133            }
1134            if let Some(hover) = overlay(WidgetState::Hovered) {
1135                fill = over(fill, hover, status.hover_progress);
1136            }
1137        }
1138        let shape = match r.side {
1139            Some(side) => r
1140                .shape
1141                .with_side(BorderSide::new(side.color.fade(o), side.width)),
1142            None => r.shape,
1143        };
1144        scene.draw_shape(visible, shape, fill.fade(o));
1145    }
1146
1147    fn on_click(&self) -> Option<Msg> {
1148        if self.is_enabled() {
1149            self.on_press.clone()
1150        } else {
1151            None
1152        }
1153    }
1154
1155    fn on_long_press(&self) -> Option<Msg> {
1156        if self.is_enabled() {
1157            self.on_long_press.clone()
1158        } else {
1159            None
1160        }
1161    }
1162
1163    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
1164        // A disabled control does not answer a tap, so it does not splash either.
1165        if !self.is_enabled() {
1166            return None;
1167        }
1168        // The ripple is the pressed overlay (`button_style_button.dart:566`), unless the
1169        // application has named one for every surface.
1170        let pressed = self.resolved(theme, WidgetStates::of(WidgetState::Pressed));
1171        let splash = theme
1172            .widgets
1173            .ink
1174            .color
1175            .or(pressed.overlay)
1176            .unwrap_or_else(|| pressed.foreground.with_alpha(0.1));
1177        let g = self.geometry(&pressed);
1178        let side = g.min.1;
1179        Some(
1180            crate::InkStyle::of(theme).color(splash).radius(
1181                pressed
1182                    .shape
1183                    .as_rounded(Rect::new(0.0, 0.0, side, side))
1184                    .map(|(_, radius)| radius)
1185                    .unwrap_or(BorderRadius::ZERO),
1186            ),
1187        )
1188    }
1189
1190    fn focusable(&self) -> bool {
1191        self.is_enabled()
1192    }
1193
1194    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
1195        // A labelled button's node carries its label, as the reference's button takes in
1196        // the text inside it (`button_style_button.dart:591`).
1197        let mut semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
1198        if let Some(label) = &self.label {
1199            semantics = semantics.label(label.clone());
1200        }
1201        // A disabled button does not announce a clickable action.
1202        Some(if self.is_enabled() {
1203            semantics.clickable()
1204        } else {
1205            semantics.disabled(true)
1206        })
1207    }
1208}
1209
1210#[cfg(test)]
1211mod tests {
1212    use super::*;
1213    use crate::{build_ui, Flex, Runtime, VisualDensity};
1214    use frus_core::{Primitive, TargetPlatform};
1215
1216    #[derive(Clone, Debug, PartialEq)]
1217    enum Msg {
1218        Pressed,
1219        Held,
1220    }
1221
1222    /// A phone's theme: the standard density, so a button is the reference's 64 × 40 in a
1223    /// 48 px touch target.
1224    fn phone() -> Theme {
1225        Theme::dark().with_platform(TargetPlatform::Android)
1226    }
1227
1228    /// The button laid out alone at the top left of a large surface: the box it takes
1229    /// and what it paints.
1230    fn laid_out(button: Button<Msg>, theme: &Theme) -> (Rect, Vec<Primitive>) {
1231        let tree = Flex::<Msg>::column()
1232            .width(400.0)
1233            .height(300.0)
1234            .align(frus_layout::Align::Start)
1235            .child(button);
1236        let ui = build_ui(
1237            &tree as &dyn Widget<Msg>,
1238            Size::new(400.0, 300.0),
1239            &Runtime::default(),
1240            theme,
1241        );
1242        let prims = ui.scene().primitives().to_vec();
1243        let surface = prims
1244            .iter()
1245            .find_map(|p| match p {
1246                Primitive::Rect { rect, blur, .. } if *blur == 0.0 => Some(*rect),
1247                _ => None,
1248            })
1249            .unwrap_or(Rect::new(0.0, 0.0, 0.0, 0.0));
1250        (surface, prims)
1251    }
1252
1253    fn label_colour(prims: &[Primitive]) -> Option<Color> {
1254        prims.iter().find_map(|p| match p {
1255            Primitive::Text { color, .. } => Some(*color),
1256            _ => None,
1257        })
1258    }
1259
1260    fn surface_colour(prims: &[Primitive]) -> Option<Color> {
1261        prims.iter().find_map(|p| match p {
1262            Primitive::Rect { color, blur, .. } if *blur == 0.0 => Some(*color),
1263            _ => None,
1264        })
1265    }
1266
1267    /// **The reference's size**: at least 64 × 40, centred in a 48 px touch target, and
1268    /// as wide as its label and 24 px either side (`elevated_button.dart:593`, `:458`;
1269    /// `button_style_button.dart:578`).
1270    #[test]
1271    fn a_button_is_the_reference_s_size() {
1272        // Nothing but padding: 48 px, under the minimum.
1273        let (short, _) = laid_out(Button::new("").on_press(Msg::Pressed), &phone());
1274        assert_eq!((short.width, short.height), (64.0, 40.0), "the minimum");
1275        assert_eq!(short.y, 4.0, "centred in a 48 px target");
1276        let (long, _) = laid_out(
1277            Button::new("A rather longer label").on_press(Msg::Pressed),
1278            &phone(),
1279        );
1280        let text = frus_text::measure_style("A rather longer label", phone().text.label_large);
1281        assert!(
1282            (long.width - (text.width + 48.0)).abs() < 1.0,
1283            "the label and 24 px either side: {} vs {}",
1284            long.width,
1285            text.width
1286        );
1287        // A text button keeps 12 px either side and 8 above and below.
1288        let (text_button, _) = laid_out(
1289            Button::new("A rather longer label")
1290                .variant(Variant::Text)
1291                .on_press(Msg::Pressed),
1292            &phone(),
1293        );
1294        assert!((text_button.width - (text.width + 24.0)).abs() < 1.0);
1295    }
1296
1297    /// **The touch target gives way to a parent that has less room**: in a slot 40 px
1298    /// tall, a button is 40 px and overflows nothing, as the reference's input padding is
1299    /// constrained by its parent (`button_style_button.dart:696`).
1300    #[test]
1301    fn the_touch_target_gives_way_to_a_smaller_slot() {
1302        let t = phone();
1303        let tree = Flex::<Msg>::column()
1304            .width(400.0)
1305            .height(300.0)
1306            .align(frus_layout::Align::Start)
1307            .child(
1308                crate::Container::new()
1309                    .height(40.0)
1310                    .child(Button::new("Skip").on_press(Msg::Pressed)),
1311            );
1312        let (_, nodes) = crate::build_ui_inspected(
1313            &tree as &dyn Widget<Msg>,
1314            Size::new(400.0, 300.0),
1315            &Runtime::default(),
1316            &t,
1317        );
1318        let b = nodes.iter().find(|n| n.name == "Button").unwrap().rect;
1319        assert_eq!(b.height, 40.0, "{b:?}");
1320        // Free to grow, it keeps its room.
1321        let (free, _) = laid_out(Button::new("Skip").on_press(Msg::Pressed), &t);
1322        assert_eq!(
1323            (free.y, free.height),
1324            (4.0, 40.0),
1325            "a 40 px button in a 48 px target"
1326        );
1327    }
1328
1329    /// **The desktops are compact** (milestone 631): the minimum loses 8 px each way and
1330    /// so does the touch target, so the button is 56 × 32 in a 40 px target.
1331    #[test]
1332    fn a_desktop_s_button_is_compact() {
1333        let desk = Theme::dark().with_platform(TargetPlatform::Windows);
1334        let (b, _) = laid_out(Button::new("").on_press(Msg::Pressed), &desk);
1335        assert_eq!((b.width, b.height, b.y), (56.0, 32.0, 4.0));
1336        let told = desk.with_visual_density(VisualDensity::STANDARD);
1337        let (b, _) = laid_out(Button::new("").on_press(Msg::Pressed), &told);
1338        assert_eq!((b.width, b.height), (64.0, 40.0));
1339        // Shrink-wrapped, there is no room around it.
1340        let mut wrapped = phone();
1341        wrapped.tap_target = TapTarget::ShrinkWrap;
1342        let (b, _) = laid_out(Button::new("OK").on_press(Msg::Pressed), &wrapped);
1343        assert_eq!(b.y, 0.0);
1344    }
1345
1346    /// **The padding shrinks as the reader's text grows** (`button_style_button.dart:294`):
1347    /// 24 px either side at the standard size, 12 at twice it, 6 at three times.
1348    #[test]
1349    fn the_padding_follows_the_text_size() {
1350        let p = |scale: f32| {
1351            frus_core::with_text_scale(scale, || {
1352                default_style(Variant::Filled, &phone(), false)
1353                    .padding
1354                    .unwrap()
1355                    .resolve(WidgetStates::EMPTY)
1356                    .copied()
1357                    .unwrap()
1358                    .left
1359            })
1360        };
1361        assert_eq!(p(1.0), 24.0);
1362        assert_eq!(p(1.5), 18.0);
1363        assert_eq!(p(2.0), 12.0);
1364        assert_eq!(p(3.0), 6.0);
1365        // With an icon: 16 before and 24 after, in the reading direction.
1366        let icon = default_style(Variant::Filled, &phone(), true)
1367            .padding
1368            .unwrap()
1369            .resolve(WidgetStates::EMPTY)
1370            .copied()
1371            .unwrap();
1372        assert_eq!((icon.left, icon.right), (16.0, 24.0));
1373        let rtl = default_style(Variant::Filled, &phone().rtl(), true)
1374            .padding
1375            .unwrap()
1376            .resolve(WidgetStates::EMPTY)
1377            .copied()
1378            .unwrap();
1379        assert_eq!((rtl.left, rtl.right), (24.0, 16.0));
1380    }
1381
1382    /// **Each kind's colours are the reference's**, enabled and disabled; a button with
1383    /// nothing to do is disabled, as the reference's is.
1384    #[test]
1385    fn each_kind_has_the_reference_s_colours() {
1386        let t = phone();
1387        let c = &t.scheme;
1388        let colours = |v: Variant, on: bool| {
1389            let b = Button::new("OK").variant(v);
1390            let b = if on { b.on_press(Msg::Pressed) } else { b };
1391            let (_, prims) = laid_out(b, &t);
1392            (surface_colour(&prims), label_colour(&prims))
1393        };
1394        assert_eq!(
1395            colours(Variant::Filled, true),
1396            (Some(c.primary), Some(c.on_primary))
1397        );
1398        assert_eq!(
1399            colours(Variant::Tonal, true),
1400            (Some(c.secondary_container), Some(c.on_secondary_container))
1401        );
1402        assert_eq!(
1403            colours(Variant::Elevated, true),
1404            (Some(c.surface_container_low), Some(c.primary))
1405        );
1406        assert_eq!(colours(Variant::Text, true).1, Some(c.primary));
1407        // Nothing to do: disabled, on_surface at 12 % under on_surface at 38 %, resolved
1408        // over the surface.
1409        assert_eq!(
1410            colours(Variant::Filled, false),
1411            (
1412                Some(crate::disabled::disabled_container(&t)),
1413                Some(crate::disabled::disabled_content(&t))
1414            )
1415        );
1416        let off = Button::new("OK").variant(Variant::Filled);
1417        assert!(!Widget::<Msg>::focusable(&off));
1418        assert_eq!(Widget::<Msg>::on_click(&off), None);
1419        // A long press alone is something to do.
1420        let held = Button::new("OK").on_long_press(Msg::Held);
1421        assert!(Widget::<Msg>::focusable(&held));
1422        assert_eq!(Widget::<Msg>::on_long_press(&held), Some(Msg::Held));
1423    }
1424
1425    /// **The overlays are the reference's**: 8 % under a pointer, 10 % focused, and the
1426    /// ripple of a press 10 % — in the label's colour for a filled button, the accent for
1427    /// the others (`elevated_button.dart:547`, `filled_button.dart:565`).
1428    #[test]
1429    fn the_overlays_are_the_reference_s() {
1430        let t = phone();
1431        let c = &t.scheme;
1432        let b = Button::new("OK").on_press(Msg::Pressed);
1433        let ink = Widget::<Msg>::ink(&b, &t).unwrap();
1434        assert_eq!(ink.color, c.on_primary.with_alpha(0.1));
1435        let e = Button::new("OK")
1436            .variant(Variant::Elevated)
1437            .on_press(Msg::Pressed);
1438        assert_eq!(
1439            Widget::<Msg>::ink(&e, &t).unwrap().color,
1440            c.primary.with_alpha(0.1)
1441        );
1442        let hover = e.resolved(&t, WidgetStates::of(WidgetState::Hovered));
1443        assert_eq!(hover.overlay, Some(c.primary.with_alpha(0.08)));
1444        let focus = e.resolved(&t, WidgetStates::of(WidgetState::Focused));
1445        assert_eq!(focus.overlay, Some(c.primary.with_alpha(0.1)));
1446        // The elevations: an elevated button rests at 1, rises to 3 under a pointer and
1447        // is back at 1 pressed; a filled one is flat and rises to 1.
1448        assert_eq!(hover.elevation, 3.0);
1449        assert_eq!(e.resolved(&t, WidgetStates::EMPTY).elevation, 1.0);
1450        assert_eq!(
1451            e.resolved(&t, WidgetState::Pressed | WidgetState::Hovered)
1452                .elevation,
1453            1.0
1454        );
1455        assert_eq!(
1456            b.resolved(&t, WidgetStates::of(WidgetState::Hovered))
1457                .elevation,
1458            1.0
1459        );
1460        assert_eq!(b.resolved(&t, WidgetStates::EMPTY).elevation, 0.0);
1461    }
1462
1463    /// **An outlined button's outline** is the outline role, the accent while focused,
1464    /// and faint while disabled (`outlined_button.dart:553`).
1465    #[test]
1466    fn an_outline_follows_the_state() {
1467        let t = phone();
1468        let c = &t.scheme;
1469        let b = Button::<Msg>::new("OK")
1470            .variant(Variant::Outlined)
1471            .on_press(Msg::Pressed);
1472        let side = |s: WidgetStates| b.resolved(&t, s).side.unwrap().color;
1473        assert_eq!(side(WidgetStates::EMPTY), c.outline);
1474        assert_eq!(side(WidgetStates::of(WidgetState::Focused)), c.primary);
1475        assert_eq!(
1476            side(WidgetStates::of(WidgetState::Disabled)),
1477            crate::disabled::disabled_container(&t)
1478        );
1479    }
1480
1481    /// **Any widget as content, an icon beside it**: the content's text takes the
1482    /// button's label colour and its icons the button's icon colour and 18 px
1483    /// (`button_style_button.dart:550`).
1484    #[test]
1485    fn any_content_takes_the_button_s_colours() {
1486        let t = phone();
1487        let b = Button::with_child(crate::Text::new("Mine"))
1488            .icon(crate::Icon::new(crate::Icons::ADD))
1489            .on_press(Msg::Pressed);
1490        let (surface, prims) = laid_out(b, &t);
1491        assert_eq!(label_colour(&prims), Some(t.scheme.on_primary));
1492        let glyph = prims.iter().find_map(|p| match p {
1493            Primitive::Path { fill: Some(c), .. } => Some(*c),
1494            _ => None,
1495        });
1496        assert_eq!(
1497            glyph,
1498            Some(t.scheme.on_primary),
1499            "the icon in the label's colour"
1500        );
1501        // 16 before the icon, as the reference's `.icon` padding says.
1502        let icon_x = prims
1503            .iter()
1504            .find_map(|p| match p {
1505                Primitive::Path { path, .. } => path.verbs().iter().find_map(|v| match v {
1506                    frus_core::PathVerb::MoveTo(pt) => Some(pt.x),
1507                    _ => None,
1508                }),
1509                _ => None,
1510            })
1511            .unwrap();
1512        assert!(icon_x >= surface.x + 16.0 - 0.5, "{icon_x} vs {surface:?}");
1513    }
1514
1515    /// **A caller's style outranks the theme's, which outranks the default** — and a
1516    /// caller's plain colour leaves the disabled look alone.
1517    #[test]
1518    fn a_style_outranks_the_theme_which_outranks_the_default() {
1519        let mut t = phone();
1520        let red = Color::rgb8(200, 0, 0);
1521        let blue = Color::rgb8(0, 0, 200);
1522        t.widgets.button.filled_style =
1523            Some(ButtonStyle::new().background_color(WidgetStateProperty::all(red)));
1524        let (_, themed) = laid_out(Button::new("OK").on_press(Msg::Pressed), &t);
1525        assert_eq!(surface_colour(&themed), Some(red));
1526        let (_, mine) = laid_out(Button::new("OK").color(blue).on_press(Msg::Pressed), &t);
1527        assert_eq!(surface_colour(&mine), Some(blue));
1528        let (_, off) = laid_out(Button::new("OK").color(blue), &phone());
1529        assert_eq!(
1530            surface_colour(&off),
1531            Some(crate::disabled::disabled_container(&phone())),
1532            "a plain colour is the enabled one"
1533        );
1534        // A fixed size is kept.
1535        let sized = Button::new("OK")
1536            .style(ButtonStyle::new().fixed_size(Size::new(120.0, 50.0)))
1537            .on_press(Msg::Pressed);
1538        let (b, _) = laid_out(sized, &phone());
1539        assert_eq!((b.width, b.height), (120.0, 50.0));
1540    }
1541
1542    /// **A button is a stadium whatever its size**, unless told otherwise.
1543    #[test]
1544    fn a_button_is_a_stadium() {
1545        let (_, prims) = laid_out(Button::new("OK").on_press(Msg::Pressed), &phone());
1546        let radius = prims.iter().find_map(|p| match p {
1547            Primitive::Rect { radius, blur, .. } if *blur == 0.0 => Some(*radius),
1548            _ => None,
1549        });
1550        assert_eq!(radius, Some(BorderRadius::uniform(20.0)));
1551    }
1552}