Skip to main content

iced_cube/primitives/
icon_button.rs

1//! Square, icon-only buttons with a label and an optional pressed state.
2//!
3//! Icon buttons share their [`Size`] and colours with
4//! [`button`](fn@crate::primitives::button), and every button variant except
5//! `Link`. A pressed icon button stays highlighted, which suits toolbar
6//! toggles such as bold or italic.
7
8use iced::widget::{self, button::Status, container, text};
9use iced::{Background, Border, Element, Length, Padding, Shadow};
10
11use crate::after_touch::after_touch;
12use crate::icon::{Glyph, opacity, themed};
13use crate::overlay::tooltip::{Position, tooltip};
14use crate::primitives::button::{self, Colours, Size};
15use crate::theme::{Tokens, fade, mix, radius};
16
17/// Visual emphasis of an icon button.
18///
19/// These are the text button variants without `Link`, which needs text to
20/// read as a link and on an icon alone looks just like `Ghost`.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
22pub enum Variant {
23    Primary,
24    Secondary,
25    Destructive,
26    Outline,
27    #[default]
28    Ghost,
29}
30
31impl Variant {
32    pub const ALL: [Variant; 5] = [
33        Variant::Primary,
34        Variant::Secondary,
35        Variant::Destructive,
36        Variant::Outline,
37        Variant::Ghost,
38    ];
39
40    /// The text button variant with the same colours.
41    pub const fn button(self) -> button::Variant {
42        match self {
43            Variant::Primary => button::Variant::Primary,
44            Variant::Secondary => button::Variant::Secondary,
45            Variant::Destructive => button::Variant::Destructive,
46            Variant::Outline => button::Variant::Outline,
47            Variant::Ghost => button::Variant::Ghost,
48        }
49    }
50}
51
52impl From<Variant> for button::Variant {
53    fn from(variant: Variant) -> Self {
54        variant.button()
55    }
56}
57
58/// An icon button builder. Convert it into an [`Element`] to render.
59///
60/// An icon button without a message is rendered disabled.
61#[derive(Debug)]
62pub struct IconButton<'a, Message> {
63    glyph: Glyph,
64    label: Option<text::Fragment<'a>>,
65    tooltip: Option<Position>,
66    variant: Variant,
67    size: Size,
68    pressed: bool,
69    id: Option<widget::Id>,
70    on_press: Option<Message>,
71}
72
73/// Creates a square ghost button showing `glyph`.
74pub fn icon_button<'a, Message>(glyph: Glyph) -> IconButton<'a, Message> {
75    IconButton {
76        glyph,
77        label: None,
78        tooltip: Some(Position::default()),
79        variant: Variant::Ghost,
80        size: Size::Md,
81        pressed: false,
82        id: None,
83        on_press: None,
84    }
85}
86
87impl<'a, Message> IconButton<'a, Message> {
88    /// Names the action, such as "Bold". The label shows as a tooltip on
89    /// hover unless [`tooltip`](Self::tooltip) turns it off.
90    pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
91        self.label = Some(label.into_fragment());
92        self
93    }
94
95    /// Where the label's tooltip appears, or `None` to never show it.
96    /// Defaults to above the button.
97    pub fn tooltip(mut self, position: Option<Position>) -> Self {
98        self.tooltip = position;
99        self
100    }
101
102    pub fn variant(mut self, variant: Variant) -> Self {
103        self.variant = variant;
104        self
105    }
106
107    /// The side length follows the button [`Size`]: `Sm` is 32 pixels,
108    /// `Md` is 36 and `Lg` is 40.
109    pub fn size(mut self, size: Size) -> Self {
110        self.size = size;
111        self
112    }
113
114    /// Draws the button as switched on, for toggles in a toolbar.
115    pub fn pressed(mut self, pressed: bool) -> Self {
116        self.pressed = pressed;
117        self
118    }
119
120    /// Sets a widget id, for clicking the button in tests.
121    pub fn id(mut self, id: impl Into<widget::Id>) -> Self {
122        self.id = Some(id.into());
123        self
124    }
125
126    pub fn on_press(mut self, message: Message) -> Self {
127        self.on_press = Some(message);
128        self
129    }
130
131    pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
132        self.on_press = message;
133        self
134    }
135
136    pub fn is_enabled(&self) -> bool {
137        self.on_press.is_some()
138    }
139
140    pub fn is_pressed(&self) -> bool {
141        self.pressed
142    }
143
144    /// The label, if one is set.
145    pub fn label_text(&self) -> Option<&str> {
146        self.label.as_deref()
147    }
148}
149
150impl<'a, Message: Clone + 'a> From<IconButton<'a, Message>> for Element<'a, Message> {
151    fn from(icon_button: IconButton<'a, Message>) -> Self {
152        let IconButton {
153            glyph,
154            label,
155            tooltip: position,
156            variant,
157            size,
158            pressed,
159            id,
160            on_press,
161        } = icon_button;
162        let metrics = size.metrics();
163        let enabled = on_press.is_some();
164        let resting = if enabled {
165            Status::Active
166        } else {
167            Status::Disabled
168        };
169
170        let glyph = themed(glyph, metrics.icon, opacity(enabled), move |theme| {
171            colours(&Tokens::of(theme), variant, pressed, resting).foreground
172        });
173
174        let button = widget::button(glyph)
175            .width(Length::Fixed(metrics.height))
176            .height(Length::Fixed(metrics.height))
177            .padding(Padding::from((metrics.height - metrics.icon) / 2.0))
178            .on_press_maybe(on_press)
179            .style(move |theme, status| style(&Tokens::of(theme), variant, pressed, status));
180
181        let mut element: Element<'a, Message> = match id {
182            Some(id) => container(after_touch(button)).id(id).into(),
183            None => after_touch(button),
184        };
185        if let (Some(label), Some(position)) = (label, position) {
186            element = tooltip(element, label).position(position).into();
187        }
188        element
189    }
190}
191
192/// Colours of an icon button, switched on or off, in a state.
193///
194/// A pressed button looks held down at rest and darkens a little more on
195/// hover, so it reads as on in every state.
196pub fn colours(tokens: &Tokens, variant: Variant, pressed: bool, status: Status) -> Colours {
197    let variant = variant.button();
198    if !pressed {
199        return button::colours(tokens, variant, status);
200    }
201
202    let held = button::colours(tokens, variant, Status::Pressed);
203    match status {
204        Status::Active => held,
205        Status::Hovered | Status::Pressed => Colours {
206            background: held
207                .background
208                .map(|background| mix(background, tokens.foreground, 0.08)),
209            ..held
210        },
211        Status::Disabled => Colours {
212            background: held.background.map(|c| fade(c, 0.5)),
213            foreground: fade(held.foreground, 0.5),
214            border: held.border.map(|c| fade(c, 0.5)),
215        },
216    }
217}
218
219/// The iced button style of an icon button, switched on or off, in a state.
220pub fn style(
221    tokens: &Tokens,
222    variant: Variant,
223    pressed: bool,
224    status: Status,
225) -> widget::button::Style {
226    let colours = colours(tokens, variant, pressed, status);
227    widget::button::Style {
228        background: colours.background.map(Background::Color),
229        text_color: colours.foreground,
230        border: Border {
231            color: colours.border.unwrap_or(iced::Color::TRANSPARENT),
232            width: if colours.border.is_some() { 1.0 } else { 0.0 },
233            radius: radius::MD.into(),
234        },
235        shadow: Shadow::default(),
236        snap: true,
237    }
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243    use crate::theme::{dark, light};
244
245    const STATES: [Status; 4] = [
246        Status::Active,
247        Status::Hovered,
248        Status::Pressed,
249        Status::Disabled,
250    ];
251
252    #[test]
253    fn defaults_to_a_medium_ghost_button_that_is_off_and_disabled() {
254        let b: IconButton<'_, ()> = icon_button(crate::lucide!(Plus));
255        assert_eq!(b.variant, Variant::Ghost);
256        assert_eq!(b.size, Size::Md);
257        assert!(!b.is_pressed());
258        assert!(!b.is_enabled());
259        assert!(b.label_text().is_none());
260        assert_eq!(b.tooltip, Some(Position::Top));
261    }
262
263    #[test]
264    fn builder_sets_every_option() {
265        let b = icon_button::<u8>(crate::lucide!(Bold))
266            .label("Bold")
267            .tooltip(None)
268            .variant(Variant::Outline)
269            .size(Size::Sm)
270            .pressed(true)
271            .on_press(1);
272        assert_eq!(b.label_text(), Some("Bold"));
273        assert!(b.tooltip.is_none());
274        assert_eq!(b.variant, Variant::Outline);
275        assert_eq!(b.size, Size::Sm);
276        assert!(b.is_pressed());
277        assert!(b.is_enabled());
278        assert!(!b.on_press_maybe(None).is_enabled());
279    }
280
281    #[test]
282    fn unpressed_matches_the_button_colours() {
283        for theme in [light(), dark()] {
284            let tokens = Tokens::of(&theme);
285            for variant in Variant::ALL {
286                for status in STATES {
287                    assert_eq!(
288                        colours(&tokens, variant, false, status),
289                        button::colours(&tokens, variant.into(), status),
290                        "{variant:?} {status:?}"
291                    );
292                }
293            }
294        }
295    }
296
297    #[test]
298    fn variants_map_to_every_button_variant_but_link() {
299        let mapped = Variant::ALL.map(Variant::button);
300        assert!(!mapped.contains(&button::Variant::Link));
301        for variant in button::Variant::ALL {
302            assert_eq!(
303                mapped.contains(&variant),
304                variant != button::Variant::Link,
305                "{variant:?}"
306            );
307        }
308    }
309
310    #[test]
311    fn pressed_ghost_and_outline_use_the_accent_at_rest() {
312        for theme in [light(), dark()] {
313            let tokens = Tokens::of(&theme);
314            for variant in [Variant::Ghost, Variant::Outline] {
315                let on = colours(&tokens, variant, true, Status::Active);
316                assert_eq!(on.background, Some(tokens.accent), "{variant:?}");
317                assert_ne!(
318                    on.background,
319                    colours(&tokens, variant, false, Status::Active).background
320                );
321            }
322        }
323    }
324
325    #[test]
326    fn pressed_differs_from_unpressed_at_rest_for_filled_variants() {
327        for theme in [light(), dark()] {
328            let tokens = Tokens::of(&theme);
329            for variant in [
330                Variant::Primary,
331                Variant::Secondary,
332                Variant::Destructive,
333                Variant::Outline,
334                Variant::Ghost,
335            ] {
336                assert_ne!(
337                    colours(&tokens, variant, true, Status::Active).background,
338                    colours(&tokens, variant, false, Status::Active).background,
339                    "{variant:?}"
340                );
341            }
342        }
343    }
344
345    #[test]
346    fn hovering_a_pressed_button_darkens_it() {
347        for theme in [light(), dark()] {
348            let tokens = Tokens::of(&theme);
349            for variant in [Variant::Ghost, Variant::Outline, Variant::Primary] {
350                let rest = colours(&tokens, variant, true, Status::Active);
351                for status in [Status::Hovered, Status::Pressed] {
352                    let active = colours(&tokens, variant, true, status);
353                    assert_ne!(active.background, rest.background, "{variant:?} {status:?}");
354                    assert_eq!(active.foreground, rest.foreground);
355                }
356            }
357        }
358    }
359
360    #[test]
361    fn disabled_pressed_keeps_the_on_look_at_half_alpha() {
362        for theme in [light(), dark()] {
363            let tokens = Tokens::of(&theme);
364            for variant in Variant::ALL {
365                let on = colours(&tokens, variant, true, Status::Active);
366                let disabled = colours(&tokens, variant, true, Status::Disabled);
367                assert!((disabled.foreground.a - on.foreground.a * 0.5).abs() < 1e-6);
368                assert_eq!(
369                    disabled.background.map(|c| c.a),
370                    on.background.map(|c| c.a * 0.5)
371                );
372            }
373        }
374    }
375
376    #[test]
377    fn only_outline_has_a_border_in_every_state() {
378        let tokens = Tokens::of(&light());
379        for variant in Variant::ALL {
380            for pressed in [false, true] {
381                for status in STATES {
382                    let style = style(&tokens, variant, pressed, status);
383                    assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
384                    assert_eq!(style.border.radius, radius::MD.into());
385                }
386            }
387        }
388    }
389}