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