Skip to main content

iced_cube/primitives/
switch.rs

1//! On/off switches for settings that apply immediately.
2
3use iced::keyboard::key::Named;
4use iced::widget::{self, toggler::Status};
5use iced::{Background, Color, Element};
6
7use crate::keys::{self, Chord, Keymap};
8use crate::theme::{Tokens, fade, mix, space as spacing, text_size};
9
10/// Height of the switch track in logical pixels. The track is twice as wide.
11pub const TRACK_HEIGHT: f32 = 20.0;
12
13/// A switch builder. Convert it into an [`Element`] to render.
14///
15/// A switch without a message is rendered disabled.
16pub struct Switch<'a, Message> {
17    is_on: bool,
18    label: Option<widget::text::Fragment<'a>>,
19    on_toggle: Option<Box<dyn Fn(bool) -> Message + 'a>>,
20}
21
22impl<Message> std::fmt::Debug for Switch<'_, Message> {
23    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
24        f.debug_struct("Switch")
25            .field("is_on", &self.is_on)
26            .field("label", &self.label)
27            .field("enabled", &self.on_toggle.is_some())
28            .finish()
29    }
30}
31
32/// Creates a switch that is on when `is_on` is true.
33pub fn switch<'a, Message>(is_on: bool) -> Switch<'a, Message> {
34    Switch {
35        is_on,
36        label: None,
37        on_toggle: None,
38    }
39}
40
41impl<'a, Message> Switch<'a, Message> {
42    pub fn label(mut self, label: impl widget::text::IntoFragment<'a>) -> Self {
43        self.label = Some(label.into_fragment());
44        self
45    }
46
47    /// Sets the message for a click. It receives the new value.
48    pub fn on_toggle(mut self, on_toggle: impl Fn(bool) -> Message + 'a) -> Self {
49        self.on_toggle = Some(Box::new(on_toggle));
50        self
51    }
52
53    /// Like [`Switch::on_toggle`], but `None` disables the switch.
54    pub fn on_toggle_maybe(mut self, on_toggle: Option<impl Fn(bool) -> Message + 'a>) -> Self {
55        self.on_toggle = on_toggle.map(|f| Box::new(f) as Box<dyn Fn(bool) -> Message + 'a>);
56        self
57    }
58
59    pub fn is_on(&self) -> bool {
60        self.is_on
61    }
62
63    pub fn is_enabled(&self) -> bool {
64        self.on_toggle.is_some()
65    }
66}
67
68impl<'a, Message: 'a> From<Switch<'a, Message>> for Element<'a, Message> {
69    fn from(switch: Switch<'a, Message>) -> Self {
70        let mut toggler = widget::toggler(switch.is_on)
71            .size(TRACK_HEIGHT)
72            .text_size(text_size::SM)
73            .spacing(spacing::SM)
74            .style(|theme, status| style(&Tokens::of(theme), status));
75        if let Some(label) = switch.label {
76            toggler = toggler.label(label);
77        }
78        if let Some(on_toggle) = switch.on_toggle {
79            toggler = toggler.on_toggle(on_toggle);
80        }
81        toggler.into()
82    }
83}
84
85/// Track, thumb and label colours of a switch.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct Colours {
88    pub track: Color,
89    pub thumb: Color,
90    pub label: Color,
91}
92
93/// Resolves the colours of a switch for an interaction status.
94pub fn colours(tokens: &Tokens, status: Status) -> Colours {
95    let (is_on, hover, disabled) = match status {
96        Status::Active { is_toggled } => (is_toggled, false, false),
97        Status::Hovered { is_toggled } => (is_toggled, true, false),
98        Status::Disabled { is_toggled } => (is_toggled, false, true),
99    };
100
101    let off_track = mix(tokens.border, tokens.foreground, 0.1);
102    let track = match (is_on, hover) {
103        (true, false) => tokens.primary,
104        (true, true) => mix(tokens.primary, tokens.background, 0.15),
105        (false, false) => off_track,
106        (false, true) => mix(off_track, tokens.foreground, 0.15),
107    };
108    let colours = Colours {
109        track,
110        thumb: tokens.background,
111        label: tokens.foreground,
112    };
113
114    if !disabled {
115        return colours;
116    }
117    Colours {
118        track: fade(colours.track, 0.5),
119        thumb: colours.thumb,
120        label: fade(colours.label, 0.5),
121    }
122}
123
124/// The iced toggler style for a status.
125pub fn style(tokens: &Tokens, status: Status) -> widget::toggler::Style {
126    let colours = colours(tokens, status);
127    widget::toggler::Style {
128        background: Background::Color(colours.track),
129        background_border_width: 0.0,
130        background_border_color: Color::TRANSPARENT,
131        foreground: Background::Color(colours.thumb),
132        foreground_border_width: 0.0,
133        foreground_border_color: Color::TRANSPARENT,
134        text_color: Some(colours.label),
135        border_radius: None,
136        padding_ratio: 0.1,
137    }
138}
139
140/// What a switch keyboard shortcut does. Useful when the app tracks
141/// which switch is current.
142#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
143pub enum Action {
144    Toggle,
145}
146
147impl Action {
148    /// The value to send through the switch's `on_toggle` message.
149    pub fn apply(self, is_on: bool) -> bool {
150        match self {
151            Action::Toggle => !is_on,
152        }
153    }
154}
155
156impl keys::Action for Action {
157    const ALL: &'static [Self] = &[Action::Toggle];
158
159    fn defaults() -> Keymap<Self> {
160        default_keymap()
161    }
162
163    fn name(self) -> &'static str {
164        "Toggle"
165    }
166
167    fn description(self) -> &'static str {
168        "Turns the current switch on or off."
169    }
170}
171
172/// The default switch shortcuts:
173///
174/// | Keys | Action |
175/// | --- | --- |
176/// | `Space` | [`Action::Toggle`] |
177pub fn default_keymap() -> Keymap<Action> {
178    Keymap::new().bind(Chord::named(Named::Space), Action::Toggle)
179}
180
181#[cfg(test)]
182mod tests {
183    use super::*;
184    use crate::theme::{dark, light};
185
186    #[test]
187    fn toggle_flips_the_value() {
188        assert!(Action::Toggle.apply(false));
189        assert!(!Action::Toggle.apply(true));
190    }
191
192    #[test]
193    fn default_keymap_and_overrides() {
194        let space = iced::keyboard::Key::Named(Named::Space);
195        let keymap = default_keymap();
196        assert_eq!(
197            keymap.resolve(&space, Default::default()),
198            Some(Action::Toggle)
199        );
200        let custom = keymap
201            .unbind(&Chord::named(Named::Space))
202            .bind("T".parse().unwrap(), Action::Toggle);
203        assert_eq!(custom.resolve(&space, Default::default()), None);
204        assert_eq!(
205            custom.resolve(
206                &iced::keyboard::Key::Character("t".into()),
207                Default::default()
208            ),
209            Some(Action::Toggle)
210        );
211    }
212
213    fn statuses(is_toggled: bool) -> [Status; 3] {
214        [
215            Status::Active { is_toggled },
216            Status::Hovered { is_toggled },
217            Status::Disabled { is_toggled },
218        ]
219    }
220
221    #[test]
222    fn default_builder_is_unlabelled_and_disabled() {
223        let s: Switch<'_, ()> = switch(true);
224        assert!(s.is_on());
225        assert!(s.label.is_none());
226        assert!(!s.is_enabled());
227    }
228
229    #[test]
230    fn on_toggle_enables_and_maybe_none_disables() {
231        assert!(switch::<bool>(false).on_toggle(|v| v).is_enabled());
232        let s = switch::<bool>(false).on_toggle_maybe(None::<fn(bool) -> bool>);
233        assert!(!s.is_enabled());
234    }
235
236    #[test]
237    fn on_track_is_primary_and_off_track_is_not() {
238        for theme in [light(), dark()] {
239            let tokens = Tokens::of(&theme);
240            let on = colours(&tokens, Status::Active { is_toggled: true });
241            let off = colours(&tokens, Status::Active { is_toggled: false });
242            assert_eq!(on.track, tokens.primary);
243            assert_ne!(off.track, tokens.primary);
244            assert_eq!(on.thumb, tokens.background);
245        }
246    }
247
248    #[test]
249    fn hover_changes_the_track() {
250        for theme in [light(), dark()] {
251            let tokens = Tokens::of(&theme);
252            for is_toggled in [false, true] {
253                let active = colours(&tokens, Status::Active { is_toggled });
254                let hovered = colours(&tokens, Status::Hovered { is_toggled });
255                assert_ne!(active.track, hovered.track);
256            }
257        }
258    }
259
260    #[test]
261    fn disabled_fades_track_and_label() {
262        for theme in [light(), dark()] {
263            let tokens = Tokens::of(&theme);
264            for is_toggled in [false, true] {
265                let active = colours(&tokens, Status::Active { is_toggled });
266                let disabled = colours(&tokens, Status::Disabled { is_toggled });
267                assert!((disabled.track.a - active.track.a * 0.5).abs() < 1e-6);
268                assert!((disabled.label.a - active.label.a * 0.5).abs() < 1e-6);
269            }
270        }
271    }
272
273    #[test]
274    fn every_status_has_a_round_track_and_label_colour() {
275        let tokens = Tokens::of(&dark());
276        for status in statuses(true).into_iter().chain(statuses(false)) {
277            let style = style(&tokens, status);
278            assert!(style.border_radius.is_none());
279            assert!(style.text_color.is_some());
280        }
281    }
282}