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