1use gpui::{
2 div, hsla, prelude::*, AnyView, CursorStyle, ElementId, Hsla, IntoElement, Styled, Window,
3};
4use std::sync::Arc;
5
6use crate::utils::is_light;
7use crate::{prelude::*, ElevationIndex, KeyBinding};
8use crate::{Color, Icon, IconName, ToggleState};
9
10// TODO: Checkbox, CheckboxWithLabel, and Switch could all be
11// restructured to use a ToggleLike, similar to Button/Buttonlike, Label/Labellike
12
13/// Creates a new checkbox.
14pub fn checkbox(id: impl Into<ElementId>, toggle_state: ToggleState) -> Checkbox {
15 Checkbox::new(id, toggle_state)
16}
17
18/// Creates a new switch.
19pub fn switch(id: impl Into<ElementId>, toggle_state: ToggleState) -> Switch {
20 Switch::new(id, toggle_state)
21}
22
23/// The visual style of a toggle.
24#[derive(Debug, Default, Clone, PartialEq, Eq)]
25pub enum ToggleStyle {
26 /// Toggle has a transparent background
27 #[default]
28 Ghost,
29 /// Toggle has a filled background based on the
30 /// elevation index of the parent container
31 ElevationBased(ElevationIndex),
32 /// A custom style using a color to tint the toggle
33 Custom(Hsla),
34}
35
36/// # Checkbox
37///
38/// Checkboxes are used for multiple choices, not for mutually exclusive choices.
39/// Each checkbox works independently from other checkboxes in the list,
40/// therefore checking an additional box does not affect any other selections.
41#[derive(IntoElement)]
42pub struct Checkbox {
43 id: ElementId,
44 toggle_state: ToggleState,
45 disabled: bool,
46 on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
47 filled: bool,
48 style: ToggleStyle,
49 tooltip: Option<Box<dyn Fn(&mut Window, &mut App) -> AnyView>>,
50 label: Option<SharedString>,
51}
52
53impl Checkbox {
54 /// Creates a new [`Checkbox`].
55 pub fn new(id: impl Into<ElementId>, checked: ToggleState) -> Self {
56 Self {
57 id: id.into(),
58 toggle_state: checked,
59 disabled: false,
60 on_click: None,
61 filled: false,
62 style: ToggleStyle::default(),
63 tooltip: None,
64 label: None,
65 }
66 }
67
68 /// Sets the disabled state of the [`Checkbox`].
69 pub fn disabled(mut self, disabled: bool) -> Self {
70 self.disabled = disabled;
71 self
72 }
73
74 /// Binds a handler to the [`Checkbox`] that will be called when clicked.
75 pub fn on_click(
76 mut self,
77 handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
78 ) -> Self {
79 self.on_click = Some(Box::new(handler));
80 self
81 }
82
83 /// Sets the `fill` setting of the checkbox, indicating whether it should be filled.
84 pub fn fill(mut self) -> Self {
85 self.filled = true;
86 self
87 }
88
89 /// Sets the style of the checkbox using the specified [`ToggleStyle`].
90 pub fn style(mut self, style: ToggleStyle) -> Self {
91 self.style = style;
92 self
93 }
94
95 /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`].
96 pub fn elevation(mut self, elevation: ElevationIndex) -> Self {
97 self.style = ToggleStyle::ElevationBased(elevation);
98 self
99 }
100
101 /// Sets the tooltip for the checkbox.
102 pub fn tooltip(mut self, tooltip: impl Fn(&mut Window, &mut App) -> AnyView + 'static) -> Self {
103 self.tooltip = Some(Box::new(tooltip));
104 self
105 }
106
107 /// Set the label for the checkbox.
108 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
109 self.label = Some(label.into());
110 self
111 }
112}
113
114impl Checkbox {
115 fn bg_color(&self, cx: &App) -> Hsla {
116 let style = self.style.clone();
117 match (style, self.filled) {
118 (ToggleStyle::Ghost, false) => cx.theme().colors().ghost_element_background,
119 (ToggleStyle::Ghost, true) => cx.theme().colors().element_background,
120 (ToggleStyle::ElevationBased(_), false) => gpui::transparent_black(),
121 (ToggleStyle::ElevationBased(elevation), true) => elevation.darker_bg(cx),
122 (ToggleStyle::Custom(_), false) => gpui::transparent_black(),
123 (ToggleStyle::Custom(color), true) => color.opacity(0.2),
124 }
125 }
126
127 fn border_color(&self, cx: &App) -> Hsla {
128 if self.disabled {
129 return cx.theme().colors().border_variant;
130 }
131
132 match self.style.clone() {
133 ToggleStyle::Ghost => cx.theme().colors().border,
134 ToggleStyle::ElevationBased(elevation) => elevation.on_elevation_bg(cx),
135 ToggleStyle::Custom(color) => color.opacity(0.3),
136 }
137 }
138}
139
140impl RenderOnce for Checkbox {
141 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
142 let group_id = format!("checkbox_group_{:?}", self.id);
143 let icon = match self.toggle_state {
144 ToggleState::Selected => Some(Icon::new(IconName::Check).size(IconSize::Small).color(
145 if self.disabled {
146 Color::Disabled
147 } else {
148 Color::Selected
149 },
150 )),
151 ToggleState::Indeterminate => Some(
152 Icon::new(IconName::Dash)
153 .size(IconSize::Small)
154 .color(if self.disabled {
155 Color::Disabled
156 } else {
157 Color::Selected
158 }),
159 ),
160 ToggleState::Unselected => None,
161 };
162
163 let bg_color = self.bg_color(cx);
164 let border_color = self.border_color(cx);
165
166 let checkbox = h_flex()
167 .justify_center()
168 .size(DynamicSpacing::Base20.rems(cx))
169 .group(group_id.clone())
170 .child(
171 div()
172 .flex()
173 .flex_none()
174 .justify_center()
175 .items_center()
176 .m(DynamicSpacing::Base04.px(cx))
177 .size(DynamicSpacing::Base16.rems(cx))
178 .rounded_sm()
179 .bg(bg_color)
180 .border_1()
181 .border_color(border_color)
182 .when(self.disabled, |this| {
183 this.cursor(CursorStyle::OperationNotAllowed)
184 })
185 .when(self.disabled, |this| {
186 this.bg(cx.theme().colors().element_disabled.opacity(0.6))
187 })
188 .when(!self.disabled, |this| {
189 this.group_hover(group_id.clone(), |el| {
190 el.bg(cx.theme().colors().element_hover)
191 })
192 })
193 .children(icon),
194 );
195
196 h_flex()
197 .id(self.id)
198 .gap(DynamicSpacing::Base06.rems(cx))
199 .child(checkbox)
200 .when_some(
201 self.on_click.filter(|_| !self.disabled),
202 |this, on_click| {
203 this.on_click(move |_, window, cx| {
204 on_click(&self.toggle_state.inverse(), window, cx)
205 })
206 },
207 )
208 // TODO: Allow label size to be different from default.
209 // TODO: Allow label color to be different from muted.
210 .when_some(self.label, |this, label| {
211 this.child(Label::new(label).color(Color::Muted))
212 })
213 .when_some(self.tooltip, |this, tooltip| {
214 this.tooltip(move |window, cx| tooltip(window, cx))
215 })
216 }
217}
218
219/// A [`Checkbox`] that has a [`Label`].
220#[derive(IntoElement)]
221pub struct CheckboxWithLabel {
222 id: ElementId,
223 label: Label,
224 checked: ToggleState,
225 on_click: Arc<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>,
226 filled: bool,
227 style: ToggleStyle,
228}
229
230// TODO: Remove `CheckboxWithLabel` now that `label` is a method of `Checkbox`.
231impl CheckboxWithLabel {
232 /// Creates a checkbox with an attached label.
233 pub fn new(
234 id: impl Into<ElementId>,
235 label: Label,
236 checked: ToggleState,
237 on_click: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
238 ) -> Self {
239 Self {
240 id: id.into(),
241 label,
242 checked,
243 on_click: Arc::new(on_click),
244 filled: false,
245 style: ToggleStyle::default(),
246 }
247 }
248
249 /// Sets the style of the checkbox using the specified [`ToggleStyle`].
250 pub fn style(mut self, style: ToggleStyle) -> Self {
251 self.style = style;
252 self
253 }
254
255 /// Match the style of the checkbox to the current elevation using [`ToggleStyle::ElevationBased`].
256 pub fn elevation(mut self, elevation: ElevationIndex) -> Self {
257 self.style = ToggleStyle::ElevationBased(elevation);
258 self
259 }
260
261 /// Sets the `fill` setting of the checkbox, indicating whether it should be filled.
262 pub fn fill(mut self) -> Self {
263 self.filled = true;
264 self
265 }
266}
267
268impl RenderOnce for CheckboxWithLabel {
269 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
270 h_flex()
271 .gap(DynamicSpacing::Base08.rems(cx))
272 .child(
273 Checkbox::new(self.id.clone(), self.checked)
274 .style(self.style)
275 .when(self.filled, Checkbox::fill)
276 .on_click({
277 let on_click = self.on_click.clone();
278 move |checked, window, cx| {
279 (on_click)(checked, window, cx);
280 }
281 }),
282 )
283 .child(
284 div()
285 .id(SharedString::from(format!("{}-label", self.id)))
286 .on_click(move |_event, window, cx| {
287 (self.on_click)(&self.checked.inverse(), window, cx);
288 })
289 .child(self.label),
290 )
291 }
292}
293
294/// # Switch
295///
296/// Switches are used to represent opposite states, such as enabled or disabled.
297#[derive(IntoElement)]
298pub struct Switch {
299 id: ElementId,
300 toggle_state: ToggleState,
301 disabled: bool,
302 on_click: Option<Box<dyn Fn(&ToggleState, &mut Window, &mut App) + 'static>>,
303 label: Option<SharedString>,
304 key_binding: Option<KeyBinding>,
305}
306
307impl Switch {
308 /// Creates a new [`Switch`].
309 pub fn new(id: impl Into<ElementId>, state: ToggleState) -> Self {
310 Self {
311 id: id.into(),
312 toggle_state: state,
313 disabled: false,
314 on_click: None,
315 label: None,
316 key_binding: None,
317 }
318 }
319
320 /// Sets the disabled state of the [`Switch`].
321 pub fn disabled(mut self, disabled: bool) -> Self {
322 self.disabled = disabled;
323 self
324 }
325
326 /// Binds a handler to the [`Switch`] that will be called when clicked.
327 pub fn on_click(
328 mut self,
329 handler: impl Fn(&ToggleState, &mut Window, &mut App) + 'static,
330 ) -> Self {
331 self.on_click = Some(Box::new(handler));
332 self
333 }
334
335 /// Sets the label of the [`Switch`].
336 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
337 self.label = Some(label.into());
338 self
339 }
340
341 /// Display the keybinding that triggers the switch action.
342 pub fn key_binding(mut self, key_binding: impl Into<Option<KeyBinding>>) -> Self {
343 self.key_binding = key_binding.into();
344 self
345 }
346}
347
348impl RenderOnce for Switch {
349 fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement {
350 let is_on = self.toggle_state == ToggleState::Selected;
351 let adjust_ratio = if is_light(cx) { 1.5 } else { 1.0 };
352 let base_color = cx.theme().colors().text;
353
354 let bg_color = if is_on {
355 cx.theme()
356 .colors()
357 .element_background
358 .blend(base_color.opacity(0.08))
359 } else {
360 cx.theme().colors().element_background
361 };
362 let thumb_color = base_color.opacity(0.8);
363 let thumb_hover_color = base_color;
364 let border_color = cx.theme().colors().border_variant;
365 // Lighter themes need higher contrast borders
366 let border_hover_color = if is_on {
367 border_color.blend(base_color.opacity(0.16 * adjust_ratio))
368 } else {
369 border_color.blend(base_color.opacity(0.05 * adjust_ratio))
370 };
371 let thumb_opacity = match (is_on, self.disabled) {
372 (_, true) => 0.2,
373 (true, false) => 1.0,
374 (false, false) => 0.5,
375 };
376
377 let group_id = format!("switch_group_{:?}", self.id);
378
379 let switch = h_flex()
380 .w(DynamicSpacing::Base32.rems(cx))
381 .h(DynamicSpacing::Base20.rems(cx))
382 .group(group_id.clone())
383 .child(
384 h_flex()
385 .when(is_on, |on| on.justify_end())
386 .when(!is_on, |off| off.justify_start())
387 .items_center()
388 .size_full()
389 .rounded_full()
390 .px(DynamicSpacing::Base02.px(cx))
391 .bg(bg_color)
392 .border_1()
393 .border_color(border_color)
394 .when(!self.disabled, |this| {
395 this.group_hover(group_id.clone(), |el| el.border_color(border_hover_color))
396 })
397 .child(
398 div()
399 .size(DynamicSpacing::Base12.rems(cx))
400 .rounded_full()
401 .bg(thumb_color)
402 .when(!self.disabled, |this| {
403 this.group_hover(group_id.clone(), |el| el.bg(thumb_hover_color))
404 })
405 .opacity(thumb_opacity),
406 ),
407 );
408
409 h_flex()
410 .id(self.id)
411 .gap(DynamicSpacing::Base06.rems(cx))
412 .child(switch)
413 .when_some(
414 self.on_click.filter(|_| !self.disabled),
415 |this, on_click| {
416 this.on_click(move |_, window, cx| {
417 on_click(&self.toggle_state.inverse(), window, cx)
418 })
419 },
420 )
421 .when_some(self.label, |this, label| {
422 this.child(Label::new(label).size(LabelSize::Small))
423 })
424 .children(self.key_binding)
425 }
426}
427
428impl ComponentPreview for Checkbox {
429 fn description() -> impl Into<Option<&'static str>> {
430 "A checkbox lets people choose between a pair of opposing states, like enabled and disabled, using a different appearance to indicate each state."
431 }
432
433 fn examples(_window: &mut Window, _: &mut App) -> Vec<ComponentExampleGroup<Self>> {
434 vec![
435 example_group_with_title(
436 "Default",
437 vec![
438 single_example(
439 "Unselected",
440 Checkbox::new("checkbox_unselected", ToggleState::Unselected),
441 ),
442 single_example(
443 "Indeterminate",
444 Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate),
445 ),
446 single_example(
447 "Selected",
448 Checkbox::new("checkbox_selected", ToggleState::Selected),
449 ),
450 ],
451 ),
452 example_group_with_title(
453 "Default (Filled)",
454 vec![
455 single_example(
456 "Unselected",
457 Checkbox::new("checkbox_unselected", ToggleState::Unselected).fill(),
458 ),
459 single_example(
460 "Indeterminate",
461 Checkbox::new("checkbox_indeterminate", ToggleState::Indeterminate).fill(),
462 ),
463 single_example(
464 "Selected",
465 Checkbox::new("checkbox_selected", ToggleState::Selected).fill(),
466 ),
467 ],
468 ),
469 example_group_with_title(
470 "ElevationBased",
471 vec![
472 single_example(
473 "Unselected",
474 Checkbox::new("checkbox_unfilled_unselected", ToggleState::Unselected)
475 .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
476 ),
477 single_example(
478 "Indeterminate",
479 Checkbox::new(
480 "checkbox_unfilled_indeterminate",
481 ToggleState::Indeterminate,
482 )
483 .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
484 ),
485 single_example(
486 "Selected",
487 Checkbox::new("checkbox_unfilled_selected", ToggleState::Selected)
488 .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
489 ),
490 ],
491 ),
492 example_group_with_title(
493 "ElevationBased (Filled)",
494 vec![
495 single_example(
496 "Unselected",
497 Checkbox::new("checkbox_filled_unselected", ToggleState::Unselected)
498 .fill()
499 .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
500 ),
501 single_example(
502 "Indeterminate",
503 Checkbox::new("checkbox_filled_indeterminate", ToggleState::Indeterminate)
504 .fill()
505 .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
506 ),
507 single_example(
508 "Selected",
509 Checkbox::new("checkbox_filled_selected", ToggleState::Selected)
510 .fill()
511 .style(ToggleStyle::ElevationBased(ElevationIndex::EditorSurface)),
512 ),
513 ],
514 ),
515 example_group_with_title(
516 "Custom Color",
517 vec![
518 single_example(
519 "Unselected",
520 Checkbox::new("checkbox_custom_unselected", ToggleState::Unselected)
521 .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
522 ),
523 single_example(
524 "Indeterminate",
525 Checkbox::new("checkbox_custom_indeterminate", ToggleState::Indeterminate)
526 .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
527 ),
528 single_example(
529 "Selected",
530 Checkbox::new("checkbox_custom_selected", ToggleState::Selected)
531 .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
532 ),
533 ],
534 ),
535 example_group_with_title(
536 "Custom Color (Filled)",
537 vec![
538 single_example(
539 "Unselected",
540 Checkbox::new("checkbox_custom_filled_unselected", ToggleState::Unselected)
541 .fill()
542 .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
543 ),
544 single_example(
545 "Indeterminate",
546 Checkbox::new(
547 "checkbox_custom_filled_indeterminate",
548 ToggleState::Indeterminate,
549 )
550 .fill()
551 .style(ToggleStyle::Custom(hsla(
552 142.0 / 360.,
553 0.68,
554 0.45,
555 0.7,
556 ))),
557 ),
558 single_example(
559 "Selected",
560 Checkbox::new("checkbox_custom_filled_selected", ToggleState::Selected)
561 .fill()
562 .style(ToggleStyle::Custom(hsla(142.0 / 360., 0.68, 0.45, 0.7))),
563 ),
564 ],
565 ),
566 example_group_with_title(
567 "Disabled",
568 vec![
569 single_example(
570 "Unselected",
571 Checkbox::new("checkbox_disabled_unselected", ToggleState::Unselected)
572 .disabled(true),
573 ),
574 single_example(
575 "Indeterminate",
576 Checkbox::new(
577 "checkbox_disabled_indeterminate",
578 ToggleState::Indeterminate,
579 )
580 .disabled(true),
581 ),
582 single_example(
583 "Selected",
584 Checkbox::new("checkbox_disabled_selected", ToggleState::Selected)
585 .disabled(true),
586 ),
587 ],
588 ),
589 example_group_with_title(
590 "Disabled (Filled)",
591 vec![
592 single_example(
593 "Unselected",
594 Checkbox::new(
595 "checkbox_disabled_filled_unselected",
596 ToggleState::Unselected,
597 )
598 .fill()
599 .disabled(true),
600 ),
601 single_example(
602 "Indeterminate",
603 Checkbox::new(
604 "checkbox_disabled_filled_indeterminate",
605 ToggleState::Indeterminate,
606 )
607 .fill()
608 .disabled(true),
609 ),
610 single_example(
611 "Selected",
612 Checkbox::new("checkbox_disabled_filled_selected", ToggleState::Selected)
613 .fill()
614 .disabled(true),
615 ),
616 ],
617 ),
618 ]
619 }
620}
621
622impl ComponentPreview for Switch {
623 fn description() -> impl Into<Option<&'static str>> {
624 "A switch toggles between two mutually exclusive states, typically used for enabling or disabling a setting."
625 }
626
627 fn examples(_window: &mut Window, _cx: &mut App) -> Vec<ComponentExampleGroup<Self>> {
628 vec![
629 example_group_with_title(
630 "Default",
631 vec![
632 single_example(
633 "Off",
634 Switch::new("switch_off", ToggleState::Unselected).on_click(|_, _, _cx| {}),
635 ),
636 single_example(
637 "On",
638 Switch::new("switch_on", ToggleState::Selected).on_click(|_, _, _cx| {}),
639 ),
640 ],
641 ),
642 example_group_with_title(
643 "Disabled",
644 vec![
645 single_example(
646 "Off",
647 Switch::new("switch_disabled_off", ToggleState::Unselected).disabled(true),
648 ),
649 single_example(
650 "On",
651 Switch::new("switch_disabled_on", ToggleState::Selected).disabled(true),
652 ),
653 ],
654 ),
655 example_group_with_title(
656 "Label Permutations",
657 vec![
658 single_example(
659 "Label",
660 Switch::new("switch_with_label", ToggleState::Selected)
661 .label("Always save on quit"),
662 ),
663 single_example(
664 "Keybinding",
665 Switch::new("switch_with_label", ToggleState::Selected)
666 .key_binding(theme_preview_keybinding("cmd-shift-e")),
667 ),
668 ],
669 ),
670 ]
671 }
672}
673
674impl ComponentPreview for CheckboxWithLabel {
675 fn description() -> impl Into<Option<&'static str>> {
676 "A checkbox with an associated label, allowing users to select an option while providing a descriptive text."
677 }
678
679 fn examples(_window: &mut Window, _: &mut App) -> Vec<ComponentExampleGroup<Self>> {
680 vec![example_group(vec![
681 single_example(
682 "Unselected",
683 CheckboxWithLabel::new(
684 "checkbox_with_label_unselected",
685 Label::new("Always save on quit"),
686 ToggleState::Unselected,
687 |_, _, _| {},
688 ),
689 ),
690 single_example(
691 "Indeterminate",
692 CheckboxWithLabel::new(
693 "checkbox_with_label_indeterminate",
694 Label::new("Always save on quit"),
695 ToggleState::Indeterminate,
696 |_, _, _| {},
697 ),
698 ),
699 single_example(
700 "Selected",
701 CheckboxWithLabel::new(
702 "checkbox_with_label_selected",
703 Label::new("Always save on quit"),
704 ToggleState::Selected,
705 |_, _, _| {},
706 ),
707 ),
708 ])]
709 }
710}