1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
20pub enum Variant {
21 #[default]
23 Default,
24 Outline,
26}
27
28impl Variant {
29 pub const ALL: [Variant; 2] = [Variant::Default, Variant::Outline];
30}
31
32#[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 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#[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
78pub 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
90pub 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 pub fn icon(mut self, glyph: Glyph) -> Self {
118 self.icon = Some(glyph);
119 self
120 }
121
122 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 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 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
181pub(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#[derive(Debug, Clone, Copy, PartialEq)]
227pub struct Colours {
228 pub background: Option<Color>,
229 pub foreground: Color,
230}
231
232pub 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
266pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
295pub enum Action {
296 Toggle,
297}
298
299impl Action {
300 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
328pub 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}