Skip to main content

iced_cube/primitives/
toggle.rs

1//! Two-state buttons that stay pressed while an option is on.
2//!
3//! A toggle is a button with a pressed state, for options such as "Bold" or
4//! "Show grid" that sit among other buttons rather than in a form. The app
5//! owns the bool and flips it in `on_toggle`, as with a switch. For a set of
6//! toggles where one or several can be on, use
7//! [`toggle_group`](mod@crate::primitives::toggle_group).
8
9use iced::keyboard::key::Named;
10use iced::widget::{self, button::Status, row, text};
11use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Shadow};
12
13use crate::icon::{Glyph, opacity, themed};
14use crate::keys::{self, Chord, Keymap};
15use crate::natural::natural;
16use crate::theme::{Tokens, fade, mix, radius, text_size};
17
18/// How a toggle looks while it is off.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum Variant {
21    /// Transparent until hovered or pressed.
22    #[default]
23    Default,
24    /// A bordered button, so the toggle reads as a control at rest.
25    Outline,
26}
27
28impl Variant {
29    pub const ALL: [Variant; 2] = [Variant::Default, Variant::Outline];
30}
31
32/// Toggle dimensions.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
34pub enum Size {
35    Sm,
36    #[default]
37    Md,
38    Lg,
39}
40
41impl Size {
42    pub const ALL: [Size; 3] = [Size::Sm, Size::Md, Size::Lg];
43
44    /// Height, horizontal padding, text size and icon size.
45    pub const fn metrics(self) -> Metrics {
46        match self {
47            Size::Sm => Metrics {
48                height: 32.0,
49                padding_x: 10.0,
50                text: text_size::SM,
51                icon: 14.0,
52            },
53            Size::Md => Metrics {
54                height: 36.0,
55                padding_x: 12.0,
56                text: text_size::SM,
57                icon: 16.0,
58            },
59            Size::Lg => Metrics {
60                height: 40.0,
61                padding_x: 16.0,
62                text: text_size::MD,
63                icon: 18.0,
64            },
65        }
66    }
67}
68
69/// Resolved dimensions for a [`Size`].
70#[derive(Debug, Clone, Copy, PartialEq)]
71pub struct Metrics {
72    pub height: f32,
73    pub padding_x: f32,
74    pub text: f32,
75    pub icon: f32,
76}
77
78/// A toggle builder. Convert it into an [`Element`] to render.
79///
80/// A toggle without [`on_toggle`](Toggle::on_toggle) is rendered disabled.
81pub struct Toggle<'a, Message> {
82    label: text::Fragment<'a>,
83    icon: Option<Glyph>,
84    pressed: bool,
85    variant: Variant,
86    size: Size,
87    on_toggle: Option<Box<dyn Fn(bool) -> Message + 'a>>,
88}
89
90/// Creates a toggle with a text label, switched off.
91pub fn toggle<'a, Message>(label: impl text::IntoFragment<'a>) -> Toggle<'a, Message> {
92    Toggle {
93        label: label.into_fragment(),
94        icon: None,
95        pressed: false,
96        variant: Variant::default(),
97        size: Size::default(),
98        on_toggle: None,
99    }
100}
101
102impl<Message> std::fmt::Debug for Toggle<'_, Message> {
103    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
104        f.debug_struct("Toggle")
105            .field("label", &self.label)
106            .field("icon", &self.icon)
107            .field("pressed", &self.pressed)
108            .field("variant", &self.variant)
109            .field("size", &self.size)
110            .field("enabled", &self.on_toggle.is_some())
111            .finish()
112    }
113}
114
115impl<'a, Message> Toggle<'a, Message> {
116    /// Adds an icon before the label.
117    pub fn icon(mut self, glyph: Glyph) -> Self {
118        self.icon = Some(glyph);
119        self
120    }
121
122    /// Whether the toggle is on.
123    pub fn pressed(mut self, pressed: bool) -> Self {
124        self.pressed = pressed;
125        self
126    }
127
128    pub fn variant(mut self, variant: Variant) -> Self {
129        self.variant = variant;
130        self
131    }
132
133    pub fn size(mut self, size: Size) -> Self {
134        self.size = size;
135        self
136    }
137
138    /// Sets the message for a click, built from the new pressed state.
139    pub fn on_toggle(mut self, on_toggle: impl Fn(bool) -> Message + 'a) -> Self {
140        self.on_toggle = Some(Box::new(on_toggle));
141        self
142    }
143
144    /// Sets the message for a click, or disables the toggle with `None`.
145    pub fn on_toggle_maybe(mut self, on_toggle: Option<impl Fn(bool) -> Message + 'a>) -> Self {
146        self.on_toggle = on_toggle.map(|f| Box::new(f) as Box<dyn Fn(bool) -> Message + 'a>);
147        self
148    }
149
150    pub fn is_pressed(&self) -> bool {
151        self.pressed
152    }
153
154    pub fn is_enabled(&self) -> bool {
155        self.on_toggle.is_some()
156    }
157}
158
159impl<'a, Message: Clone + 'a> From<Toggle<'a, Message>> for Element<'a, Message> {
160    fn from(toggle: Toggle<'a, Message>) -> Self {
161        let Toggle {
162            label,
163            icon,
164            pressed,
165            variant,
166            size,
167            on_toggle,
168        } = toggle;
169        let message = on_toggle.map(|on_toggle| on_toggle(!pressed));
170        natural(button(
171            label,
172            icon,
173            pressed,
174            size.metrics(),
175            message,
176            move |tokens, status| style(tokens, variant, pressed, status),
177        ))
178    }
179}
180
181/// Renders the toggle button itself, so a group can draw its items with
182/// their own borders and corners.
183pub(crate) fn button<'a, Message: Clone + 'a>(
184    label: impl text::IntoFragment<'a>,
185    icon: Option<Glyph>,
186    pressed: bool,
187    metrics: Metrics,
188    message: Option<Message>,
189    style: impl Fn(&Tokens, Status) -> widget::button::Style + 'a,
190) -> Element<'a, Message> {
191    let enabled = message.is_some();
192    let resting = if enabled {
193        Status::Active
194    } else {
195        Status::Disabled
196    };
197
198    let mut content = row![].spacing(6).align_y(Alignment::Center);
199    if let Some(glyph) = icon {
200        content = content.push(themed(
201            glyph,
202            metrics.icon,
203            opacity(enabled),
204            move |theme| colours(&Tokens::of(theme), pressed, resting).foreground,
205        ));
206    }
207    content = content.push(
208        text(label)
209            .size(metrics.text)
210            .wrapping(text::Wrapping::None),
211    );
212
213    widget::button(
214        widget::container(content)
215            .height(Length::Fill)
216            .align_y(Alignment::Center),
217    )
218    .height(Length::Fixed(metrics.height))
219    .padding(Padding::from([0.0, metrics.padding_x]))
220    .on_press_maybe(message)
221    .style(move |theme, status| style(&Tokens::of(theme), status))
222    .into()
223}
224
225/// Background and foreground colours of a toggle, on or off, in a state.
226#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct Colours {
228    pub background: Option<Color>,
229    pub foreground: Color,
230}
231
232/// Resolves the colours of a toggle. Off, it is muted and takes a fill
233/// halfway to the accent on hover, keeping its muted text; on, it takes
234/// the accent with full-strength text and darkens a little on hover, so
235/// a hovered toggle never looks pressed.
236pub fn colours(tokens: &Tokens, pressed: bool, status: Status) -> Colours {
237    let hover = matches!(status, Status::Hovered | Status::Pressed);
238    let colours = match (pressed, hover) {
239        (true, false) => Colours {
240            background: Some(tokens.accent),
241            foreground: tokens.accent_foreground,
242        },
243        (true, true) => Colours {
244            background: Some(mix(tokens.accent, tokens.foreground, 0.08)),
245            foreground: tokens.accent_foreground,
246        },
247        (false, true) => Colours {
248            background: Some(mix(tokens.background, tokens.accent, 0.5)),
249            foreground: tokens.muted_foreground,
250        },
251        (false, false) => Colours {
252            background: None,
253            foreground: tokens.muted_foreground,
254        },
255    };
256
257    if status != Status::Disabled {
258        return colours;
259    }
260    Colours {
261        background: colours.background.map(|c| fade(c, 0.5)),
262        foreground: fade(colours.foreground, 0.5),
263    }
264}
265
266/// The iced button style of a toggle.
267pub fn style(
268    tokens: &Tokens,
269    variant: Variant,
270    pressed: bool,
271    status: Status,
272) -> widget::button::Style {
273    let colours = colours(tokens, pressed, status);
274    let outline = variant == Variant::Outline;
275    let border = if status == Status::Disabled {
276        fade(tokens.border, 0.5)
277    } else {
278        tokens.border
279    };
280    widget::button::Style {
281        background: colours.background.map(Background::Color),
282        text_color: colours.foreground,
283        border: Border {
284            color: if outline { border } else { Color::TRANSPARENT },
285            width: if outline { 1.0 } else { 0.0 },
286            radius: radius::MD.into(),
287        },
288        shadow: Shadow::default(),
289        snap: true,
290    }
291}
292
293/// What a toggle keyboard shortcut does.
294#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
295pub enum Action {
296    Toggle,
297}
298
299impl Action {
300    /// The pressed state to send through the toggle's `on_toggle` message.
301    pub fn apply(self, pressed: bool) -> bool {
302        match self {
303            Action::Toggle => !pressed,
304        }
305    }
306}
307
308impl keys::Action for Action {
309    const ALL: &'static [Self] = &[Action::Toggle];
310
311    fn defaults() -> Keymap<Self> {
312        default_keymap()
313    }
314
315    fn name(self) -> &'static str {
316        match self {
317            Action::Toggle => "Toggle",
318        }
319    }
320
321    fn description(self) -> &'static str {
322        match self {
323            Action::Toggle => "Switches the toggle on or off.",
324        }
325    }
326}
327
328/// The default toggle shortcuts:
329///
330/// | Keys | Action |
331/// | --- | --- |
332/// | `Space` | [`Action::Toggle`] |
333///
334/// Shortcuts are app-wide, so an app with several toggles binds each one
335/// its own chord, such as `Ctrl+B` for a bold toggle.
336pub fn default_keymap() -> Keymap<Action> {
337    Keymap::new().bind(Chord::named(Named::Space), Action::Toggle)
338}
339
340#[cfg(test)]
341mod tests {
342    use super::*;
343    use crate::theme::{dark, light};
344
345    const STATES: [Status; 4] = [
346        Status::Active,
347        Status::Hovered,
348        Status::Pressed,
349        Status::Disabled,
350    ];
351
352    #[test]
353    fn defaults_to_an_off_medium_toggle_that_is_disabled() {
354        let t: Toggle<'_, bool> = toggle("Bold");
355        assert_eq!(t.variant, Variant::Default);
356        assert_eq!(t.size, Size::Md);
357        assert!(t.icon.is_none());
358        assert!(!t.is_pressed());
359        assert!(!t.is_enabled());
360    }
361
362    #[test]
363    fn builder_sets_every_option() {
364        let t = toggle("Grid")
365            .icon(crate::lucide!(Grid3x3))
366            .pressed(true)
367            .variant(Variant::Outline)
368            .size(Size::Lg)
369            .on_toggle(|on| on);
370        assert_eq!(t.icon.map(Glyph::name), Some("grid-3x3"));
371        assert!(t.is_pressed());
372        assert!(t.is_enabled());
373        assert_eq!(t.variant, Variant::Outline);
374        assert!(
375            !toggle("Grid")
376                .on_toggle_maybe(None::<fn(bool) -> bool>)
377                .is_enabled()
378        );
379        assert!(
380            toggle("Grid")
381                .on_toggle_maybe(Some(|on: bool| on))
382                .is_enabled()
383        );
384        assert_eq!(t.size, Size::Lg);
385    }
386
387    #[test]
388    fn sizes_grow_monotonically() {
389        let heights = Size::ALL.map(|size| size.metrics().height);
390        assert!(heights.windows(2).all(|w| w[0] < w[1]));
391        let icons = Size::ALL.map(|size| size.metrics().icon);
392        assert!(icons.windows(2).all(|w| w[0] < w[1]));
393    }
394
395    #[test]
396    fn on_takes_the_accent_and_off_is_transparent_at_rest() {
397        for theme in [light(), dark()] {
398            let tokens = Tokens::of(&theme);
399            let on = colours(&tokens, true, Status::Active);
400            let off = colours(&tokens, false, Status::Active);
401            assert_eq!(on.background, Some(tokens.accent));
402            assert_eq!(on.foreground, tokens.accent_foreground);
403            assert_eq!(off.background, None);
404            assert_eq!(off.foreground, tokens.muted_foreground);
405        }
406    }
407
408    #[test]
409    fn hover_changes_the_background_on_and_off() {
410        for theme in [light(), dark()] {
411            let tokens = Tokens::of(&theme);
412            for pressed in [false, true] {
413                let rest = colours(&tokens, pressed, Status::Active);
414                for status in [Status::Hovered, Status::Pressed] {
415                    let hovered = colours(&tokens, pressed, status);
416                    assert_ne!(hovered.background, rest.background, "{pressed} {status:?}");
417                    assert!(hovered.background.is_some());
418                }
419            }
420        }
421    }
422
423    fn distance(a: Color, b: Color) -> f32 {
424        (a.r - b.r).abs() + (a.g - b.g).abs() + (a.b - b.b).abs()
425    }
426
427    #[test]
428    fn hovering_an_off_toggle_is_lighter_than_pressing_it() {
429        for theme in [light(), dark()] {
430            let tokens = Tokens::of(&theme);
431            let pressed = colours(&tokens, true, Status::Active);
432            let hovered = colours(&tokens, false, Status::Hovered);
433            let (Some(pressed_fill), Some(hovered_fill)) = (pressed.background, hovered.background)
434            else {
435                panic!("both fill");
436            };
437            let pressed_lift = distance(pressed_fill, tokens.background);
438            let hovered_lift = distance(hovered_fill, tokens.background);
439            assert!(hovered_lift > 0.0, "{}", tokens.is_dark);
440            assert!(hovered_lift < pressed_lift * 0.6, "{}", tokens.is_dark);
441            assert_ne!(hovered.foreground, pressed.foreground);
442            assert_eq!(hovered.foreground, tokens.muted_foreground);
443        }
444    }
445
446    #[test]
447    fn on_and_off_differ_in_every_enabled_state() {
448        let tokens = Tokens::of(&light());
449        for status in [Status::Active, Status::Hovered, Status::Pressed] {
450            assert_ne!(
451                colours(&tokens, true, status),
452                colours(&tokens, false, status),
453                "{status:?}"
454            );
455        }
456    }
457
458    #[test]
459    fn disabled_keeps_the_look_at_half_alpha() {
460        for theme in [light(), dark()] {
461            let tokens = Tokens::of(&theme);
462            for pressed in [false, true] {
463                let rest = colours(&tokens, pressed, Status::Active);
464                let disabled = colours(&tokens, pressed, Status::Disabled);
465                assert!((disabled.foreground.a - rest.foreground.a * 0.5).abs() < 1e-6);
466                assert_eq!(
467                    disabled.background.map(|c| c.a),
468                    rest.background.map(|c| c.a * 0.5)
469                );
470            }
471        }
472    }
473
474    #[test]
475    fn only_outline_has_a_border_in_every_state() {
476        let tokens = Tokens::of(&dark());
477        for variant in Variant::ALL {
478            for pressed in [false, true] {
479                for status in STATES {
480                    let style = style(&tokens, variant, pressed, status);
481                    assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
482                    assert_eq!(style.border.radius, radius::MD.into());
483                }
484            }
485        }
486    }
487
488    #[test]
489    fn toggle_action_flips_the_state() {
490        assert!(Action::Toggle.apply(false));
491        assert!(!Action::Toggle.apply(true));
492    }
493
494    #[test]
495    fn default_keymap_and_overrides() {
496        let space = iced::keyboard::Key::Named(Named::Space);
497        let keymap = default_keymap();
498        assert_eq!(
499            keymap.resolve(&space, Default::default()),
500            Some(Action::Toggle)
501        );
502        assert_eq!(Keymap::<Action>::defaults(), keymap);
503
504        let bold: Chord = "Ctrl+B".parse().unwrap();
505        let custom = keymap
506            .unbind(&Chord::named(Named::Space))
507            .bind(bold.clone(), Action::Toggle);
508        assert_eq!(custom.resolve(&space, Default::default()), None);
509        assert_eq!(
510            custom.resolve(bold.key(), bold.modifiers()),
511            Some(Action::Toggle)
512        );
513        assert!(custom.unbind_action(&Action::Toggle).is_empty());
514    }
515}