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 {
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
265pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
294pub enum Action {
295 Toggle,
296}
297
298impl Action {
299 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
327pub 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}