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::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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
21pub enum Variant {
22 #[default]
24 Default,
25 Outline,
27}
28
29impl Variant {
30 pub const ALL: [Variant; 2] = [Variant::Default, Variant::Outline];
31}
32
33#[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 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#[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
79pub 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
91pub 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 pub fn icon(mut self, glyph: Glyph) -> Self {
119 self.icon = Some(glyph);
120 self
121 }
122
123 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 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 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
183pub(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#[derive(Debug, Clone, Copy, PartialEq)]
237pub struct Colours {
238 pub background: Option<Color>,
239 pub foreground: Color,
240}
241
242pub 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
276pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
305pub enum Action {
306 Toggle,
307}
308
309impl Action {
310 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
338pub 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}