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        // Transparent at rest, so it takes the colour of whatever surface
287        // it sits on, such as a dialog or popover.
288        Variant::Outline => Colours {
289            background: hover.then_some(tokens.accent),
290            foreground: tokens.foreground,
291            border: Some(tokens.border),
292        },
293        Variant::Ghost => Colours {
294            background: hover.then_some(tokens.accent),
295            foreground: tokens.foreground,
296            border: None,
297        },
298        Variant::Link => Colours {
299            background: None,
300            foreground: if hover {
301                mix(tokens.primary, tokens.foreground, 0.3)
302            } else {
303                tokens.primary
304            },
305            border: None,
306        },
307    };
308
309    if status != Status::Disabled {
310        return colours;
311    }
312    Colours {
313        background: colours.background.map(|c| fade(c, 0.5)),
314        foreground: fade(colours.foreground, 0.5),
315        border: colours.border.map(|c| fade(c, 0.5)),
316    }
317}
318
319/// The iced button style for a variant in a state.
320pub fn style(tokens: &Tokens, variant: Variant, status: Status) -> widget::button::Style {
321    let colours = colours(tokens, variant, status);
322    widget::button::Style {
323        background: colours.background.map(Background::Color),
324        text_color: colours.foreground,
325        border: Border {
326            color: colours.border.unwrap_or(iced::Color::TRANSPARENT),
327            width: if colours.border.is_some() { 1.0 } else { 0.0 },
328            radius: radius::MD.into(),
329        },
330        shadow: Shadow::default(),
331        snap: true,
332    }
333}
334
335#[cfg(test)]
336mod tests {
337    use super::*;
338    use crate::theme::{dark, light};
339
340    const STATES: [Status; 4] = [
341        Status::Active,
342        Status::Hovered,
343        Status::Pressed,
344        Status::Disabled,
345    ];
346
347    #[test]
348    fn default_builder_is_primary_medium_and_disabled() {
349        let b: Button<'_, ()> = button("Save");
350        assert_eq!(b.variant, Variant::Primary);
351        assert_eq!(b.size, Size::Md);
352        assert!(!b.is_enabled());
353    }
354
355    #[test]
356    fn links_have_no_side_padding_and_the_rest_keep_their_size() {
357        for size in Size::ALL {
358            assert_eq!(padding_x(size, Variant::Link), 0.0);
359            for variant in Variant::ALL.into_iter().filter(|v| *v != Variant::Link) {
360                assert_eq!(padding_x(size, variant), size.metrics().padding_x);
361            }
362        }
363    }
364
365    #[test]
366    fn on_press_enables_and_on_press_maybe_none_disables() {
367        assert!(button::<u8>("Go").on_press(1).is_enabled());
368        assert!(
369            !button::<u8>("Go")
370                .on_press(1)
371                .on_press_maybe(None)
372                .is_enabled()
373        );
374    }
375
376    #[test]
377    fn sizes_grow_monotonically() {
378        let heights = [Size::Sm, Size::Md, Size::Lg].map(|s| s.metrics().height);
379        assert!(heights.windows(2).all(|w| w[0] < w[1]));
380    }
381
382    #[test]
383    fn hover_changes_background_for_filled_variants() {
384        let tokens = Tokens::of(&light());
385        for variant in [
386            Variant::Primary,
387            Variant::Secondary,
388            Variant::Destructive,
389            Variant::Outline,
390            Variant::Ghost,
391        ] {
392            let active = colours(&tokens, variant, Status::Active);
393            let hovered = colours(&tokens, variant, Status::Hovered);
394            assert_ne!(active.background, hovered.background, "{variant:?}");
395        }
396    }
397
398    #[test]
399    fn pressed_is_stronger_than_hovered_for_primary() {
400        let tokens = Tokens::of(&light());
401        let hovered = colours(&tokens, Variant::Primary, Status::Hovered);
402        let pressed = colours(&tokens, Variant::Primary, Status::Pressed);
403        assert_ne!(hovered.background, pressed.background);
404    }
405
406    #[test]
407    fn disabled_halves_alpha_for_every_variant() {
408        for theme in [light(), dark()] {
409            let tokens = Tokens::of(&theme);
410            for variant in Variant::ALL {
411                let active = colours(&tokens, variant, Status::Active);
412                let disabled = colours(&tokens, variant, Status::Disabled);
413                assert!((disabled.foreground.a - active.foreground.a * 0.5).abs() < 1e-6);
414            }
415        }
416    }
417
418    #[test]
419    fn only_outline_has_a_border() {
420        let tokens = Tokens::of(&dark());
421        for variant in Variant::ALL {
422            for status in STATES {
423                let style = style(&tokens, variant, status);
424                assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
425            }
426        }
427    }
428
429    #[test]
430    fn outline_ghost_and_link_are_transparent_at_rest() {
431        for theme in [light(), dark()] {
432            let tokens = Tokens::of(&theme);
433            for variant in [Variant::Outline, Variant::Ghost, Variant::Link] {
434                for status in [Status::Active, Status::Disabled] {
435                    let background = colours(&tokens, variant, status).background;
436                    assert!(background.is_none(), "{variant:?} {status:?}");
437                }
438            }
439        }
440    }
441}