Skip to main content

iced_cube/primitives/
button.rs

1//! Buttons with consistent variants, sizes and states.
2
3use iced::widget::{self, button::Status, row, text};
4use iced::{Alignment, Background, Border, Element, Length, Padding, Shadow, Theme};
5
6use crate::icon::{Glyph, icon, tinted};
7use crate::theme::{Tokens, fade, mix, radius, text_size};
8
9/// Visual emphasis of a button.
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
11pub enum Variant {
12    #[default]
13    Primary,
14    Secondary,
15    Destructive,
16    Outline,
17    Ghost,
18    Link,
19}
20
21impl Variant {
22    pub const ALL: [Variant; 6] = [
23        Variant::Primary,
24        Variant::Secondary,
25        Variant::Destructive,
26        Variant::Outline,
27        Variant::Ghost,
28        Variant::Link,
29    ];
30}
31
32/// Button dimensions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum Size {
35    Sm,
36    #[default]
37    Md,
38    Lg,
39    /// Square, for icon-only buttons.
40    Icon,
41}
42
43impl Size {
44    pub const ALL: [Size; 4] = [Size::Sm, Size::Md, Size::Lg, Size::Icon];
45
46    /// Height, horizontal padding, text size and icon size.
47    pub const fn metrics(self) -> Metrics {
48        match self {
49            Size::Sm => Metrics {
50                height: 32.0,
51                padding_x: 12.0,
52                text: text_size::SM,
53                icon: 14.0,
54            },
55            Size::Md => Metrics {
56                height: 36.0,
57                padding_x: 16.0,
58                text: text_size::SM,
59                icon: 16.0,
60            },
61            Size::Lg => Metrics {
62                height: 40.0,
63                padding_x: 24.0,
64                text: text_size::MD,
65                icon: 18.0,
66            },
67            Size::Icon => Metrics {
68                height: 36.0,
69                padding_x: 0.0,
70                text: text_size::SM,
71                icon: 16.0,
72            },
73        }
74    }
75}
76
77/// Resolved dimensions for a [`Size`].
78#[derive(Debug, Clone, Copy, PartialEq)]
79pub struct Metrics {
80    pub height: f32,
81    pub padding_x: f32,
82    pub text: f32,
83    pub icon: f32,
84}
85
86/// A button builder. Convert it into an [`Element`] to render.
87///
88/// A button without a message is rendered disabled.
89#[derive(Debug)]
90pub struct Button<'a, Message> {
91    label: Option<text::Fragment<'a>>,
92    leading: Option<Glyph>,
93    trailing: Option<Glyph>,
94    variant: Variant,
95    size: Size,
96    width: Length,
97    on_press: Option<Message>,
98}
99
100/// Creates a button with a text label.
101pub fn button<'a, Message>(label: impl text::IntoFragment<'a>) -> Button<'a, Message> {
102    Button {
103        label: Some(label.into_fragment()),
104        leading: None,
105        trailing: None,
106        variant: Variant::default(),
107        size: Size::default(),
108        width: Length::Shrink,
109        on_press: None,
110    }
111}
112
113/// Creates a square icon-only button.
114pub fn icon_button<'a, Message>(glyph: Glyph) -> Button<'a, Message> {
115    Button {
116        label: None,
117        leading: Some(glyph),
118        trailing: None,
119        variant: Variant::Ghost,
120        size: Size::Icon,
121        width: Length::Shrink,
122        on_press: None,
123    }
124}
125
126impl<'a, Message> Button<'a, Message> {
127    pub fn variant(mut self, variant: Variant) -> Self {
128        self.variant = variant;
129        self
130    }
131
132    pub fn size(mut self, size: Size) -> Self {
133        self.size = size;
134        self
135    }
136
137    /// Adds an icon before the label.
138    pub fn icon(mut self, glyph: Glyph) -> Self {
139        self.leading = Some(glyph);
140        self
141    }
142
143    /// Adds an icon after the label.
144    pub fn trailing_icon(mut self, glyph: Glyph) -> Self {
145        self.trailing = Some(glyph);
146        self
147    }
148
149    pub fn width(mut self, width: impl Into<Length>) -> Self {
150        self.width = width.into();
151        self
152    }
153
154    pub fn on_press(mut self, message: Message) -> Self {
155        self.on_press = Some(message);
156        self
157    }
158
159    pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
160        self.on_press = message;
161        self
162    }
163
164    pub fn is_enabled(&self) -> bool {
165        self.on_press.is_some()
166    }
167}
168
169impl<'a, Message: Clone + 'a> From<Button<'a, Message>> for Element<'a, Message> {
170    fn from(button: Button<'a, Message>) -> Self {
171        let metrics = button.size.metrics();
172        let variant = button.variant;
173        let enabled = button.is_enabled();
174
175        let glyph = move |glyph: Glyph| -> Element<'a, Message> {
176            if variant == Variant::Link {
177                return icon(glyph, metrics.icon).into();
178            }
179            let theme_icon = tinted(glyph, metrics.icon, None).style(move |theme: &Theme, _| {
180                let colours = colours(&Tokens::of(theme), variant, status_for(enabled));
181                widget::svg::Style {
182                    color: Some(colours.foreground),
183                }
184            });
185            theme_icon.into()
186        };
187
188        let mut content = row![].spacing(8).align_y(Alignment::Center);
189        if let Some(leading) = button.leading {
190            content = content.push(glyph(leading));
191        }
192        if let Some(label) = button.label {
193            content = content.push(text(label).size(metrics.text));
194        }
195        if let Some(trailing) = button.trailing {
196            content = content.push(glyph(trailing));
197        }
198
199        let (width, padding) = match button.size {
200            Size::Icon => (
201                Length::Fixed(metrics.height),
202                Padding::from((metrics.height - metrics.icon) / 2.0),
203            ),
204            _ => (button.width, Padding::from([0.0, metrics.padding_x])),
205        };
206
207        widget::button(
208            widget::container(content)
209                .height(Length::Fill)
210                .align_y(Alignment::Center)
211                .align_x(Alignment::Center)
212                .width(Length::Fill),
213        )
214        .width(width)
215        .height(Length::Fixed(metrics.height))
216        .padding(padding)
217        .on_press_maybe(button.on_press)
218        .style(move |theme, status| style(&Tokens::of(theme), variant, status))
219        .into()
220    }
221}
222
223fn status_for(enabled: bool) -> Status {
224    if enabled {
225        Status::Active
226    } else {
227        Status::Disabled
228    }
229}
230
231/// Background, foreground and border colours for a variant in a state.
232#[derive(Debug, Clone, Copy, PartialEq)]
233pub struct Colours {
234    pub background: Option<iced::Color>,
235    pub foreground: iced::Color,
236    pub border: Option<iced::Color>,
237}
238
239/// Resolves the colours of a variant for a given interaction state.
240pub fn colours(tokens: &Tokens, variant: Variant, status: Status) -> Colours {
241    let hover = matches!(status, Status::Hovered | Status::Pressed);
242    let pressed = status == Status::Pressed;
243    let emphasise = |base: iced::Color| {
244        let amount = if pressed { 0.2 } else { 0.1 };
245        let target = if tokens.is_dark {
246            iced::Color::BLACK
247        } else {
248            iced::Color::WHITE
249        };
250        if hover {
251            mix(base, target, amount)
252        } else {
253            base
254        }
255    };
256
257    let colours = match variant {
258        Variant::Primary => Colours {
259            background: Some(emphasise(tokens.primary)),
260            foreground: tokens.primary_foreground,
261            border: None,
262        },
263        Variant::Secondary => Colours {
264            background: Some(if hover {
265                mix(tokens.secondary, tokens.border, 0.5)
266            } else {
267                tokens.secondary
268            }),
269            foreground: tokens.secondary_foreground,
270            border: None,
271        },
272        Variant::Destructive => Colours {
273            background: Some(emphasise(tokens.destructive)),
274            foreground: tokens.destructive_foreground,
275            border: None,
276        },
277        Variant::Outline => Colours {
278            background: Some(if hover {
279                tokens.accent
280            } else {
281                tokens.background
282            }),
283            foreground: tokens.foreground,
284            border: Some(tokens.border),
285        },
286        Variant::Ghost => Colours {
287            background: hover.then_some(tokens.accent),
288            foreground: tokens.foreground,
289            border: None,
290        },
291        Variant::Link => Colours {
292            background: None,
293            foreground: if hover {
294                mix(tokens.primary, tokens.foreground, 0.3)
295            } else {
296                tokens.primary
297            },
298            border: None,
299        },
300    };
301
302    if status != Status::Disabled {
303        return colours;
304    }
305    Colours {
306        background: colours.background.map(|c| fade(c, 0.5)),
307        foreground: fade(colours.foreground, 0.5),
308        border: colours.border.map(|c| fade(c, 0.5)),
309    }
310}
311
312/// The iced button style for a variant in a state.
313pub fn style(tokens: &Tokens, variant: Variant, status: Status) -> widget::button::Style {
314    let colours = colours(tokens, variant, status);
315    widget::button::Style {
316        background: colours.background.map(Background::Color),
317        text_color: colours.foreground,
318        border: Border {
319            color: colours.border.unwrap_or(iced::Color::TRANSPARENT),
320            width: if colours.border.is_some() { 1.0 } else { 0.0 },
321            radius: radius::MD.into(),
322        },
323        shadow: Shadow::default(),
324        snap: true,
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::theme::{dark, light};
332
333    const STATES: [Status; 4] = [
334        Status::Active,
335        Status::Hovered,
336        Status::Pressed,
337        Status::Disabled,
338    ];
339
340    #[test]
341    fn default_builder_is_primary_medium_and_disabled() {
342        let b: Button<'_, ()> = button("Save");
343        assert_eq!(b.variant, Variant::Primary);
344        assert_eq!(b.size, Size::Md);
345        assert!(!b.is_enabled());
346    }
347
348    #[test]
349    fn on_press_enables_and_on_press_maybe_none_disables() {
350        assert!(button::<u8>("Go").on_press(1).is_enabled());
351        assert!(
352            !button::<u8>("Go")
353                .on_press(1)
354                .on_press_maybe(None)
355                .is_enabled()
356        );
357    }
358
359    #[test]
360    fn icon_button_is_square_ghost() {
361        let b: Button<'_, ()> = icon_button(crate::lucide!(Plus));
362        assert_eq!(b.size, Size::Icon);
363        assert_eq!(b.variant, Variant::Ghost);
364        assert!(b.label.is_none());
365    }
366
367    #[test]
368    fn sizes_grow_monotonically() {
369        let heights = [Size::Sm, Size::Md, Size::Lg].map(|s| s.metrics().height);
370        assert!(heights.windows(2).all(|w| w[0] < w[1]));
371    }
372
373    #[test]
374    fn hover_changes_background_for_filled_variants() {
375        let tokens = Tokens::of(&light());
376        for variant in [
377            Variant::Primary,
378            Variant::Secondary,
379            Variant::Destructive,
380            Variant::Outline,
381            Variant::Ghost,
382        ] {
383            let active = colours(&tokens, variant, Status::Active);
384            let hovered = colours(&tokens, variant, Status::Hovered);
385            assert_ne!(active.background, hovered.background, "{variant:?}");
386        }
387    }
388
389    #[test]
390    fn pressed_is_stronger_than_hovered_for_primary() {
391        let tokens = Tokens::of(&light());
392        let hovered = colours(&tokens, Variant::Primary, Status::Hovered);
393        let pressed = colours(&tokens, Variant::Primary, Status::Pressed);
394        assert_ne!(hovered.background, pressed.background);
395    }
396
397    #[test]
398    fn disabled_halves_alpha_for_every_variant() {
399        for theme in [light(), dark()] {
400            let tokens = Tokens::of(&theme);
401            for variant in Variant::ALL {
402                let active = colours(&tokens, variant, Status::Active);
403                let disabled = colours(&tokens, variant, Status::Disabled);
404                assert!((disabled.foreground.a - active.foreground.a * 0.5).abs() < 1e-6);
405            }
406        }
407    }
408
409    #[test]
410    fn only_outline_has_a_border() {
411        let tokens = Tokens::of(&dark());
412        for variant in Variant::ALL {
413            for status in STATES {
414                let style = style(&tokens, variant, status);
415                assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
416            }
417        }
418    }
419
420    #[test]
421    fn ghost_and_link_are_transparent_at_rest() {
422        let tokens = Tokens::of(&light());
423        assert!(
424            colours(&tokens, Variant::Ghost, Status::Active)
425                .background
426                .is_none()
427        );
428        assert!(
429            colours(&tokens, Variant::Link, Status::Active)
430                .background
431                .is_none()
432        );
433    }
434}