Skip to main content

frus_widgets/
iconbutton.rs

1//! [`IconButton`]: a button holding **one glyph**, in a box as wide as it is tall.
2//!
3//! It exists because milestone 313 kept finding the same thing. A `Button` is sized for a
4//! word — 64 px wide before its label is measured, 24 px of room either side — and a back
5//! arrow, a month arrow, a delete cross or a stepper's plus is not a word. Seven call sites
6//! in this repository were asking a button to be something it is not, and the reference has
7//! had the answer all along: 40 × 40, circular, no fill, the icon in `on_surface_variant`.
8//!
9//! ```ignore
10//! IconButton::new(Icons::CLOSE).label("Remove").on_press(Msg::Delete)
11//! IconButton::glyph("\u{2190}").label("Back").on_press(Msg::Pop)
12//! IconButton::new(Icons::STAR).selected(starred).on_press(Msg::Star)
13//! ```
14//!
15//! It takes either an icon from the bundled set or a **glyph**, because the set does not
16//! cover everything an application draws and a button that only accepts what happens to be
17//! bundled is a button that sends people back to `Button`.
18//!
19//! **A label is not optional in practice.** An icon says nothing to a screen reader, so
20//! [`IconButton::label`] is what makes it announceable — the reference's `tooltip`, doing
21//! the same job.
22
23use frus_core::{BorderRadius, Color, Point, Rect, Scene, TextStyle};
24use frus_layout::{Dimension, Style};
25
26use crate::disabled::{disabled_container, disabled_content};
27use crate::icons::IconData;
28use crate::interaction::Status;
29use crate::theme::{TapTarget, Theme};
30use crate::widget::Widget;
31
32/// The box: as wide as it is tall.
33pub const ICON_BUTTON_SIZE: f32 = 40.0;
34/// The glyph inside it.
35pub const ICON_BUTTON_ICON_SIZE: f32 = 24.0;
36/// An outlined icon button's outline.
37pub const ICON_BUTTON_BORDER_WIDTH: f32 = 1.0;
38
39/// How much of a surface an icon button brings with it.
40#[derive(Copy, Clone, Debug, PartialEq, Eq, Default)]
41pub enum IconButtonVariant {
42    /// No surface at all: the glyph, and the ink a tap leaves.
43    #[default]
44    Standard,
45    /// The accent, filled.
46    Filled,
47    /// A tonal fill.
48    Tonal,
49    /// An outline and no fill.
50    Outlined,
51}
52
53/// What the button draws: one of the bundled icons, or a glyph of your own.
54enum Content {
55    Icon(IconData),
56    Glyph(String),
57    /// A pair being crossed, and the end it is heading for — see
58    /// [`IconButton::animated`].
59    Morph(crate::icons::AnimatedIconData, f32),
60    /// A picture rather than a path — see [`IconButton::image`].
61    Image(crate::ImageIcon),
62}
63
64/// A button holding one glyph.
65pub struct IconButton<Msg = crate::callback::Callback> {
66    content: Content,
67    variant: IconButtonVariant,
68    selected: bool,
69    enabled: bool,
70    label: Option<String>,
71    on_press: Option<Msg>,
72    size: Option<f32>,
73    icon_size: Option<f32>,
74    color: Option<Color>,
75    icon_color: Option<Color>,
76    border_color: Option<Color>,
77    border_width: Option<f32>,
78    radius: Option<BorderRadius>,
79    tap_target: Option<TapTarget>,
80}
81
82impl<Msg> IconButton<Msg> {
83    /// A button showing one of the bundled icons.
84    pub fn new(icon: IconData) -> Self {
85        Self::of(Content::Icon(icon))
86    }
87
88    /// A button showing a **glyph** — an arrow, a cross, an ellipsis — for the many shapes
89    /// the bundled set does not carry.
90    pub fn glyph(glyph: impl Into<String>) -> Self {
91        Self::of(Content::Glyph(glyph.into()))
92    }
93
94    /// A button showing a **picture**: a brand mark, a flag, a glyph that is artwork
95    /// rather than a path.
96    ///
97    /// It is drawn at the button's own glyph size, like every other kind of mark here, so
98    /// a picture and an icon in one bar are the same size without either being told.
99    ///
100    /// ```ignore
101    /// IconButton::image(ImageIcon::new(Image::memory(MARK))).label("Home").on_press(msg)
102    /// ```
103    pub fn image(icon: crate::ImageIcon) -> Self {
104        Self::of(Content::Image(icon))
105    }
106
107    /// **A button whose mark crosses between two**, at the end `on` names — a drawer's
108    /// hamburger becoming a cross as the drawer opens, a play becoming a pause.
109    ///
110    /// The button does not time the crossing: it declares the end and the runtime drives
111    /// the value there, the way a drawer's own slide is driven. Two controls reading the
112    /// same flag are therefore at the same place on the same frame, because it is the
113    /// same rule moving both and not two timers that happen to agree.
114    ///
115    /// ```
116    /// use frus_widgets::{AnimatedIcons, IconButton};
117    ///
118    /// let drawer_open = false;
119    /// let _button = IconButton::<()>::animated(AnimatedIcons::MENU_CLOSE, drawer_open)
120    ///     .label("Menu")
121    ///     .on_press(());
122    /// ```
123    pub fn animated(icon: crate::icons::AnimatedIconData, on: bool) -> Self {
124        Self::of(Content::Morph(icon, if on { 1.0 } else { 0.0 }))
125    }
126
127    fn of(content: Content) -> Self {
128        Self {
129            content,
130            variant: IconButtonVariant::default(),
131            selected: false,
132            enabled: true,
133            label: None,
134            on_press: None,
135            size: None,
136            icon_size: None,
137            color: None,
138            icon_color: None,
139            border_color: None,
140            border_width: None,
141            radius: None,
142            tap_target: None,
143        }
144    }
145
146    /// What a screen reader announces. An icon has no text of its own, so without this the
147    /// button is a shape with no name.
148    pub fn label(mut self, label: impl Into<String>) -> Self {
149        self.label = Some(label.into());
150        self
151    }
152
153    /// Chooses how much surface the button brings.
154    pub fn variant(mut self, variant: IconButtonVariant) -> Self {
155        self.variant = variant;
156        self
157    }
158
159    /// Marks it **on** — a toggle that is currently pressed in. The glyph takes the accent.
160    pub fn selected(mut self, selected: bool) -> Self {
161        self.selected = selected;
162        self
163    }
164
165    /// Enables or disables it. Disabled, it is greyed out and emits nothing.
166    pub fn enabled(mut self, enabled: bool) -> Self {
167        self.enabled = enabled;
168        self
169    }
170
171    /// The message emitted on click.
172    pub fn on_press(mut self, message: impl Into<Msg>) -> Self {
173        self.on_press = Some(message.into());
174        self
175    }
176
177    /// The box's side.
178    pub fn size(mut self, size: f32) -> Self {
179        self.size = Some(size);
180        self
181    }
182
183    /// The glyph's size inside it.
184    pub fn icon_size(mut self, size: f32) -> Self {
185        self.icon_size = Some(size);
186        self
187    }
188
189    /// The surface under the glyph.
190    pub fn color(mut self, color: Color) -> Self {
191        self.color = Some(color);
192        self
193    }
194
195    /// The glyph's colour.
196    pub fn icon_color(mut self, color: Color) -> Self {
197        self.icon_color = Some(color);
198        self
199    }
200
201    /// The outline's colour.
202    pub fn border_color(mut self, color: Color) -> Self {
203        self.border_color = Some(color);
204        self
205    }
206
207    /// The outline's thickness; `0.0` removes it.
208    pub fn border_width(mut self, width: f32) -> Self {
209        self.border_width = Some(width);
210        self
211    }
212
213    /// The corner radii. Unset, the button is a **circle**.
214    pub fn radius(mut self, radius: impl Into<BorderRadius>) -> Self {
215        self.radius = Some(radius.into());
216        self
217    }
218
219    /// **How much room it reserves for a finger** ([`TapTarget`]). Unset, the theme's
220    /// answer, which is [`Padded`](TapTarget::Padded) — at least 48 pixels either way,
221    /// whatever this control paints in the middle of it.
222    pub fn tap_target(mut self, target: TapTarget) -> Self {
223        self.tap_target = Some(target);
224        self
225    }
226
227    fn side(&self, theme: &Theme) -> f32 {
228        self.size
229            .or(theme.widgets.icon_button.size)
230            .unwrap_or(ICON_BUTTON_SIZE)
231    }
232
233    /// The room this button reserves, resolved as `caller ?? theme ?? framework`
234    /// (`icon_button.dart:708`).
235    fn reserved(&self, theme: &Theme) -> f32 {
236        self.tap_target
237            .or(theme.widgets.icon_button.tap_target)
238            .unwrap_or(theme.tap_target)
239            .min_side()
240    }
241
242    /// The **face** — what the button paints — centred in the box it was given, which is
243    /// its tap target and is larger.
244    fn face(&self, bounds: Rect, theme: &Theme) -> Rect {
245        let side = self.side(theme);
246        Rect::new(
247            bounds.x + (bounds.width - side) * 0.5,
248            bounds.y + (bounds.height - side) * 0.5,
249            side,
250            side,
251        )
252    }
253
254    fn glyph_size(&self, theme: &Theme) -> f32 {
255        self.icon_size
256            .or(theme.widgets.icon_button.icon_size)
257            .unwrap_or(ICON_BUTTON_ICON_SIZE)
258    }
259
260    fn radius_of(&self, theme: &Theme, side: f32) -> BorderRadius {
261        self.radius
262            .or(theme.widgets.icon_button.radius)
263            // A circle, which is what an icon button is: the radius follows the box, so a
264            // smaller one stays round instead of becoming a square with soft corners.
265            .unwrap_or(BorderRadius::uniform(side / 2.0))
266    }
267
268    /// `(surface, glyph, outline)`.
269    fn palette(&self, theme: &Theme) -> (Color, Color, Option<Color>) {
270        if !self.enabled {
271            return (
272                match self.variant {
273                    IconButtonVariant::Standard | IconButtonVariant::Outlined => Color::TRANSPARENT,
274                    _ => disabled_container(theme),
275                },
276                disabled_content(theme),
277                // An outlined icon button **keeps its outline**, at the container opacity.
278                // Dropping it took the shape away entirely: milestone 324's golden showed a
279                // disabled stepper as two bare glyphs with no buttons around them, which
280                // reads as broken rather than as unavailable.
281                match self.variant {
282                    IconButtonVariant::Outlined => Some(disabled_container(theme)),
283                    _ => None,
284                },
285            );
286        }
287        let (surface, glyph, outline) = match self.variant {
288            IconButtonVariant::Standard => (
289                Color::TRANSPARENT,
290                if self.selected {
291                    theme.scheme.primary
292                } else {
293                    theme.scheme.on_surface_variant
294                },
295                None,
296            ),
297            IconButtonVariant::Filled => (theme.scheme.primary, theme.scheme.on_primary, None),
298            IconButtonVariant::Tonal => (
299                theme.scheme.secondary_container,
300                theme.scheme.on_secondary_container,
301                None,
302            ),
303            IconButtonVariant::Outlined => (
304                Color::TRANSPARENT,
305                if self.selected {
306                    theme.scheme.primary
307                } else {
308                    theme.scheme.on_surface_variant
309                },
310                Some(theme.scheme.outline),
311            ),
312        };
313        (
314            self.color
315                .or(theme.widgets.icon_button.color)
316                .unwrap_or(surface),
317            self.icon_color
318                .or(theme.widgets.icon_button.icon_color)
319                .unwrap_or(glyph),
320            self.border_color
321                .or(theme.widgets.icon_button.border_color)
322                .or(outline),
323        )
324    }
325}
326
327impl<Msg: Clone> Widget<Msg> for IconButton<Msg> {
328    fn style(&self) -> Style {
329        Widget::<Msg>::style_themed(self, &Theme::default())
330    }
331
332    /// **The box is the tap target, not the face** (`icon_button.dart:708`). An icon
333    /// button paints 40 pixels and the reference reserves 48 for the finger, painting the
334    /// face in the middle of it. This is the control most often placed at the very edge
335    /// of a bar, where the eight missing pixels are the ones a thumb reaches for.
336    fn style_themed(&self, theme: &Theme) -> Style {
337        let side = self.side(theme).max(self.reserved(theme));
338        Style {
339            width: Dimension::Length(side),
340            height: Dimension::Length(side),
341            ..Default::default()
342        }
343    }
344
345    fn children(&self) -> &[Box<dyn Widget<Msg>>] {
346        &[]
347    }
348
349    fn paint(&self, bounds: Rect, status: Status, theme: &Theme, scene: &mut Scene) {
350        let o = status.opacity;
351        let (surface, glyph, outline) = self.palette(theme);
352        let bounds = self.face(bounds, theme);
353        let radius = self.radius_of(theme, bounds.height.min(bounds.width));
354        let (border_width, border_color) = match (
355            self.border_width.or(theme.widgets.icon_button.border_width),
356            outline,
357        ) {
358            (Some(width), Some(color)) => (width, color),
359            (Some(width), None) => (width, theme.scheme.outline),
360            (None, Some(color)) => (ICON_BUTTON_BORDER_WIDTH, color),
361            (None, None) => (0.0, Color::TRANSPARENT),
362        };
363        let surface = if self.enabled {
364            theme.state_layer(surface, glyph, &status)
365        } else {
366            surface
367        };
368        scene.draw_rect(
369            bounds,
370            surface.fade(o),
371            radius,
372            border_width,
373            border_color.fade(o),
374        );
375
376        let size = self.glyph_size(theme);
377        match &self.content {
378            // The end of the animation is what an isolated frame draws — a test, a
379            // golden, the frame before the loop has advanced anything — which is the
380            // same rule `Status::value` already applies on mount.
381            Content::Morph(pair, _) => {
382                let path = pair.at(status.value).placed(
383                    size,
384                    bounds.x + (bounds.width - size) / 2.0,
385                    bounds.y + (bounds.height - size) / 2.0,
386                    theme.direction,
387                );
388                scene.fill_path(&path, glyph.fade(o));
389            }
390            Content::Icon(name) => {
391                let path = name.placed(
392                    size,
393                    bounds.x + (bounds.width - size) / 2.0,
394                    bounds.y + (bounds.height - size) / 2.0,
395                    theme.direction,
396                );
397                scene.fill_path(&path, glyph.fade(o));
398            }
399            // The picture takes the button's glyph size, which is what makes a mark drawn
400            // from a bitmap and one drawn from a path interchangeable in a row of actions.
401            Content::Image(icon) => {
402                let square = Rect::new(
403                    bounds.x + (bounds.width - size) / 2.0,
404                    bounds.y + (bounds.height - size) / 2.0,
405                    size,
406                    size,
407                );
408                icon.paint_at(square, status, theme, scene);
409            }
410            Content::Glyph(text) => {
411                let style = TextStyle::new(size);
412                let measured = frus_text::measure_styled(
413                    text,
414                    size,
415                    style.resolved().weight,
416                    style.resolved().italic,
417                );
418                scene.text(
419                    Point::new(
420                        bounds.x + (bounds.width - measured.width) / 2.0,
421                        bounds.y + (bounds.height - measured.height) / 2.0,
422                    ),
423                    text.clone(),
424                    &style.resolved(),
425                    glyph.fade(o),
426                );
427            }
428        }
429    }
430
431    /// Only a morphing button has an animated value; every other one answers `None` and
432    /// keeps the behaviour it had before this existed.
433    fn anim_target(&self) -> Option<f32> {
434        match self.content {
435            Content::Morph(_, target) => Some(target),
436            _ => None,
437        }
438    }
439
440    fn on_click(&self) -> Option<Msg> {
441        if self.enabled {
442            self.on_press.clone()
443        } else {
444            None
445        }
446    }
447
448    fn ink(&self, theme: &Theme) -> Option<crate::InkStyle> {
449        if !self.enabled {
450            return None;
451        }
452        let (_, glyph, _) = self.palette(theme);
453        let splash = theme.widgets.ink.color.unwrap_or_else(|| glyph.fade(0.16));
454        Some(
455            crate::InkStyle::of(theme)
456                .color(splash)
457                .radius(self.radius_of(theme, self.side(theme))),
458        )
459    }
460
461    fn focusable(&self) -> bool {
462        self.enabled
463    }
464
465    fn semantics(&self) -> Option<frus_core::SemanticsProperties> {
466        let semantics = frus_core::SemanticsProperties::new(frus_core::Role::Button);
467        let semantics = match &self.label {
468            Some(label) => semantics.label(label.clone()),
469            // Better than nothing, and visibly worse than a label: a glyph read out is at
470            // least something, an unnamed icon is silence.
471            None => match &self.content {
472                Content::Glyph(text) => semantics.label(text.clone()),
473                Content::Icon(_) | Content::Morph(..) | Content::Image(_) => semantics,
474            },
475        };
476        Some(if self.enabled {
477            semantics.toggled(self.selected).clickable()
478        } else {
479            semantics.disabled(true)
480        })
481    }
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use crate::icons::Icons;
488    use crate::theme::{MIN_TAP_TARGET, SHRUNK_TAP_TARGET};
489    use frus_core::Primitive;
490
491    #[derive(Clone, Debug, PartialEq)]
492    enum Msg {
493        Pressed,
494    }
495
496    fn painted(button: &IconButton<Msg>) -> Vec<Primitive> {
497        let mut scene = Scene::new();
498        Widget::<Msg>::paint(
499            button,
500            Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
501            Status::default(),
502            &Theme::default(),
503            &mut scene,
504        );
505        scene.primitives().to_vec()
506    }
507
508    /// **A morphing button is a path, and it follows the animated value.** The mark it
509    /// draws at half the crossing is neither end, and the button still says what it is
510    /// for through its label rather than through the mark — a shape that is halfway
511    /// between two things names neither of them.
512    #[test]
513    fn a_morphing_button_draws_the_crossing_and_still_says_what_it_does() {
514        let paint_at = |value: f32| {
515            let mut scene = Scene::new();
516            Widget::<Msg>::paint(
517                &IconButton::animated(crate::AnimatedIcons::MENU_CLOSE, true)
518                    .label("Menu")
519                    .on_press(Msg::Pressed),
520                Rect::new(0.0, 0.0, ICON_BUTTON_SIZE, ICON_BUTTON_SIZE),
521                Status {
522                    value,
523                    ..Status::default()
524                },
525                &Theme::default(),
526                &mut scene,
527            );
528            scene
529                .primitives()
530                .iter()
531                .find_map(|p| match p {
532                    Primitive::Path { path, .. } => Some(path.clone()),
533                    _ => None,
534                })
535                .expect("a morphing button paints a path")
536        };
537        assert_ne!(paint_at(0.0).verbs(), paint_at(0.5).verbs());
538        assert_ne!(paint_at(0.5).verbs(), paint_at(1.0).verbs());
539
540        let button = IconButton::animated(crate::AnimatedIcons::MENU_CLOSE, false)
541            .label("Menu")
542            .on_press(Msg::Pressed);
543        assert_eq!(Widget::<Msg>::anim_target(&button), Some(0.0));
544        assert_eq!(
545            Widget::<Msg>::anim_target(&IconButton::<Msg>::new(Icons::CLOSE)),
546            None,
547            "a button that is one mark has no animated value"
548        );
549    }
550
551    /// **An icon button paints 40 pixels and the reference reserves 48** (milestone 442,
552    /// `icon_button.dart:708`). It is the control most often put at the very edge of a
553    /// bar, where the eight missing pixels are the ones a thumb reaches for.
554    #[test]
555    fn an_icon_button_reserves_room_for_a_finger() {
556        let theme = Theme::dark();
557        let side = |button: &IconButton<()>, theme: &Theme| match Widget::<()>::style_themed(
558            button, theme,
559        )
560        .width
561        {
562            Dimension::Length(w) => w,
563            other => panic!("{other:?}"),
564        };
565        assert_eq!(side(&IconButton::new(Icons::CLOSE), &theme), MIN_TAP_TARGET);
566        assert_eq!(
567            side(
568                &IconButton::new(Icons::CLOSE).tap_target(TapTarget::ShrinkWrap),
569                &theme
570            ),
571            SHRUNK_TAP_TARGET
572        );
573        // A button asked for a face **larger** than the target keeps its own size: the
574        // target is a floor, not a size.
575        assert_eq!(
576            side(&IconButton::new(Icons::CLOSE).size(64.0), &theme),
577            64.0
578        );
579    }
580
581    /// And **the face it paints does not grow with the room**: it keeps its own side and
582    /// sits in the middle of it.
583    #[test]
584    fn the_face_is_centred_in_the_room() {
585        let theme = Theme::dark();
586        let mut scene = Scene::new();
587        Widget::<()>::paint(
588            &IconButton::new(Icons::CLOSE),
589            Rect::new(0.0, 0.0, MIN_TAP_TARGET, MIN_TAP_TARGET),
590            Status::default(),
591            &theme,
592            &mut scene,
593        );
594        let face = scene
595            .primitives()
596            .iter()
597            .find_map(|p| match p {
598                frus_core::Primitive::Rect { rect, .. } => Some(*rect),
599                _ => None,
600            })
601            .expect("an icon button paints its surface");
602        assert_eq!(face.width, ICON_BUTTON_SIZE);
603        assert_eq!(face.height, ICON_BUTTON_SIZE);
604        let inset = (MIN_TAP_TARGET - ICON_BUTTON_SIZE) * 0.5;
605        assert!(
606            (face.x - inset).abs() < 0.01 && (face.y - inset).abs() < 0.01,
607            "centred in the room: {face:?}"
608        );
609    }
610
611    #[test]
612    fn it_is_a_circle_as_wide_as_it_is_tall() {
613        let theme = Theme::default();
614        // The **box** is square at the tap target since milestone 442; the face it paints
615        // inside that box is square at its own side.
616        let style = Widget::<Msg>::style_themed(&IconButton::new(Icons::CLOSE), &theme);
617        assert_eq!(style.width, Dimension::Length(MIN_TAP_TARGET));
618        assert_eq!(style.height, Dimension::Length(MIN_TAP_TARGET));
619        let radius = painted(&IconButton::new(Icons::CLOSE))
620            .iter()
621            .find_map(|p| match p {
622                Primitive::Rect { radius, .. } => Some(*radius),
623                _ => None,
624            })
625            .expect("a box");
626        assert_eq!(radius, BorderRadius::uniform(ICON_BUTTON_SIZE / 2.0));
627    }
628
629    #[test]
630    fn a_standard_one_is_a_glyph_and_nothing_else() {
631        let theme = Theme::default();
632        let painted = painted(&IconButton::new(Icons::CLOSE));
633        let (color, border) = painted
634            .iter()
635            .find_map(|p| match p {
636                Primitive::Rect {
637                    color,
638                    border_width,
639                    ..
640                } => Some((*color, *border_width)),
641                _ => None,
642            })
643            .expect("a box");
644        assert_eq!(color, Color::TRANSPARENT, "no surface at rest");
645        assert_eq!(border, 0.0);
646        let glyph = painted
647            .iter()
648            .find_map(|p| match p {
649                Primitive::Path { fill, .. } => *fill,
650                _ => None,
651            })
652            .expect("an icon");
653        assert_eq!(glyph, theme.scheme.on_surface_variant);
654    }
655
656    #[test]
657    fn a_glyph_is_drawn_where_the_set_has_no_icon() {
658        // The bundled set has no back arrow, no minus, no ellipsis. A button that only took
659        // what happens to be bundled would send those call sites back to `Button`.
660        let painted = painted(&IconButton::<Msg>::glyph("\u{2190}"));
661        assert!(
662            painted
663                .iter()
664                .any(|p| matches!(p, Primitive::Text { text, .. } if text == "\u{2190}")),
665            "the glyph is drawn"
666        );
667    }
668
669    #[test]
670    fn selected_takes_the_accent() {
671        let theme = Theme::default();
672        let glyph = |selected: bool| {
673            painted(&IconButton::<Msg>::new(Icons::STAR).selected(selected))
674                .iter()
675                .find_map(|p| match p {
676                    Primitive::Path { fill, .. } => *fill,
677                    _ => None,
678                })
679                .expect("an icon")
680        };
681        assert_eq!(glyph(false), theme.scheme.on_surface_variant);
682        assert_eq!(glyph(true), theme.scheme.primary);
683    }
684
685    #[test]
686    fn the_variants_are_told_apart_by_their_surface() {
687        let theme = Theme::default();
688        let surface = |variant: IconButtonVariant| {
689            painted(&IconButton::<Msg>::new(Icons::CLOSE).variant(variant))
690                .iter()
691                .find_map(|p| match p {
692                    Primitive::Rect {
693                        color,
694                        border_width,
695                        ..
696                    } => Some((*color, *border_width)),
697                    _ => None,
698                })
699                .expect("a box")
700        };
701        assert_eq!(surface(IconButtonVariant::Filled).0, theme.scheme.primary);
702        assert_eq!(
703            surface(IconButtonVariant::Tonal).0,
704            theme.scheme.secondary_container
705        );
706        let outlined = surface(IconButtonVariant::Outlined);
707        assert_eq!(outlined.0, Color::TRANSPARENT);
708        assert_eq!(outlined.1, ICON_BUTTON_BORDER_WIDTH);
709    }
710
711    #[test]
712    fn disabled_is_inert_and_says_so() {
713        let button = IconButton::new(Icons::CLOSE)
714            .on_press(Msg::Pressed)
715            .enabled(false);
716        assert_eq!(Widget::on_click(&button), None);
717        assert!(!Widget::<Msg>::focusable(&button));
718        assert!(Widget::<Msg>::ink(&button, &Theme::default()).is_none());
719        let semantics = Widget::<Msg>::semantics(&button).expect("semantics");
720        assert!(semantics.disabled);
721        assert!(!semantics.clickable);
722    }
723
724    #[test]
725    fn an_icon_button_can_be_named() {
726        // An icon says nothing to a screen reader; the label is what it is announced by.
727        let named = IconButton::<Msg>::new(Icons::CLOSE).label("Remove");
728        assert_eq!(
729            Widget::<Msg>::semantics(&named).unwrap().label.as_deref(),
730            Some("Remove")
731        );
732        // A glyph at least reads as itself.
733        assert_eq!(
734            Widget::<Msg>::semantics(&IconButton::<Msg>::glyph("+"))
735                .unwrap()
736                .label
737                .as_deref(),
738            Some("+")
739        );
740    }
741
742    #[test]
743    fn every_measurement_is_the_callers_and_then_the_themes() {
744        let mut theme = Theme::default();
745        theme.widgets.icon_button.size = Some(56.0);
746        // The **face** — what the button paints — since milestone 442, because the box
747        // around it is the tap target and is a floor rather than a size.
748        let side = |button: IconButton<Msg>, theme: &Theme| {
749            let mut scene = Scene::new();
750            Widget::<Msg>::paint(
751                &button,
752                Rect::new(0.0, 0.0, 96.0, 96.0),
753                Status::default(),
754                theme,
755                &mut scene,
756            );
757            scene
758                .primitives()
759                .iter()
760                .find_map(|p| match p {
761                    Primitive::Rect { rect, .. } => Some(rect.width),
762                    _ => None,
763                })
764                .expect("a box")
765        };
766        assert_eq!(
767            side(IconButton::new(Icons::CLOSE), &Theme::default()),
768            ICON_BUTTON_SIZE,
769            "the framework's"
770        );
771        assert_eq!(
772            side(IconButton::new(Icons::CLOSE), &theme),
773            56.0,
774            "the theme's"
775        );
776        assert_eq!(
777            side(IconButton::new(Icons::CLOSE).size(32.0), &theme),
778            32.0,
779            "the caller's, over the theme's"
780        );
781        // And a face smaller than the target still gets the target's room around it: the
782        // control shrinks, the area a finger may land in does not.
783        assert_eq!(
784            match Widget::<Msg>::style_themed(&IconButton::new(Icons::CLOSE).size(32.0), &theme)
785                .width
786            {
787                Dimension::Length(w) => w,
788                other => panic!("{other:?}"),
789            },
790            MIN_TAP_TARGET
791        );
792    }
793}