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