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