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 fills on hover;
233/// on, it takes the accent and darkens a little on hover, so it reads as
234/// on in every state.
235pub fn colours(tokens: &Tokens, pressed: bool, status: Status) -> Colours {
236    let hover = matches!(status, Status::Hovered | Status::Pressed);
237    let colours = match (pressed, hover) {
238        (true, false) => Colours {
239            background: Some(tokens.accent),
240            foreground: tokens.accent_foreground,
241        },
242        (true, true) => Colours {
243            background: Some(mix(tokens.accent, tokens.foreground, 0.08)),
244            foreground: tokens.accent_foreground,
245        },
246        (false, true) => Colours {
247            background: Some(tokens.muted),
248            foreground: tokens.foreground,
249        },
250        (false, false) => Colours {
251            background: None,
252            foreground: tokens.muted_foreground,
253        },
254    };
255
256    if status != Status::Disabled {
257        return colours;
258    }
259    Colours {
260        background: colours.background.map(|c| fade(c, 0.5)),
261        foreground: fade(colours.foreground, 0.5),
262    }
263}
264
265/// The iced button style of a toggle.
266pub fn style(
267    tokens: &Tokens,
268    variant: Variant,
269    pressed: bool,
270    status: Status,
271) -> widget::button::Style {
272    let colours = colours(tokens, pressed, status);
273    let outline = variant == Variant::Outline;
274    let border = if status == Status::Disabled {
275        fade(tokens.border, 0.5)
276    } else {
277        tokens.border
278    };
279    widget::button::Style {
280        background: colours.background.map(Background::Color),
281        text_color: colours.foreground,
282        border: Border {
283            color: if outline { border } else { Color::TRANSPARENT },
284            width: if outline { 1.0 } else { 0.0 },
285            radius: radius::MD.into(),
286        },
287        shadow: Shadow::default(),
288        snap: true,
289    }
290}
291
292/// What a toggle keyboard shortcut does.
293#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
294pub enum Action {
295    Toggle,
296}
297
298impl Action {
299    /// The pressed state to send through the toggle's `on_toggle` message.
300    pub fn apply(self, pressed: bool) -> bool {
301        match self {
302            Action::Toggle => !pressed,
303        }
304    }
305}
306
307impl keys::Action for Action {
308    const ALL: &'static [Self] = &[Action::Toggle];
309
310    fn defaults() -> Keymap<Self> {
311        default_keymap()
312    }
313
314    fn name(self) -> &'static str {
315        match self {
316            Action::Toggle => "Toggle",
317        }
318    }
319
320    fn description(self) -> &'static str {
321        match self {
322            Action::Toggle => "Switches the toggle on or off.",
323        }
324    }
325}
326
327/// The default toggle shortcuts:
328///
329/// | Keys | Action |
330/// | --- | --- |
331/// | `Space` | [`Action::Toggle`] |
332///
333/// Shortcuts are app-wide, so an app with several toggles binds each one
334/// its own chord, such as `Ctrl+B` for a bold toggle.
335pub fn default_keymap() -> Keymap<Action> {
336    Keymap::new().bind(Chord::named(Named::Space), Action::Toggle)
337}
338
339#[cfg(test)]
340mod tests {
341    use super::*;
342    use crate::theme::{dark, light};
343
344    const STATES: [Status; 4] = [
345        Status::Active,
346        Status::Hovered,
347        Status::Pressed,
348        Status::Disabled,
349    ];
350
351    #[test]
352    fn defaults_to_an_off_medium_toggle_that_is_disabled() {
353        let t: Toggle<'_, bool> = toggle("Bold");
354        assert_eq!(t.variant, Variant::Default);
355        assert_eq!(t.size, Size::Md);
356        assert!(t.icon.is_none());
357        assert!(!t.is_pressed());
358        assert!(!t.is_enabled());
359    }
360
361    #[test]
362    fn builder_sets_every_option() {
363        let t = toggle("Grid")
364            .icon(crate::lucide!(Grid3x3))
365            .pressed(true)
366            .variant(Variant::Outline)
367            .size(Size::Lg)
368            .on_toggle(|on| on);
369        assert_eq!(t.icon.map(Glyph::name), Some("grid-3x3"));
370        assert!(t.is_pressed());
371        assert!(t.is_enabled());
372        assert_eq!(t.variant, Variant::Outline);
373        assert!(
374            !toggle("Grid")
375                .on_toggle_maybe(None::<fn(bool) -> bool>)
376                .is_enabled()
377        );
378        assert!(
379            toggle("Grid")
380                .on_toggle_maybe(Some(|on: bool| on))
381                .is_enabled()
382        );
383        assert_eq!(t.size, Size::Lg);
384    }
385
386    #[test]
387    fn sizes_grow_monotonically() {
388        let heights = Size::ALL.map(|size| size.metrics().height);
389        assert!(heights.windows(2).all(|w| w[0] < w[1]));
390        let icons = Size::ALL.map(|size| size.metrics().icon);
391        assert!(icons.windows(2).all(|w| w[0] < w[1]));
392    }
393
394    #[test]
395    fn on_takes_the_accent_and_off_is_transparent_at_rest() {
396        for theme in [light(), dark()] {
397            let tokens = Tokens::of(&theme);
398            let on = colours(&tokens, true, Status::Active);
399            let off = colours(&tokens, false, Status::Active);
400            assert_eq!(on.background, Some(tokens.accent));
401            assert_eq!(on.foreground, tokens.accent_foreground);
402            assert_eq!(off.background, None);
403            assert_eq!(off.foreground, tokens.muted_foreground);
404        }
405    }
406
407    #[test]
408    fn hover_changes_the_background_on_and_off() {
409        for theme in [light(), dark()] {
410            let tokens = Tokens::of(&theme);
411            for pressed in [false, true] {
412                let rest = colours(&tokens, pressed, Status::Active);
413                for status in [Status::Hovered, Status::Pressed] {
414                    let hovered = colours(&tokens, pressed, status);
415                    assert_ne!(hovered.background, rest.background, "{pressed} {status:?}");
416                    assert!(hovered.background.is_some());
417                }
418            }
419        }
420    }
421
422    #[test]
423    fn on_and_off_differ_in_every_enabled_state() {
424        let tokens = Tokens::of(&light());
425        for status in [Status::Active, Status::Hovered, Status::Pressed] {
426            assert_ne!(
427                colours(&tokens, true, status),
428                colours(&tokens, false, status),
429                "{status:?}"
430            );
431        }
432    }
433
434    #[test]
435    fn disabled_keeps_the_look_at_half_alpha() {
436        for theme in [light(), dark()] {
437            let tokens = Tokens::of(&theme);
438            for pressed in [false, true] {
439                let rest = colours(&tokens, pressed, Status::Active);
440                let disabled = colours(&tokens, pressed, Status::Disabled);
441                assert!((disabled.foreground.a - rest.foreground.a * 0.5).abs() < 1e-6);
442                assert_eq!(
443                    disabled.background.map(|c| c.a),
444                    rest.background.map(|c| c.a * 0.5)
445                );
446            }
447        }
448    }
449
450    #[test]
451    fn only_outline_has_a_border_in_every_state() {
452        let tokens = Tokens::of(&dark());
453        for variant in Variant::ALL {
454            for pressed in [false, true] {
455                for status in STATES {
456                    let style = style(&tokens, variant, pressed, status);
457                    assert_eq!(style.border.width > 0.0, variant == Variant::Outline);
458                    assert_eq!(style.border.radius, radius::MD.into());
459                }
460            }
461        }
462    }
463
464    #[test]
465    fn toggle_action_flips_the_state() {
466        assert!(Action::Toggle.apply(false));
467        assert!(!Action::Toggle.apply(true));
468    }
469
470    #[test]
471    fn default_keymap_and_overrides() {
472        let space = iced::keyboard::Key::Named(Named::Space);
473        let keymap = default_keymap();
474        assert_eq!(
475            keymap.resolve(&space, Default::default()),
476            Some(Action::Toggle)
477        );
478        assert_eq!(Keymap::<Action>::defaults(), keymap);
479
480        let bold: Chord = "Ctrl+B".parse().unwrap();
481        let custom = keymap
482            .unbind(&Chord::named(Named::Space))
483            .bind(bold.clone(), Action::Toggle);
484        assert_eq!(custom.resolve(&space, Default::default()), None);
485        assert_eq!(
486            custom.resolve(bold.key(), bold.modifiers()),
487            Some(Action::Toggle)
488        );
489        assert!(custom.unbind_action(&Action::Toggle).is_empty());
490    }
491}