1use 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
10pub const TRACK_HEIGHT: f32 = 20.0;
12
13pub 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
32pub 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 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 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#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct Colours {
88 pub track: Color,
89 pub thumb: Color,
90 pub label: Color,
91}
92
93pub 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
124pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
143pub enum Action {
144 Toggle,
145}
146
147impl Action {
148 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
172pub 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}