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