1use std::{cell::RefCell, rc::Rc, time::Duration};
4
5use gpui::{
6 prelude::*, px, AnimationExt, AnyElement, App, IntoElement, ParentElement, Pixels, RenderOnce,
7 StatefulInteractiveElement, Styled, Window,
8};
9use herogpui_core::{element_id, Color};
10use herogpui_theme::ActiveTheme;
11
12use crate::a11y::{self, A11y as _};
13use crate::anim::Tween;
14
15#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
17#[non_exhaustive]
18pub struct CheckboxState {
19 pub is_selected: bool,
21 pub is_indeterminate: bool,
23 pub is_disabled: bool,
25 pub is_read_only: bool,
27 pub is_invalid: bool,
29 pub is_required: bool,
31}
32
33const FILL_FADE_MS: u64 = 200;
51const FILL_SCALE_MS: u64 = 100;
54const FILL_REST_SCALE: f32 = 0.7;
56const FILL_BG_MS: u64 = 200;
60const CHECK_DRAW_MS: u64 = 150;
63const CHECK_DRAW_DELAY_MS: u64 = 15;
64const CHECK_UNDRAW_MS: u64 = 200;
66
67const CHECK_VIEWBOX: (f32, f32) = (17., 18.);
76const CHECK_POLYLINE: [(f32, f32); 3] = [(1., 9.), (7., 14.), (15., 4.)];
77const CHECK_STROKE: f32 = 2.5;
78const CHECK_DASH_UNITS: f32 = 22.;
82
83fn fill_layer(
86 id: &gpui::ElementId,
87 opacity: Tween<f32>,
88 scale: Tween<f32>,
89 reduce_motion: bool,
90 radius: Pixels,
91 box_px: Pixels,
92 background: Tween<gpui::Hsla>,
93) -> AnyElement {
94 let (opacity_to, scale_to) = (opacity.target(), scale.target());
95 let color_to = background.target();
96 let base = gpui::div().absolute();
97 if !opacity.animates(reduce_motion)
98 && !scale.animates(reduce_motion)
99 && !background.animates(reduce_motion)
100 {
101 opacity.settle();
102 scale.settle();
103 background.settle();
104 let inset = box_px * (1.0 - scale_to) / 2.0;
105 return base
106 .left(inset)
107 .top(inset)
108 .right(inset)
109 .bottom(inset)
110 .rounded(radius * scale_to)
111 .opacity(opacity_to)
112 .bg(color_to)
113 .into_any_element();
114 }
115
116 let color_from = background.from();
117 let color = background.value();
118 let colored = base.with_animation(
119 element_id::indexed(id, "fill-color", background.generation()),
120 gpui::Animation::new(Duration::from_millis(FILL_BG_MS))
121 .with_easing(|t| crate::anim::Curve::Out.at(t)),
122 move |el, delta| {
123 let value = if delta >= 1.0 {
124 color_to
125 } else {
126 herogpui_core::mix_oklab(color_from, color_to, delta)
127 };
128 color.set(value);
129 el.bg(value)
130 },
131 );
132 let scale_from = scale.from();
133 let scale_value = scale.value();
134 let scaled = colored.with_animation(
135 element_id::indexed(id, "fill-scale", scale.generation()),
136 gpui::Animation::new(Duration::from_millis(FILL_SCALE_MS))
137 .with_easing(|t| crate::anim::Curve::Linear.at(t)),
138 move |el, delta| {
139 let value = scale_from + (scale_to - scale_from) * delta;
140 scale_value.set(value);
141 let inset = box_px * (1.0 - value) / 2.0;
142 el.map_element(|fill| {
143 fill.left(inset)
144 .top(inset)
145 .right(inset)
146 .bottom(inset)
147 .rounded(radius * value)
148 })
149 },
150 );
151 let opacity_from = opacity.from();
152 let opacity_value = opacity.value();
153 scaled
154 .with_animation(
155 element_id::indexed(id, "fill-fade", opacity.generation()),
156 gpui::Animation::new(Duration::from_millis(FILL_FADE_MS))
157 .with_easing(|t| crate::anim::Curve::Linear.at(t)),
158 move |el, delta| {
159 let value = opacity_from + (opacity_to - opacity_from) * delta;
160 opacity_value.set(value);
161 el.map_element(|colored| colored.map_element(|fill| fill.opacity(value)))
162 },
163 )
164 .into_any_element()
165}
166
167fn easing_bg_layer(
171 id: &gpui::ElementId,
172 tag: &'static str,
173 target: gpui::Hsla,
174 radius: Pixels,
175 window: &mut Window,
176 cx: &mut App,
177) -> AnyElement {
178 let reduce_motion = ActiveTheme::reduce_motion(cx);
179 let mut tween = Tween::keyed(id, tag, target, window, cx);
180 tween.snap_if_reduced(reduce_motion);
181 let base = gpui::div().absolute().inset_0().rounded(radius);
182 if !tween.animates(reduce_motion) {
183 tween.settle();
184 return base.bg(target).into_any_element();
185 }
186 let (from, to) = (tween.from(), tween.target());
187 let color = tween.value();
188 base.with_animation(
189 element_id::indexed(id, tag, tween.generation()),
190 gpui::Animation::new(Duration::from_millis(FILL_BG_MS))
191 .with_easing(|t| crate::anim::Curve::Out.at(t)),
192 move |el, delta| {
193 let next = if delta >= 1.0 {
194 to
195 } else {
196 herogpui_core::mix_oklab(from, to, delta)
197 };
198 color.set(next);
199 el.bg(next)
200 },
201 )
202 .into_any_element()
203}
204
205fn check_layer(
211 id: &gpui::ElementId,
212 tween: Tween<f32>,
213 reduce_motion: bool,
214 size: Pixels,
215 color: gpui::Hsla,
216) -> AnyElement {
217 let progress = tween.value();
218 let canvas = gpui::canvas(
219 |bounds, _, _| bounds,
220 move |bounds, _, window, _| paint_check_stroke(bounds, progress.get(), color, window),
221 )
222 .size(size);
223 if !tween.animates(reduce_motion) {
224 tween.settle();
225 return canvas.into_any_element();
226 }
227
228 let (from, to) = (tween.from(), tween.target());
229 let (duration, easing): (u64, Box<dyn Fn(f32) -> f32>) = if to > from {
230 let total = (CHECK_DRAW_DELAY_MS + CHECK_DRAW_MS) as f32;
231 let delay = CHECK_DRAW_DELAY_MS as f32;
232 let span = CHECK_DRAW_MS as f32;
233 (
234 CHECK_DRAW_DELAY_MS + CHECK_DRAW_MS,
235 Box::new(move |t: f32| ((t * total - delay) / span).clamp(0., 1.)),
236 )
237 } else {
238 (
239 CHECK_UNDRAW_MS,
240 Box::new(crate::anim::tailwind_default_ease()),
241 )
242 };
243 let progress = tween.value();
244 canvas
245 .with_animation(
246 element_id::indexed(id, "check-draw", tween.generation()),
247 gpui::Animation::new(Duration::from_millis(duration)).with_easing(easing),
248 move |el, delta| {
249 progress.set(from + (to - from) * delta);
250 el
251 },
252 )
253 .into_any_element()
254}
255
256fn stroke_segment(
260 a: gpui::Point<Pixels>,
261 b: gpui::Point<Pixels>,
262 width: Pixels,
263 color: gpui::Hsla,
264 window: &mut Window,
265) {
266 let mut builder = gpui::PathBuilder::stroke(width);
267 builder.move_to(a);
268 builder.line_to(b);
269 if let Ok(path) = builder.build() {
270 window.paint_path(path, color);
271 }
272}
273
274fn paint_check_stroke(
278 bounds: gpui::Bounds<Pixels>,
279 progress: f32,
280 color: gpui::Hsla,
281 window: &mut Window,
282) {
283 if progress <= 0.0 {
284 return;
285 }
286 let progress = progress.min(1.0);
287 let (view_w, view_h) = CHECK_VIEWBOX;
288 let scale = (f32::from(bounds.size.width) / view_w).min(f32::from(bounds.size.height) / view_h);
289 let origin = gpui::point(
290 bounds.origin.x + (bounds.size.width - px(view_w * scale)) / 2.0,
291 bounds.origin.y + (bounds.size.height - px(view_h * scale)) / 2.0,
292 );
293 let map = |point: (f32, f32)| {
294 gpui::point(
295 origin.x + px(point.0 * scale),
296 origin.y + px(point.1 * scale),
297 )
298 };
299
300 let [start, elbow, end] = CHECK_POLYLINE;
301 let first = segment_length(start, elbow);
302 let total = first + segment_length(elbow, end);
303 let reveal = (progress * CHECK_DASH_UNITS).min(total);
306 let past_elbow = reveal > first;
307 let tip = if past_elbow {
308 lerp_point(elbow, end, (reveal - first) / segment_length(elbow, end))
309 } else {
310 lerp_point(start, elbow, reveal / first)
311 };
312
313 let stroke_w = px(CHECK_STROKE * scale);
320 stroke_segment(
321 map(start),
322 if past_elbow { map(elbow) } else { map(tip) },
323 stroke_w,
324 color,
325 window,
326 );
327 if past_elbow {
328 stroke_segment(map(elbow), map(tip), stroke_w, color, window);
329 }
330 let cap_radius = stroke_w / 2.0;
331 let caps = [
332 Some(map(start)),
333 past_elbow.then(|| map(elbow)),
334 Some(map(tip)),
335 ];
336 for center in caps.into_iter().flatten() {
337 crate::util::paint_disc(center, cap_radius, color, window);
338 }
339}
340
341fn segment_length(a: (f32, f32), b: (f32, f32)) -> f32 {
342 ((b.0 - a.0) * (b.0 - a.0) + (b.1 - a.1) * (b.1 - a.1)).sqrt()
343}
344
345fn lerp_point(a: (f32, f32), b: (f32, f32), t: f32) -> (f32, f32) {
346 (a.0 + (b.0 - a.0) * t, a.1 + (b.1 - a.1) * t)
347}
348
349#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
369pub enum CheckboxSize {
370 Sm,
372 #[default]
374 Md,
375}
376
377impl CheckboxSize {
378 pub const ALL: [CheckboxSize; 2] = [Self::Sm, Self::Md];
380
381 fn metrics(self) -> (Pixels, Pixels, Pixels) {
383 match self {
384 Self::Sm => (px(14.), px(10.), px(12.)),
385 Self::Md => (px(16.), px(12.), px(14.)),
386 }
387 }
388
389 pub fn label(self) -> &'static str {
391 match self {
392 Self::Sm => "Small",
393 Self::Md => "Medium",
394 }
395 }
396}
397
398#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
400#[derive(IntoElement)]
401pub struct Checkbox {
402 value: Option<gpui::SharedString>,
405 validation_behavior: crate::form::ValidationBehavior,
407 name: Option<gpui::SharedString>,
410 id: gpui::ElementId,
411 checked: Option<bool>,
414 default_checked: bool,
415 is_indeterminate: bool,
416 is_disabled: bool,
417 is_read_only: bool,
418 is_required: bool,
419 validate: Option<crate::validation::Validator<bool>>,
421 validation_errors: Vec<gpui::SharedString>,
423 is_invalid: bool,
424 variant: herogpui_core::FieldVariant,
425 indicator: Option<Box<dyn Fn(CheckboxState) -> AnyElement + 'static>>,
428 content: Option<Box<dyn Fn(CheckboxState) -> AnyElement + 'static>>,
430 is_round: bool,
433 hover_bg: Option<gpui::Hsla>,
435 radius: Option<Pixels>,
438 size: CheckboxSize,
440 text_size: Option<Pixels>,
442 description: Option<gpui::SharedString>,
443 label_text: Option<gpui::SharedString>,
450 error_message: Option<gpui::SharedString>,
451 children: Vec<AnyElement>,
452 on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
453 form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
454 form_focus_target: Option<Rc<RefCell<crate::form::LiveFormFieldState>>>,
455 sx: Option<Box<gpui::StyleRefinement>>,
457}
458
459impl Checkbox {
460 pub fn validate(mut self, f: impl Fn(&bool) -> Option<gpui::SharedString> + 'static) -> Self {
465 self.validate = Some(std::sync::Arc::new(f));
466 self
467 }
468
469 pub fn validation_errors(
472 mut self,
473 errors: impl IntoIterator<Item = impl Into<gpui::SharedString>>,
474 ) -> Self {
475 self.validation_errors = errors.into_iter().map(Into::into).collect();
476 self
477 }
478
479 pub fn is_required(mut self, v: bool) -> Self {
481 self.is_required = v;
482 self
483 }
484
485 pub fn is_invalid(mut self, v: bool) -> Self {
487 self.is_invalid = v;
488 self
489 }
490
491 pub fn indicator(mut self, render: impl Fn(CheckboxState) -> AnyElement + 'static) -> Self {
493 self.indicator = Some(Box::new(render));
494 self
495 }
496
497 pub fn content(mut self, render: impl Fn(CheckboxState) -> AnyElement + 'static) -> Self {
500 self.content = Some(Box::new(render));
501 self
502 }
503
504 pub fn is_round(mut self, v: bool) -> Self {
507 self.is_round = v;
508 self
509 }
510
511 pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
514 self.hover_bg = Some(color.into());
515 self
516 }
517
518 pub fn radius(mut self, radius: impl Into<Pixels>) -> Self {
525 self.radius = Some(radius.into());
526 self
527 }
528
529 pub fn size(mut self, size: CheckboxSize) -> Self {
533 self.size = size;
534 self
535 }
536
537 pub fn text_size(mut self, size: impl Into<Pixels>) -> Self {
541 self.text_size = Some(size.into());
542 self
543 }
544
545 pub fn variant(mut self, variant: herogpui_core::FieldVariant) -> Self {
547 self.variant = variant;
548 self
549 }
550
551 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
556 crate::util::refine_sx(&mut self.sx, style);
557 self
558 }
559
560 pub fn is_read_only(mut self, v: bool) -> Self {
562 self.is_read_only = v;
563 self
564 }
565
566 pub fn new(id: impl Into<gpui::ElementId>) -> Self {
568 Self {
569 value: None,
570 validation_behavior: crate::form::ValidationBehavior::Native,
571 name: None,
572 id: id.into(),
573 checked: None,
574 default_checked: false,
575 is_indeterminate: false,
576 is_disabled: false,
577 is_read_only: false,
578 is_required: false,
579 validate: None,
580 validation_errors: Vec::new(),
581 is_invalid: false,
582 variant: herogpui_core::FieldVariant::Primary,
583 indicator: None,
584 content: None,
585 is_round: false,
586 hover_bg: None,
587 radius: None,
588 size: CheckboxSize::default(),
589 text_size: None,
590 description: None,
591 label_text: None,
592 error_message: None,
593 children: Vec::new(),
594 on_change: None,
595 form_state: Rc::new(RefCell::new(crate::form::LiveFormFieldState {
596 value: crate::form::FormValue::Flag(false),
597 is_invalid: false,
598 is_successful: true,
599 focus: None,
600 restore: None,
601 })),
602 form_focus_target: None,
603 sx: None,
604 }
605 }
606
607 pub fn value(mut self, value: impl Into<gpui::SharedString>) -> Self {
612 self.value = Some(value.into());
613 self
614 }
615
616 pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
619 self.validation_behavior = behavior;
620 self
621 }
622
623 pub fn name(mut self, name: impl Into<gpui::SharedString>) -> Self {
625 self.name = Some(name.into());
626 self
627 }
628
629 pub fn form_field(&self) -> Option<crate::form::FormField> {
652 let name = self.name.clone()?;
653 let checked = self.checked.unwrap_or(self.default_checked);
654 let validity = crate::validation::resolve(
655 self.is_invalid,
656 &self.validation_errors,
657 self.validate.as_ref().and_then(|f| f(&checked)),
658 self.error_message.clone(),
659 );
660 {
661 let mut state = self.form_state.borrow_mut();
662 state.value = match (&self.value, checked) {
663 (Some(value), true) => crate::form::FormValue::Text(value.clone()),
664 _ => crate::form::FormValue::Flag(checked),
665 };
666 state.is_invalid = validity.is_invalid;
667 state.is_successful = !self.is_disabled;
668 }
669 Some(
670 crate::form::FormField::live(name, self.form_state.clone())
671 .is_required(self.is_required)
672 .validation_behavior(self.validation_behavior),
673 )
674 }
675
676 pub fn is_selected(mut self, v: bool) -> Self {
679 self.checked = Some(v);
680 self
681 }
682
683 pub fn default_selected(mut self, v: bool) -> Self {
688 self.default_checked = v;
689 self
690 }
691
692 pub fn is_indeterminate(mut self, v: bool) -> Self {
694 self.is_indeterminate = v;
695 self
696 }
697
698 pub fn is_disabled(mut self, v: bool) -> Self {
700 self.is_disabled = v;
701 self
702 }
703
704 pub fn label(mut self, el: impl IntoElement) -> Self {
706 self.children.push(el.into_any_element());
707 self
708 }
709
710 pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
712 self.description = Some(text.into());
713 self
714 }
715
716 fn a11y_label(mut self, text: impl Into<gpui::SharedString>) -> Self {
719 self.label_text = Some(text.into());
720 self
721 }
722
723 pub fn error_message(mut self, text: impl Into<gpui::SharedString>) -> Self {
725 self.error_message = Some(text.into());
726 self
727 }
728
729 pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
731 self.on_change = Some(std::sync::Arc::new(f));
732 self
733 }
734
735 fn form_focus_target(mut self, state: Rc<RefCell<crate::form::LiveFormFieldState>>) -> Self {
736 self.form_focus_target = Some(state);
737 self
738 }
739}
740
741impl ParentElement for Checkbox {
742 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
743 self.children.extend(elements);
744 }
745}
746
747impl RenderOnce for Checkbox {
748 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
749 let (checked, own) = crate::util::controlled(
751 window,
752 cx,
753 element_id::scoped(&self.id, "checked"),
754 self.checked,
755 self.default_checked,
756 );
757 let reset_own = own.clone();
758 let reset_state = Rc::downgrade(&self.form_state);
759 let reset_value = self.value.clone();
760 let reset_change = self
761 .checked
762 .is_some()
763 .then(|| self.on_change.clone())
764 .flatten();
765 self.form_state.borrow_mut().restore = (reset_own.is_some() || reset_change.is_some())
766 .then(|| {
767 let default_checked = self.default_checked;
768 let reset_state = reset_state.clone();
769 crate::util::shared(move |window: &mut Window, cx: &mut App| {
770 if let Some(state) = reset_state.upgrade() {
771 state.borrow_mut().value = match (&reset_value, default_checked) {
772 (Some(value), true) => crate::form::FormValue::Text(value.clone()),
773 _ => crate::form::FormValue::Flag(default_checked),
774 };
775 }
776 if let Some(held) = &reset_own {
777 held.update(cx, |checked, cx| {
778 *checked = default_checked;
779 cx.notify();
780 });
781 }
782 if let Some(on_change) = &reset_change {
783 on_change(&default_checked, window, cx);
784 }
785 }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
786 });
787
788 let validity = crate::validation::resolve(
790 self.is_invalid,
791 &self.validation_errors,
792 self.validate.as_ref().and_then(|f| f(&checked)),
793 self.error_message.clone(),
794 );
795 {
796 let mut state = self.form_state.borrow_mut();
797 state.value = match (&self.value, checked) {
798 (Some(value), true) => crate::form::FormValue::Text(value.clone()),
799 _ => crate::form::FormValue::Flag(checked),
800 };
801 state.is_invalid = validity.is_invalid;
802 state.is_successful = !self.is_disabled;
803 }
804
805 let focus_handle =
809 crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
810 self.form_state.borrow_mut().focus = Some(focus_handle.clone());
811 if let Some(target) = &self.form_focus_target {
812 target.borrow_mut().focus = Some(focus_handle.clone());
813 }
814 let (accent_color, accent_hover, accent_foreground) = {
818 let sem = if validity.is_invalid {
819 cx.role(Color::Danger)
820 } else {
821 cx.role(Color::Accent)
822 };
823 (sem.color, sem.hover(), sem.foreground)
824 };
825
826 let fill_visible = checked;
830 let check_visible = checked && !self.is_indeterminate;
831
832 let interaction =
839 crate::util::interaction(element_id::scoped(&self.id, "interaction"), window, cx);
840 let (is_hovered, is_pressed) = if self.is_disabled {
841 (false, false)
842 } else {
843 *interaction.read(cx)
844 };
845
846 let (box_px, icon_px, text) = self.size.metrics();
850 let text = self.text_size.unwrap_or(text);
851 let control_radius = if self.is_round {
852 box_px / 2.0
855 } else {
856 self.radius.unwrap_or_else(|| crate::util::mark_radius(cx))
859 };
860
861 let control_bg_target = if self.is_indeterminate {
867 if is_pressed {
868 accent_hover
869 } else {
870 accent_color
871 }
872 } else {
873 match self.variant {
874 herogpui_core::FieldVariant::Primary => cx.colors().field.background,
875 herogpui_core::FieldVariant::Secondary => cx.colors().default.color,
876 }
877 };
878 let fill_hover = self.hover_bg.unwrap_or(accent_hover);
879 let fill_bg_target = if is_hovered { fill_hover } else { accent_color };
880
881 let reduce_motion = ActiveTheme::reduce_motion(cx);
883 let mut fill_opacity =
884 Tween::keyed(&self.id, "fill-fade", f32::from(fill_visible), window, cx);
885 let mut fill_scale = Tween::keyed(
886 &self.id,
887 "fill-scale",
888 if fill_visible { 1.0 } else { FILL_REST_SCALE },
889 window,
890 cx,
891 );
892 let mut check_stroke = Tween::keyed(
893 &self.id,
894 "check-motion",
895 f32::from(check_visible),
896 window,
897 cx,
898 );
899 fill_opacity.snap_if_reduced(reduce_motion);
900 fill_scale.snap_if_reduced(reduce_motion);
901 check_stroke.snap_if_reduced(reduce_motion);
902 let mut fill_background = Tween::keyed(&self.id, "fill-bg", fill_bg_target, window, cx);
903 fill_background.snap_if_reduced(reduce_motion);
904 let control_background = easing_bg_layer(
905 &self.id,
906 "control-bg",
907 control_bg_target,
908 control_radius,
909 window,
910 cx,
911 );
912
913 let checkbox_state = CheckboxState {
914 is_selected: checked,
915 is_indeterminate: self.is_indeterminate,
916 is_disabled: self.is_disabled,
917 is_read_only: self.is_read_only,
918 is_invalid: validity.is_invalid,
919 is_required: self.is_required,
920 };
921
922 let mut boxel = gpui::div()
928 .id(element_id::scoped(&self.id, "control"))
929 .flex()
930 .items_center()
931 .justify_center()
932 .size(box_px)
933 .rounded(control_radius)
934 .flex_shrink_0()
943 .bg(match self.variant {
948 herogpui_core::FieldVariant::Primary => cx.colors().field.background,
949 herogpui_core::FieldVariant::Secondary => cx.colors().default.color,
950 });
951
952 let box_shadow: Vec<gpui::BoxShadow> =
957 if self.variant == herogpui_core::FieldVariant::Primary {
958 cx.layout().field_shadow.clone()
959 } else {
960 Vec::new()
961 };
962
963 if validity.is_invalid && !fill_visible && !self.is_indeterminate {
967 boxel = boxel.border_1().border_color(cx.colors().danger.color);
968 }
969
970 if !self.is_disabled {
973 boxel = crate::util::track_interaction(boxel, &interaction);
974 }
975
976 boxel = boxel.child(control_background);
979 boxel = boxel.child(fill_layer(
980 &self.id,
981 fill_opacity,
982 fill_scale,
983 reduce_motion,
984 control_radius,
985 box_px,
986 fill_background,
987 ));
988
989 if let Some(render) = &self.indicator {
992 boxel = boxel.child(render(checkbox_state));
993 } else if self.is_indeterminate {
994 boxel = boxel.child(
995 gpui::div()
996 .w(icon_px)
997 .h(px(2.))
998 .rounded_full()
999 .bg(accent_foreground),
1000 );
1001 } else {
1002 boxel = boxel.child(
1011 gpui::div()
1012 .size(icon_px)
1013 .flex()
1014 .items_center()
1015 .justify_center()
1016 .child(check_layer(
1017 &self.id,
1018 check_stroke,
1019 reduce_motion,
1020 icon_px - px(2.),
1021 accent_foreground,
1022 )),
1023 );
1024 }
1025
1026 let boxel = crate::util::with_focus_ring_overlay(
1027 boxel,
1028 !self.is_disabled && focus_handle.is_focused(window) && crate::util::focus_visible(cx),
1029 true,
1030 control_radius,
1031 box_shadow,
1032 cx,
1033 );
1034
1035 let children = self
1036 .content
1037 .map_or(self.children, |render| vec![render(checkbox_state)]);
1038 let name = a11y::Name::field(
1042 self.label_text.as_ref(),
1043 self.description.as_ref(),
1044 &validity,
1045 );
1046 let row = gpui::div()
1047 .id(self.id.clone())
1048 .a11y_named(a11y::Role::CheckBox, &name)
1049 .a11y_checked(checked, self.is_indeterminate)
1050 .when(!self.is_disabled, |el| el.track_focus(&focus_handle))
1051 .flex()
1052 .items_center()
1053 .gap(px(12.))
1055 .when(!self.is_disabled && !self.is_read_only, |r| {
1056 r.cursor(crate::util::interactive_cursor(cx))
1057 })
1058 .children(
1059 std::iter::once(boxel.into_any_element())
1060 .chain(children)
1061 .chain(self.is_required.then(|| {
1062 gpui::div()
1063 .text_color(cx.colors().danger.color)
1064 .child("*")
1065 .into_any_element()
1066 })),
1067 )
1068 .text_size(text)
1069 .line_height(crate::util::leading_for(text).unwrap_or(px(20.)))
1070 .font_weight(gpui::FontWeight::MEDIUM)
1071 .text_color(cx.colors().foreground);
1072
1073 let row = if self.is_disabled {
1074 row
1075 } else {
1076 crate::util::record_focus_bounds(row, &focus_handle, window, cx)
1077 };
1078 let content = if !self.is_disabled
1079 && !self.is_read_only
1080 && (self.on_change.is_some() || own.is_some())
1081 {
1082 let on_change = self.on_change;
1083 row.on_click(move |event, window, cx| {
1084 if matches!(
1085 event,
1086 gpui::ClickEvent::Keyboard(event)
1087 if event.button == gpui::KeyboardButton::Enter
1088 ) {
1089 return;
1090 }
1091 if let Some(held) = &own {
1094 held.update(cx, |v, cx| {
1095 *v = !checked;
1096 cx.notify();
1097 });
1098 }
1099 if let Some(cb) = &on_change {
1100 cb(&!checked, window, cx);
1101 }
1102 })
1103 .into_any_element()
1104 } else {
1105 row.into_any_element()
1106 };
1107
1108 let message = validity.first();
1109 let mut root = gpui::div()
1110 .flex()
1111 .flex_col()
1112 .items_start()
1113 .gap(px(4.))
1114 .when(self.is_disabled, |r| {
1115 r.opacity(cx.layout().disabled_opacity)
1116 })
1117 .child(content);
1118 if let Some(message) = message {
1119 root = root.child(
1120 gpui::div()
1121 .w_full()
1122 .pl(box_px + px(12.))
1123 .child(crate::field::ErrorMessage::new(message)),
1124 );
1125 } else if let Some(description) = self.description {
1126 root = root.child(
1127 gpui::div()
1128 .w_full()
1129 .pl(box_px + px(12.))
1130 .child(crate::field::Description::new(description)),
1131 );
1132 }
1133 root = crate::util::apply_sx(root, &self.sx);
1134 root.into_any_element()
1135 }
1136}
1137
1138#[must_use = "builder methods return a new value; pass the option to its component"]
1144#[derive(Clone)]
1145pub struct CheckboxOption {
1146 key: gpui::SharedString,
1147 label: gpui::SharedString,
1148 description: Option<gpui::SharedString>,
1149 is_disabled: bool,
1150}
1151
1152impl CheckboxOption {
1153 pub fn new(key: impl Into<gpui::SharedString>, label: impl Into<gpui::SharedString>) -> Self {
1155 Self {
1156 key: key.into(),
1157 label: label.into(),
1158 description: None,
1159 is_disabled: false,
1160 }
1161 }
1162
1163 pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
1165 self.description = Some(text.into());
1166 self
1167 }
1168
1169 pub fn is_disabled(mut self, v: bool) -> Self {
1171 self.is_disabled = v;
1172 self
1173 }
1174
1175 pub fn key(&self) -> &gpui::SharedString {
1177 &self.key
1178 }
1179}
1180
1181type OnGroupChange =
1182 std::sync::Arc<dyn Fn(&std::collections::HashSet<gpui::SharedString>, &mut Window, &mut App)>;
1183
1184#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
1189#[derive(IntoElement)]
1190pub struct CheckboxGroup {
1191 name: Option<gpui::SharedString>,
1194 id: gpui::ElementId,
1195 options: Vec<CheckboxOption>,
1196 label: Option<gpui::SharedString>,
1197 description: Option<gpui::SharedString>,
1198 error_message: Option<gpui::SharedString>,
1199 value: Option<std::collections::HashSet<gpui::SharedString>>,
1200 default_value: std::collections::HashSet<gpui::SharedString>,
1201 orientation: herogpui_core::Orientation,
1202 variant: herogpui_core::FieldVariant,
1203 is_disabled: bool,
1204 is_read_only: bool,
1205 is_invalid: bool,
1206 is_required: bool,
1207 on_change: Option<OnGroupChange>,
1208 form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
1209 sx: Option<Box<gpui::StyleRefinement>>,
1211}
1212
1213impl CheckboxGroup {
1214 pub fn new(id: impl Into<gpui::ElementId>, options: Vec<CheckboxOption>) -> Self {
1216 Self {
1217 name: None,
1218 id: id.into(),
1219 options,
1220 label: None,
1221 description: None,
1222 error_message: None,
1223 value: None,
1224 default_value: std::collections::HashSet::new(),
1225 orientation: herogpui_core::Orientation::Vertical,
1226 variant: herogpui_core::FieldVariant::Primary,
1227 is_disabled: false,
1228 is_read_only: false,
1229 is_invalid: false,
1230 is_required: false,
1231 on_change: None,
1232 form_state: Rc::new(RefCell::new(crate::form::LiveFormFieldState {
1233 value: crate::form::FormValue::Keys(Vec::new()),
1234 is_invalid: false,
1235 is_successful: true,
1236 focus: None,
1237 restore: None,
1238 })),
1239 sx: None,
1240 }
1241 }
1242
1243 pub fn name(mut self, name: impl Into<gpui::SharedString>) -> Self {
1245 self.name = Some(name.into());
1246 self
1247 }
1248
1249 pub fn form_field(&self) -> Option<crate::form::FormField> {
1273 let name = self.name.clone()?;
1274 let selected = self.value.as_ref().unwrap_or(&self.default_value);
1275 let values = self
1276 .options
1277 .iter()
1278 .filter(|option| {
1279 !self.is_disabled && !option.is_disabled && selected.contains(&option.key)
1280 })
1281 .map(|option| option.key.clone())
1282 .collect();
1283 {
1284 let mut state = self.form_state.borrow_mut();
1285 state.value = crate::form::FormValue::Keys(values);
1286 state.is_invalid = self.is_invalid || self.error_message.is_some();
1287 state.is_successful = !self.is_disabled;
1288 state.focus = None;
1289 }
1290 Some(
1291 crate::form::FormField::live(name, self.form_state.clone())
1292 .is_required(self.is_required),
1293 )
1294 }
1295
1296 pub fn label(mut self, text: impl Into<gpui::SharedString>) -> Self {
1298 self.label = Some(text.into());
1299 self
1300 }
1301
1302 pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
1304 self.description = Some(text.into());
1305 self
1306 }
1307
1308 pub fn error_message(mut self, text: impl Into<gpui::SharedString>) -> Self {
1310 self.error_message = Some(text.into());
1311 self
1312 }
1313
1314 pub fn value(mut self, keys: impl IntoIterator<Item = gpui::SharedString>) -> Self {
1316 self.value = Some(keys.into_iter().collect());
1317 self
1318 }
1319
1320 pub fn default_value(mut self, keys: impl IntoIterator<Item = gpui::SharedString>) -> Self {
1325 self.default_value = keys.into_iter().collect();
1326 self
1327 }
1328
1329 pub fn orientation(mut self, orientation: herogpui_core::Orientation) -> Self {
1331 self.orientation = orientation;
1332 self
1333 }
1334
1335 pub fn variant(mut self, variant: herogpui_core::FieldVariant) -> Self {
1337 self.variant = variant;
1338 self
1339 }
1340
1341 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
1346 crate::util::refine_sx(&mut self.sx, style);
1347 self
1348 }
1349
1350 pub fn is_disabled(mut self, v: bool) -> Self {
1352 self.is_disabled = v;
1353 self
1354 }
1355
1356 pub fn is_invalid(mut self, v: bool) -> Self {
1358 self.is_invalid = v;
1359 self
1360 }
1361
1362 pub fn is_read_only(mut self, v: bool) -> Self {
1364 self.is_read_only = v;
1365 self
1366 }
1367
1368 pub fn is_required(mut self, v: bool) -> Self {
1370 self.is_required = v;
1371 self
1372 }
1373
1374 pub fn on_change(
1376 mut self,
1377 handler: impl Fn(&std::collections::HashSet<gpui::SharedString>, &mut Window, &mut App)
1378 + 'static,
1379 ) -> Self {
1380 self.on_change = Some(std::sync::Arc::new(handler));
1381 self
1382 }
1383}
1384
1385impl RenderOnce for CheckboxGroup {
1386 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1387 let (value, own) = crate::util::controlled(
1389 window,
1390 cx,
1391 element_id::scoped(&self.id, "value"),
1392 self.value.clone(),
1393 self.default_value.clone(),
1394 );
1395 let reset_own = own.clone();
1396 let reset_state = Rc::downgrade(&self.form_state);
1397 let reset_options = self.options.clone();
1398 let reset_change = self
1399 .value
1400 .is_some()
1401 .then(|| self.on_change.clone())
1402 .flatten();
1403 self.form_state.borrow_mut().restore = (reset_own.is_some() || reset_change.is_some())
1404 .then(|| {
1405 let default_value = self.default_value.clone();
1406 let reset_state = reset_state.clone();
1407 let reset_options = reset_options.clone();
1408 crate::util::shared(move |window: &mut Window, cx: &mut App| {
1409 if let Some(state) = reset_state.upgrade() {
1410 state.borrow_mut().value = crate::form::FormValue::Keys(
1411 reset_options
1412 .iter()
1413 .filter(|option| {
1414 default_value.contains(&option.key) && !option.is_disabled
1415 })
1416 .map(|option| option.key.clone())
1417 .collect(),
1418 );
1419 }
1420 if let Some(held) = &reset_own {
1421 held.update(cx, |value, cx| {
1422 *value = default_value.clone();
1423 cx.notify();
1424 });
1425 }
1426 if let Some(on_change) = &reset_change {
1427 on_change(&default_value, window, cx);
1428 }
1429 }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
1430 });
1431 let form_values = self
1432 .options
1433 .iter()
1434 .filter(|option| {
1435 !self.is_disabled && !option.is_disabled && value.contains(&option.key)
1436 })
1437 .map(|option| option.key.clone())
1438 .collect();
1439
1440 let colors = cx.colors();
1441 let is_invalid = self.is_invalid || self.error_message.is_some();
1442 {
1443 let mut state = self.form_state.borrow_mut();
1444 state.value = crate::form::FormValue::Keys(form_values);
1445 state.is_invalid = is_invalid;
1446 state.is_successful = !self.is_disabled;
1447 state.focus = None;
1448 }
1449 let first_enabled = self
1450 .options
1451 .iter()
1452 .position(|option| !self.is_disabled && !option.is_disabled);
1453
1454 let group_name = a11y::Name::field(
1457 self.label.as_ref(),
1458 self.description.as_ref(),
1459 &crate::validation::resolve(self.is_invalid, &[], None, self.error_message.clone()),
1460 );
1461 let mut root = gpui::div()
1462 .id(self.id.clone())
1463 .a11y_named(a11y::Role::Group, &group_name)
1464 .flex()
1465 .flex_col()
1466 .gap(px(16.));
1467
1468 if let Some(label) = &self.label {
1469 root = root.child(
1470 crate::field::Label::new(label.clone())
1471 .is_required(self.is_required)
1472 .is_disabled(self.is_disabled)
1473 .is_invalid(is_invalid),
1474 );
1475 }
1476
1477 let mut list = gpui::div().flex().gap(px(16.));
1479 list = match self.orientation {
1480 herogpui_core::Orientation::Vertical => list.flex_col(),
1481 herogpui_core::Orientation::Horizontal => list.flex_row().flex_wrap(),
1482 };
1483
1484 for (index, option) in self.options.iter().enumerate() {
1485 let key = option.key.clone();
1486 let checked = value.contains(&key);
1487 let disabled = self.is_disabled || option.is_disabled;
1488
1489 let mut label_el = gpui::div()
1490 .flex()
1491 .flex_col()
1492 .gap(px(4.))
1494 .child(gpui::div().child(option.label.to_string()));
1495 if let Some(description) = &option.description {
1496 label_el = label_el.child(
1497 gpui::div()
1498 .text_size(px(12.))
1499 .line_height(px(16.))
1500 .font_weight(gpui::FontWeight::NORMAL)
1501 .text_color(colors.muted)
1502 .child(description.to_string()),
1503 );
1504 }
1505
1506 let selection = value.clone();
1507 let on_change = self.on_change.clone();
1508 let own = own.clone();
1509 let mut checkbox = Checkbox::new(element_id::indexed(&self.id, "opt", index))
1510 .is_selected(checked)
1511 .is_disabled(disabled)
1512 .is_read_only(self.is_read_only)
1513 .is_invalid(is_invalid)
1514 .variant(self.variant)
1515 .label(label_el)
1516 .a11y_label(option.label.clone())
1517 .on_change(move |_next, window, cx| {
1518 let mut set = selection.clone();
1519 if !set.remove(&key) {
1520 set.insert(key.clone());
1521 }
1522 if let Some(held) = &own {
1525 held.update(cx, |v, cx| {
1526 *v = set.clone();
1527 cx.notify();
1528 });
1529 }
1530 if let Some(cb) = &on_change {
1531 cb(&set, window, cx);
1532 }
1533 });
1534 if first_enabled == Some(index) {
1535 checkbox = checkbox.form_focus_target(self.form_state.clone());
1536 }
1537 list = list.child(checkbox);
1538 }
1539
1540 root = root.child(list);
1541
1542 let error = is_invalid.then(|| self.error_message.clone()).flatten();
1543 if let Some(error) = crate::anim::field_error_panel(&self.id, error, window, cx) {
1544 root = root.child(error);
1545 } else if let Some(description) = self.description {
1546 root = root.child(crate::field::Description::new(description));
1547 }
1548
1549 root = crate::util::apply_sx(root, &self.sx);
1550 root
1551 }
1552}
1553
1554#[cfg(test)]
1555mod size_tests {
1556 use super::*;
1557
1558 #[test]
1559 fn md_is_the_pinned_geometry_and_sm_scales_together() {
1560 assert_eq!(CheckboxSize::default(), CheckboxSize::Md);
1561 assert_eq!(CheckboxSize::Md.metrics(), (px(16.), px(12.), px(14.)));
1562 assert_eq!(CheckboxSize::Sm.metrics(), (px(14.), px(10.), px(12.)));
1563 assert_eq!(
1564 crate::util::leading_for(CheckboxSize::Sm.metrics().2),
1565 Some(px(16.))
1566 );
1567 }
1568}
1569
1570crate::util::impl_component_styled!(Checkbox, CheckboxGroup);