1use iced::keyboard::key::Named;
4use iced::widget::{self, button::Status, container, mouse_area, row, space, text};
5use iced::{Alignment, Background, Border, Color, Element, Length, Shadow, Theme, mouse};
6
7use crate::icon::{Glyph, opacity, themed};
8use crate::keys::{self, Chord, Keymap};
9use crate::theme::{Tokens, fade, mix, radius, space as spacing, text_size};
10
11pub const BOX_SIZE: f32 = 16.0;
13
14const ICON_SIZE: f32 = 14.0;
15
16#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
18pub enum CheckState {
19 #[default]
20 Unchecked,
21 Checked,
22 Indeterminate,
24}
25
26impl CheckState {
27 pub const ALL: [CheckState; 3] = [
28 CheckState::Unchecked,
29 CheckState::Checked,
30 CheckState::Indeterminate,
31 ];
32
33 pub const fn toggled(self) -> Self {
35 match self {
36 CheckState::Checked => CheckState::Unchecked,
37 CheckState::Unchecked | CheckState::Indeterminate => CheckState::Checked,
38 }
39 }
40
41 pub const fn is_checked(self) -> bool {
42 matches!(self, CheckState::Checked)
43 }
44
45 pub const fn is_filled(self) -> bool {
47 !matches!(self, CheckState::Unchecked)
48 }
49
50 fn glyph(self) -> Option<Glyph> {
51 match self {
52 CheckState::Unchecked => None,
53 CheckState::Checked => Some(crate::lucide!(Check)),
54 CheckState::Indeterminate => Some(crate::lucide!(Minus)),
55 }
56 }
57}
58
59impl From<bool> for CheckState {
60 fn from(checked: bool) -> Self {
61 if checked {
62 CheckState::Checked
63 } else {
64 CheckState::Unchecked
65 }
66 }
67}
68
69#[derive(Debug)]
73pub struct Checkbox<'a, Message> {
74 state: CheckState,
75 label: Option<text::Fragment<'a>>,
76 on_toggle: Option<Message>,
77}
78
79pub fn checkbox<'a, Message>(state: impl Into<CheckState>) -> Checkbox<'a, Message> {
81 Checkbox {
82 state: state.into(),
83 label: None,
84 on_toggle: None,
85 }
86}
87
88impl<'a, Message> Checkbox<'a, Message> {
89 pub fn label(mut self, label: impl text::IntoFragment<'a>) -> Self {
90 self.label = Some(label.into_fragment());
91 self
92 }
93
94 pub fn on_toggle(mut self, on_toggle: impl FnOnce(bool) -> Message) -> Self {
97 self.on_toggle = Some(on_toggle(self.state.toggled().is_checked()));
98 self
99 }
100
101 pub fn on_toggle_maybe(mut self, on_toggle: Option<impl FnOnce(bool) -> Message>) -> Self {
103 let next = self.state.toggled().is_checked();
104 self.on_toggle = on_toggle.map(|f| f(next));
105 self
106 }
107
108 pub fn state(&self) -> CheckState {
109 self.state
110 }
111
112 pub fn is_enabled(&self) -> bool {
113 self.on_toggle.is_some()
114 }
115}
116
117impl<'a, Message: Clone + 'a> From<Checkbox<'a, Message>> for Element<'a, Message> {
118 fn from(checkbox: Checkbox<'a, Message>) -> Self {
119 let state = checkbox.state;
120 let enabled = checkbox.on_toggle.is_some();
121 let resting = if enabled {
122 Status::Active
123 } else {
124 Status::Disabled
125 };
126
127 let mark: Element<'a, Message> = match state.glyph() {
128 Some(glyph) => themed(glyph, ICON_SIZE, opacity(enabled), move |theme| {
129 colours(&Tokens::of(theme), state, resting).icon
130 })
131 .into(),
132 None => space().into(),
133 };
134
135 let indicator = widget::button(container(mark).center(Length::Fill))
136 .width(BOX_SIZE)
137 .height(BOX_SIZE)
138 .padding(0)
139 .on_press_maybe(checkbox.on_toggle.clone())
140 .style(move |theme, status| style(&Tokens::of(theme), state, status));
141
142 let Some(label) = checkbox.label else {
143 return indicator.into();
144 };
145
146 let label = text(label)
147 .size(text_size::SM)
148 .style(move |theme: &Theme| text::Style {
149 color: Some(colours(&Tokens::of(theme), state, resting).label),
150 });
151 let label = match checkbox.on_toggle {
152 Some(message) => mouse_area(label)
153 .on_press(message)
154 .interaction(mouse::Interaction::Pointer),
155 None => mouse_area(label),
156 };
157
158 row![indicator, label]
159 .spacing(spacing::SM)
160 .align_y(Alignment::Center)
161 .into()
162 }
163}
164
165#[derive(Debug, Clone, Copy, PartialEq)]
167pub struct Colours {
168 pub background: Color,
169 pub border: Color,
170 pub icon: Color,
171 pub label: Color,
172}
173
174pub fn colours(tokens: &Tokens, state: CheckState, status: Status) -> Colours {
176 let hover = matches!(status, Status::Hovered | Status::Pressed);
177 let pressed = status == Status::Pressed;
178
179 let colours = if state.is_filled() {
180 let target = if tokens.is_dark {
181 Color::BLACK
182 } else {
183 Color::WHITE
184 };
185 let amount = match (hover, pressed) {
186 (_, true) => 0.2,
187 (true, false) => 0.1,
188 _ => 0.0,
189 };
190 let fill = mix(tokens.primary, target, amount);
191 Colours {
192 background: fill,
193 border: Color::TRANSPARENT,
194 icon: tokens.primary_foreground,
195 label: tokens.foreground,
196 }
197 } else {
198 Colours {
199 background: if pressed {
200 tokens.accent
201 } else {
202 Color::TRANSPARENT
203 },
204 border: mix(
205 tokens.border,
206 tokens.foreground,
207 if hover { 0.6 } else { 0.3 },
208 ),
209 icon: tokens.primary_foreground,
210 label: tokens.foreground,
211 }
212 };
213
214 if status != Status::Disabled {
215 return colours;
216 }
217 Colours {
218 background: fade(colours.background, 0.5),
219 border: fade(colours.border, 0.5),
220 icon: fade(colours.icon, 0.5),
221 label: fade(colours.label, 0.5),
222 }
223}
224
225pub fn style(tokens: &Tokens, state: CheckState, status: Status) -> widget::button::Style {
227 let colours = colours(tokens, state, status);
228 widget::button::Style {
229 background: Some(Background::Color(colours.background)),
230 text_color: colours.icon,
231 border: Border {
232 color: colours.border,
233 width: 1.0,
234 radius: radius::SM.into(),
235 },
236 shadow: Shadow::default(),
237 snap: true,
238 }
239}
240
241#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
244pub enum Action {
245 Toggle,
246}
247
248impl Action {
249 pub fn apply(self, state: CheckState) -> bool {
252 match self {
253 Action::Toggle => state.toggled().is_checked(),
254 }
255 }
256}
257
258impl keys::Action for Action {
259 const ALL: &'static [Self] = &[Action::Toggle];
260
261 fn defaults() -> Keymap<Self> {
262 default_keymap()
263 }
264
265 fn name(self) -> &'static str {
266 "Toggle"
267 }
268
269 fn description(self) -> &'static str {
270 "Checks or unchecks the current checkbox. An indeterminate box becomes checked."
271 }
272}
273
274pub fn default_keymap() -> Keymap<Action> {
280 Keymap::new().bind(Chord::named(Named::Space), Action::Toggle)
281}
282
283#[cfg(test)]
284mod tests {
285 use super::*;
286 use crate::theme::{dark, light};
287
288 #[test]
289 fn toggle_matches_a_click() {
290 assert!(Action::Toggle.apply(CheckState::Unchecked));
291 assert!(!Action::Toggle.apply(CheckState::Checked));
292 assert!(Action::Toggle.apply(CheckState::Indeterminate));
293 }
294
295 #[test]
296 fn default_keymap_and_overrides() {
297 let keymap = default_keymap();
298 assert_eq!(
299 keymap.resolve(
300 &iced::keyboard::Key::Named(Named::Space),
301 Default::default()
302 ),
303 Some(Action::Toggle)
304 );
305 let custom = keymap
306 .clear()
307 .bind("Enter".parse().unwrap(), Action::Toggle);
308 assert_eq!(custom.len(), 1);
309 assert_eq!(
310 custom.resolve(
311 &iced::keyboard::Key::Named(Named::Enter),
312 Default::default()
313 ),
314 Some(Action::Toggle)
315 );
316 }
317
318 const STATES: [Status; 4] = [
319 Status::Active,
320 Status::Hovered,
321 Status::Pressed,
322 Status::Disabled,
323 ];
324
325 #[test]
326 fn default_builder_is_unchecked_unlabelled_and_disabled() {
327 let c: Checkbox<'_, ()> = checkbox(false);
328 assert_eq!(c.state(), CheckState::Unchecked);
329 assert!(c.label.is_none());
330 assert!(!c.is_enabled());
331 }
332
333 #[test]
334 fn bool_converts_to_two_states() {
335 assert_eq!(CheckState::from(true), CheckState::Checked);
336 assert_eq!(CheckState::from(false), CheckState::Unchecked);
337 }
338
339 #[test]
340 fn toggling_cycles_between_checked_and_unchecked() {
341 assert_eq!(CheckState::Unchecked.toggled(), CheckState::Checked);
342 assert_eq!(CheckState::Checked.toggled(), CheckState::Unchecked);
343 assert_eq!(CheckState::Indeterminate.toggled(), CheckState::Checked);
344 }
345
346 #[test]
347 fn on_toggle_receives_the_next_value() {
348 assert_eq!(checkbox(false).on_toggle(|v| v).on_toggle, Some(true));
349 assert_eq!(checkbox(true).on_toggle(|v| v).on_toggle, Some(false));
350 let mixed = checkbox(CheckState::Indeterminate).on_toggle(|v| v);
351 assert_eq!(mixed.on_toggle, Some(true));
352 }
353
354 #[test]
355 fn on_toggle_maybe_none_disables() {
356 let c = checkbox(true).on_toggle_maybe(None::<fn(bool) -> bool>);
357 assert!(!c.is_enabled());
358 }
359
360 #[test]
361 fn only_filled_states_draw_a_mark() {
362 assert!(CheckState::Unchecked.glyph().is_none());
363 assert_eq!(CheckState::Checked.glyph().map(Glyph::name), Some("check"));
364 assert_eq!(
365 CheckState::Indeterminate.glyph().map(Glyph::name),
366 Some("minus")
367 );
368 }
369
370 #[test]
371 fn filled_states_use_primary_and_unchecked_is_transparent() {
372 for theme in [light(), dark()] {
373 let tokens = Tokens::of(&theme);
374 let checked = colours(&tokens, CheckState::Checked, Status::Active);
375 let mixed = colours(&tokens, CheckState::Indeterminate, Status::Active);
376 let unchecked = colours(&tokens, CheckState::Unchecked, Status::Active);
377 assert_eq!(checked.background, tokens.primary);
378 assert_eq!(checked, mixed);
379 assert_eq!(unchecked.background, Color::TRANSPARENT);
380 }
381 }
382
383 #[test]
384 fn hover_and_press_change_the_box_in_every_state() {
385 for theme in [light(), dark()] {
386 let tokens = Tokens::of(&theme);
387 for state in CheckState::ALL {
388 let active = colours(&tokens, state, Status::Active);
389 let hovered = colours(&tokens, state, Status::Hovered);
390 let pressed = colours(&tokens, state, Status::Pressed);
391 assert_ne!(active, hovered, "{state:?}");
392 assert_ne!(hovered, pressed, "{state:?}");
393 }
394 }
395 }
396
397 #[test]
398 fn disabled_halves_alpha_everywhere() {
399 for theme in [light(), dark()] {
400 let tokens = Tokens::of(&theme);
401 for state in CheckState::ALL {
402 let active = colours(&tokens, state, Status::Active);
403 let disabled = colours(&tokens, state, Status::Disabled);
404 assert!((disabled.border.a - active.border.a * 0.5).abs() < 1e-6);
405 assert!((disabled.label.a - active.label.a * 0.5).abs() < 1e-6);
406 assert!((disabled.icon.a - active.icon.a * 0.5).abs() < 1e-6);
407 }
408 }
409 }
410
411 #[test]
412 fn every_style_has_a_rounded_one_pixel_border() {
413 for theme in [light(), dark()] {
414 let tokens = Tokens::of(&theme);
415 for state in CheckState::ALL {
416 for status in STATES {
417 let style = style(&tokens, state, status);
418 assert_eq!(style.border.width, 1.0);
419 assert!(style.background.is_some());
420 }
421 }
422 }
423 }
424}