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