1use std::{
4 cell::{Cell, RefCell},
5 rc::Rc,
6 time::Duration,
7};
8
9use gpui::{
10 prelude::*, px, Animation, AnimationExt, AnyElement, App, IntoElement, ParentElement,
11 RenderOnce, StatefulInteractiveElement, Styled, Window,
12};
13use herogpui_core::{element_id, Color, Size};
14use herogpui_theme::ActiveTheme;
15
16use crate::a11y::{self, A11y as _};
17
18#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct SwitchState {
22 pub is_selected: bool,
24 pub is_hovered: bool,
26 pub is_pressed: bool,
28 pub is_focused: bool,
30 pub is_focus_visible: bool,
32 pub is_disabled: bool,
34 pub is_read_only: bool,
36 pub is_invalid: bool,
38 pub is_required: bool,
40}
41
42const THUMB_TRANSITION_MS: u64 = 300;
47const TRACK_TRANSITION_MS: u64 = 250;
51const THUMB_COLOR_TRANSITION_MS: u64 = 200;
54
55#[derive(Clone)]
56struct ThumbMotion {
57 selected: bool,
58 generation: usize,
59 from: f32,
60 position: Rc<Cell<f32>>,
61}
62
63struct ThumbMotionFrame {
64 base: gpui::ElementId,
65 generation: usize,
66 from: f32,
67 to: f32,
68 position: Rc<Cell<f32>>,
69 animate: bool,
70}
71
72impl ThumbMotionFrame {
73 fn render(self, thumb: gpui::Div, travel: gpui::Pixels) -> AnyElement {
74 if !self.animate {
75 self.position.set(self.to);
76 return thumb.ml(travel * self.to).into_any_element();
77 }
78
79 let slide = element_id::indexed(&self.base, "thumb-slide", self.generation);
80 let position = self.position;
81 let from = self.from;
82 let to = self.to;
83 thumb
84 .with_animation(
85 slide,
86 Animation::new(Duration::from_millis(THUMB_TRANSITION_MS))
87 .with_easing(|t| crate::anim::Curve::OutFluid.at(t)),
88 move |thumb, delta| {
89 let fraction = from + (to - from) * delta;
90 position.set(fraction);
91 thumb.ml(travel * fraction)
92 },
93 )
94 .into_any_element()
95 }
96}
97
98#[derive(Clone)]
99struct TrackMotion {
100 target: gpui::Hsla,
101 generation: usize,
102 from: gpui::Hsla,
103 color: Rc<Cell<gpui::Hsla>>,
104}
105
106struct TrackMotionFrame {
107 base: gpui::ElementId,
108 generation: usize,
109 from: gpui::Hsla,
110 to: gpui::Hsla,
111 color: Rc<Cell<gpui::Hsla>>,
112 animation_key: &'static str,
113 duration_ms: u64,
114 curve: crate::anim::Curve,
115 animate: bool,
116}
117
118impl TrackMotionFrame {
119 fn render(self, fill: gpui::Div) -> AnyElement {
120 if !self.animate {
121 self.color.set(self.to);
122 return fill.bg(self.to).into_any_element();
123 }
124
125 let background = element_id::indexed(&self.base, self.animation_key, self.generation);
126 let color = self.color;
127 let from = self.from;
128 let to = self.to;
129 fill.with_animation(
130 background,
131 Animation::new(Duration::from_millis(self.duration_ms))
132 .with_easing(move |t| self.curve.at(t)),
133 move |fill, delta| {
134 let next = herogpui_core::mix_oklab(from, to, delta);
135 color.set(next);
136 fill.bg(next)
137 },
138 )
139 .into_any_element()
140 }
141}
142
143fn thumb_motion(
144 id: &gpui::ElementId,
145 selected: bool,
146 window: &mut Window,
147 cx: &mut App,
148) -> ThumbMotionFrame {
149 let state = window.use_keyed_state(element_id::scoped(id, "thumb-motion"), cx, |_, _| {
150 ThumbMotion {
151 selected,
152 generation: 0,
153 from: if selected { 1.0 } else { 0.0 },
154 position: Rc::new(Cell::new(if selected { 1.0 } else { 0.0 })),
155 }
156 });
157 let mut current = state.read(cx).clone();
158 let to = if selected { 1.0 } else { 0.0 };
159 if current.selected != selected {
160 current.selected = selected;
161 current.generation = current.generation.wrapping_add(1);
162 current.from = current.position.get();
163 state.update(cx, |stored, _| *stored = current.clone());
164 }
165 if ActiveTheme::reduce_motion(cx) && (current.position.get() - to).abs() > f32::EPSILON {
166 current.from = to;
167 current.position.set(to);
168 state.update(cx, |stored, _| *stored = current.clone());
169 }
170 ThumbMotionFrame {
171 base: id.clone(),
172 generation: current.generation,
173 from: current.from,
174 to,
175 position: current.position,
176 animate: current.generation != 0
177 && !ActiveTheme::reduce_motion(cx)
178 && (current.from - to).abs() > f32::EPSILON,
179 }
180}
181
182#[allow(clippy::too_many_arguments)] fn color_motion(
184 id: &gpui::ElementId,
185 target: gpui::Hsla,
186 state_key: &'static str,
187 animation_key: &'static str,
188 duration_ms: u64,
189 curve: crate::anim::Curve,
190 window: &mut Window,
191 cx: &mut App,
192) -> TrackMotionFrame {
193 let state = window.use_keyed_state(element_id::scoped(id, state_key), cx, |_, _| TrackMotion {
194 target,
195 generation: 0,
196 from: target,
197 color: Rc::new(Cell::new(target)),
198 });
199 let mut current = state.read(cx).clone();
200 if current.target != target {
201 current.target = target;
202 current.generation = current.generation.wrapping_add(1);
203 current.from = current.color.get();
204 state.update(cx, |stored, _| *stored = current.clone());
205 }
206 let reduce_motion = ActiveTheme::reduce_motion(cx);
207 if reduce_motion && current.color.get() != target {
208 current.from = target;
209 current.color.set(target);
210 state.update(cx, |stored, _| *stored = current.clone());
211 }
212 let animate = !reduce_motion && current.generation != 0 && current.color.get() != target;
213 TrackMotionFrame {
214 base: id.clone(),
215 generation: current.generation,
216 from: current.from,
217 to: target,
218 color: current.color,
219 animation_key,
220 duration_ms,
221 curve,
222 animate,
223 }
224}
225
226fn track_motion(
227 id: &gpui::ElementId,
228 target: gpui::Hsla,
229 window: &mut Window,
230 cx: &mut App,
231) -> TrackMotionFrame {
232 color_motion(
233 id,
234 target,
235 "track-motion",
236 "track-background",
237 TRACK_TRANSITION_MS,
238 crate::anim::Curve::Smooth,
239 window,
240 cx,
241 )
242}
243
244fn thumb_color_motion(
245 id: &gpui::ElementId,
246 target: gpui::Hsla,
247 window: &mut Window,
248 cx: &mut App,
249) -> TrackMotionFrame {
250 color_motion(
251 id,
252 target,
253 "thumb-color-motion",
254 "thumb-background",
255 THUMB_COLOR_TRANSITION_MS,
256 crate::anim::Curve::Out,
257 window,
258 cx,
259 )
260}
261
262#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
264#[derive(IntoElement)]
265pub struct Switch {
266 value: Option<gpui::SharedString>,
269 validation_behavior: crate::form::ValidationBehavior,
271 name: Option<gpui::SharedString>,
274 id: gpui::ElementId,
275 content: Option<std::sync::Arc<dyn Fn(SwitchState) -> AnyElement + 'static>>,
278 checked: Option<bool>,
281 default_checked: bool,
282 size: Size,
283 text_size: Option<gpui::Pixels>,
285 is_disabled: bool,
286 is_invalid: bool,
287 validate: Option<crate::validation::Validator<bool>>,
289 validation_errors: Vec<gpui::SharedString>,
291 is_required: bool,
292 is_read_only: bool,
293 label: Option<AnyElement>,
294 description: Option<gpui::SharedString>,
297 thumb_off: Option<AnyElement>,
300 thumb_on: Option<AnyElement>,
301 label_first: bool,
304 on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
307 form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
308 hover_bg: Option<gpui::Hsla>,
311 sx: Option<Box<gpui::StyleRefinement>>,
313 recipes: Vec<gpui::SharedString>,
314}
315
316impl Switch {
317 pub fn on_press(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
320 self.on_change(handler)
321 }
322
323 pub fn validate(mut self, f: impl Fn(&bool) -> Option<gpui::SharedString> + 'static) -> Self {
328 self.validate = Some(std::sync::Arc::new(f));
329 self
330 }
331
332 pub fn validation_errors(
335 mut self,
336 errors: impl IntoIterator<Item = impl Into<gpui::SharedString>>,
337 ) -> Self {
338 self.validation_errors = errors.into_iter().map(Into::into).collect();
339 self
340 }
341
342 pub fn is_invalid(mut self, v: bool) -> Self {
344 self.is_invalid = v;
345 self
346 }
347
348 pub fn is_required(mut self, v: bool) -> Self {
350 self.is_required = v;
351 self
352 }
353
354 pub fn is_read_only(mut self, v: bool) -> Self {
356 self.is_read_only = v;
357 self
358 }
359
360 pub fn new(id: impl Into<gpui::ElementId>) -> Self {
362 Self {
363 content: None,
364 value: None,
365 validation_behavior: crate::form::ValidationBehavior::Native,
366 name: None,
367 id: id.into(),
368 checked: None,
369 default_checked: false,
370 size: Size::Md,
371 text_size: None,
372 is_disabled: false,
373 is_invalid: false,
374 validate: None,
375 validation_errors: Vec::new(),
376 is_required: false,
377 is_read_only: false,
378 label: None,
379 description: None,
380 thumb_off: None,
381 thumb_on: None,
382 label_first: false,
383 on_change: None,
384 hover_bg: None,
385 recipes: Vec::new(),
386 form_state: Rc::new(RefCell::new(crate::form::LiveFormFieldState {
387 value: crate::form::FormValue::Flag(false),
388 is_invalid: false,
389 is_successful: true,
390 focus: None,
391 restore: None,
392 })),
393 sx: None,
394 }
395 }
396
397 pub fn value(mut self, value: impl Into<gpui::SharedString>) -> Self {
402 self.value = Some(value.into());
403 self
404 }
405
406 pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
409 self.validation_behavior = behavior;
410 self
411 }
412
413 pub fn name(mut self, name: impl Into<gpui::SharedString>) -> Self {
415 self.name = Some(name.into());
416 self
417 }
418
419 pub fn form_field(&self) -> Option<crate::form::FormField> {
442 let name = self.name.clone()?;
443 let checked = self.checked.unwrap_or(self.default_checked);
444 let validity = crate::validation::resolve(
445 self.is_invalid,
446 &self.validation_errors,
447 self.validate.as_ref().and_then(|f| f(&checked)),
448 None,
449 );
450 {
451 let mut state = self.form_state.borrow_mut();
452 state.value = match (&self.value, checked) {
453 (Some(value), true) => crate::form::FormValue::Text(value.clone()),
454 _ => crate::form::FormValue::Flag(checked),
455 };
456 state.is_invalid = validity.is_invalid;
457 state.is_successful = !self.is_disabled;
458 }
459 Some(
460 crate::form::FormField::live(name, self.form_state.clone())
461 .is_required(self.is_required)
462 .validation_behavior(self.validation_behavior),
463 )
464 }
465
466 pub fn is_selected(mut self, v: bool) -> Self {
470 self.checked = Some(v);
471 self
472 }
473
474 pub fn default_selected(mut self, v: bool) -> Self {
479 self.default_checked = v;
480 self
481 }
482
483 pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
486 self.hover_bg = Some(color.into());
487 self
488 }
489
490 pub fn size(mut self, s: Size) -> Self {
492 self.size = s;
493 self
494 }
495
496 pub fn text_size(mut self, size: impl Into<gpui::Pixels>) -> Self {
503 self.text_size = Some(size.into());
504 self
505 }
506
507 pub fn recipe(mut self, name: impl Into<gpui::SharedString>) -> Self {
510 self.recipes.push(name.into());
511 self
512 }
513
514 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
519 crate::util::refine_sx(&mut self.sx, style);
520 self
521 }
522
523 pub fn is_disabled(mut self, v: bool) -> Self {
525 self.is_disabled = v;
526 self
527 }
528
529 pub fn content(mut self, render: impl Fn(SwitchState) -> AnyElement + 'static) -> Self {
534 self.content = Some(std::sync::Arc::new(render));
535 self
536 }
537
538 pub fn label(mut self, el: impl IntoElement) -> Self {
540 self.label = Some(el.into_any_element());
541 self
542 }
543
544 pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
546 self.description = Some(text.into());
547 self
548 }
549
550 pub fn thumb_icons(mut self, off: impl IntoElement, on: impl IntoElement) -> Self {
555 self.thumb_off = Some(off.into_any_element());
556 self.thumb_on = Some(on.into_any_element());
557 self
558 }
559
560 pub fn label_first(mut self, v: bool) -> Self {
563 self.label_first = v;
564 self
565 }
566
567 pub fn on_change(mut self, f: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
569 self.on_change = Some(std::sync::Arc::new(f));
570 self
571 }
572}
573
574impl RenderOnce for Switch {
575 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
576 let (checked, own) = crate::util::controlled(
578 window,
579 cx,
580 element_id::scoped(&self.id, "checked"),
581 self.checked,
582 self.default_checked,
583 );
584 let reset_own = own.clone();
585 let reset_state = Rc::downgrade(&self.form_state);
586 let reset_value = self.value.clone();
587 let reset_change = self
588 .checked
589 .is_some()
590 .then(|| self.on_change.clone())
591 .flatten();
592 self.form_state.borrow_mut().restore = (reset_own.is_some() || reset_change.is_some())
593 .then(|| {
594 let default_checked = self.default_checked;
595 crate::util::shared(move |window: &mut Window, cx: &mut App| {
596 if let Some(state) = reset_state.upgrade() {
597 state.borrow_mut().value = match (&reset_value, default_checked) {
598 (Some(value), true) => crate::form::FormValue::Text(value.clone()),
599 _ => crate::form::FormValue::Flag(default_checked),
600 };
601 }
602 if let Some(held) = &reset_own {
603 held.update(cx, |checked, cx| {
604 *checked = default_checked;
605 cx.notify();
606 });
607 }
608 if let Some(on_change) = &reset_change {
609 on_change(&default_checked, window, cx);
610 }
611 }) as std::sync::Arc<dyn Fn(&mut Window, &mut App)>
612 });
613
614 let focus_handle =
617 crate::util::tab_stop_handle(element_id::scoped(&self.id, "focus"), window, cx);
618 self.form_state.borrow_mut().focus = Some(focus_handle.clone());
619 let interaction =
622 crate::util::interaction(element_id::scoped(&self.id, "interaction"), window, cx);
623 let thumb_motion = thumb_motion(&self.id, checked, window, cx);
624
625 let validity = crate::validation::resolve(
627 self.is_invalid,
628 &self.validation_errors,
629 self.validate.as_ref().and_then(|f| f(&checked)),
630 None,
631 );
632 {
633 let mut state = self.form_state.borrow_mut();
634 state.value = match (&self.value, checked) {
635 (Some(value), true) => crate::form::FormValue::Text(value.clone()),
636 _ => crate::form::FormValue::Flag(checked),
637 };
638 state.is_invalid = validity.is_invalid;
639 state.is_successful = !self.is_disabled;
640 }
641
642 let (w, h, thumb_w, thumb_h, track_r, thumb_r) = match self.size {
649 Size::Sm => (px(32.), px(16.), px(16.5), px(12.), px(8.), px(6.)),
650 Size::Md => (px(40.), px(20.), px(22.), px(16.), px(12.), px(8.)),
651 Size::Lg => (px(48.), px(24.), px(27.5), px(20.), px(12.), px(12.)),
652 };
653 let thumb_inset = px(2.);
654 let thumb_travel = w - thumb_w - thumb_inset * 2.;
655
656 let (
657 accent_color,
658 accent_hover,
659 accent_foreground,
660 default_color,
661 default_hover,
662 default_foreground,
663 danger_color,
664 ) = {
665 let sem = cx.role(Color::Accent);
666 let colors = cx.colors();
667 (
668 sem.color,
669 sem.hover(),
670 sem.foreground,
671 colors.default.color,
672 colors.default.hover(),
673 colors.default.foreground,
674 colors.danger.color,
675 )
676 };
677
678 let sx_corners = crate::util::fill_unspecified_corners(
681 crate::util::sx_radius(&self.sx),
682 cx.theme().components.switch.resolve(&self.recipes).radius,
683 );
684 let track_bg = if checked { accent_color } else { default_color };
685 let hover_bg = self
686 .hover_bg
687 .unwrap_or(if checked { accent_hover } else { default_hover });
688 let interaction_state = *interaction.read(cx);
689 let track_target = if !self.is_disabled
690 && !self.is_read_only
691 && (interaction_state.0 || interaction_state.1)
692 {
693 hover_bg
694 } else {
695 track_bg
696 };
697 let track_motion_frame = track_motion(&self.id, track_target, window, cx);
698 let disabled_opacity = cx.layout().disabled_opacity;
699 let field_shadow = cx.layout().field_shadow.clone();
700
701 let name = a11y::Name::field(None, self.description.as_ref(), &validity);
705 let mut track = gpui::div()
706 .id(element_id::scoped(&self.id, "control"))
707 .relative()
708 .w(w)
709 .h(h)
710 .rounded(track_r)
711 .map(|track| crate::util::round_sx_corners(track, &sx_corners))
712 .overflow_hidden()
719 .bg(track_bg)
720 .flex()
721 .items_center()
722 .px(thumb_inset)
723 .when(!self.is_disabled, |t| t.cursor(crate::util::interactive_cursor(cx)));
724 track = track.child(
729 track_motion_frame.render(
730 gpui::div()
731 .absolute()
732 .inset_0()
733 .rounded(track_r)
734 .map(|fill| crate::util::round_sx_corners(fill, &sx_corners)),
735 ),
736 );
737
738 let mut thumb_el = gpui::div()
742 .w(thumb_w)
743 .h(thumb_h)
744 .rounded(thumb_r)
745 .map(|thumb| crate::util::round_sx_corners(thumb, &sx_corners))
746 .flex_shrink_0()
747 .flex()
749 .items_center()
750 .justify_center();
751 if let Some(glyph) = if checked {
752 self.thumb_on
753 } else {
754 self.thumb_off
755 } {
756 thumb_el = thumb_el.child(glyph);
757 }
758 let thumb_el = if checked {
759 thumb_el
760 .when(self.is_disabled, |thumb| thumb.opacity(0.4))
761 .shadow(vec![
763 gpui::BoxShadow {
764 color: gpui::black().alpha(0.02),
765 offset: gpui::point(px(0.), px(0.)),
766 blur_radius: px(5.),
767 spread_radius: px(0.),
768 inset: false,
769 },
770 gpui::BoxShadow {
771 color: gpui::black().alpha(0.06),
772 offset: gpui::point(px(0.), px(2.)),
773 blur_radius: px(10.),
774 spread_radius: px(0.),
775 inset: false,
776 },
777 gpui::BoxShadow {
778 color: gpui::black().alpha(0.3),
779 offset: gpui::point(px(0.), px(0.)),
780 blur_radius: px(1.),
781 spread_radius: px(0.),
782 inset: false,
783 },
784 ])
785 } else {
786 thumb_el.when(!field_shadow.is_empty(), |t| t.shadow(field_shadow.clone()))
787 };
788 let thumb_bg = if checked {
789 accent_foreground
790 } else if self.is_disabled {
791 default_foreground.alpha(0.2)
792 } else {
793 herogpui_theme::white()
794 };
795 let thumb_color_frame = thumb_color_motion(&self.id, thumb_bg, window, cx);
796 let thumb_slot = gpui::div()
800 .w(thumb_w)
801 .h(thumb_h)
802 .flex_shrink_0()
803 .child(thumb_color_frame.render(thumb_el));
804 track = track.child(thumb_motion.render(thumb_slot, thumb_travel));
805
806 let ring_radius = crate::button::uniform_ring_radius(None, track_r, &sx_corners);
816 if !self.is_disabled && ring_radius.is_none() {
817 track =
818 crate::util::ring_if_focused(track, &focus_handle, true, Vec::new(), window, cx);
819 }
820 let mut track = gpui::div().relative().child(track);
821 if let (false, Some(ring_radius)) = (self.is_disabled, ring_radius) {
822 track = crate::util::ring_overlay_if_focused(
823 track,
824 &focus_handle,
825 true,
826 ring_radius,
827 Vec::new(),
828 window,
829 cx,
830 );
831 }
832
833 let mut el = gpui::div()
836 .id(self.id.clone())
837 .a11y_named(a11y::Role::Switch, &name)
838 .a11y_checked(checked, false)
839 .when(!self.is_disabled, |root| root.track_focus(&focus_handle))
840 .flex()
841 .items_center()
842 .gap(px(12.))
843 .text_size(px(14.))
844 .line_height(px(20.))
845 .when_some(self.text_size, |el, size| {
846 el.text_size(size)
847 .line_height(crate::util::leading_for(size).unwrap_or(px(20.)))
848 })
849 .font_weight(gpui::FontWeight::MEDIUM)
850 .when(!self.is_disabled, |root| {
851 root.cursor(crate::util::interactive_cursor(cx))
852 });
853 let content_row = self.content.clone().map(|render| {
854 let (is_hovered, is_pressed) = *interaction.read(cx);
855 let focused = focus_handle.is_focused(window);
856 render(SwitchState {
857 is_hovered,
858 is_pressed,
859 is_focused: focused,
860 is_focus_visible: focused && crate::util::focus_visible(cx),
861 is_selected: checked,
862 is_disabled: self.is_disabled,
863 is_read_only: self.is_read_only,
864 is_invalid: validity.is_invalid,
865 is_required: self.is_required,
866 })
867 });
868 let label_text_size = self.text_size;
869 let label_row = self.label.map(|label| {
870 gpui::div()
871 .flex()
872 .items_center()
873 .text_size(px(14.))
877 .line_height(px(20.))
879 .when_some(label_text_size, |el, size| {
880 el.text_size(size)
881 .line_height(crate::util::leading_for(size).unwrap_or(px(20.)))
882 })
883 .font_weight(gpui::FontWeight::MEDIUM)
884 .gap(px(4.))
885 .child(label)
886 .when(self.is_required, |r| {
887 r.child(gpui::div().text_color(danger_color).child("*"))
888 })
889 });
890 match (self.label_first, label_row, content_row) {
891 (true, _, Some(content)) => el = el.child(content).child(track),
894 (false, _, Some(content)) => el = el.child(track).child(content),
895 (true, Some(label), None) => el = el.child(label).child(track),
896 (false, Some(label), None) => el = el.child(track).child(label),
897 (_, None, None) => el = el.child(track),
898 }
899
900 if !self.is_disabled {
901 el = crate::util::track_interaction(el, &interaction);
902 }
903 if !self.is_disabled && !self.is_read_only && (self.on_change.is_some() || own.is_some()) {
904 let on_change = self.on_change;
905 el = el.on_click(move |_, window, cx| {
906 if let Some(held) = &own {
908 held.update(cx, |v, cx| {
909 *v = !checked;
910 cx.notify();
911 });
912 }
913 if let Some(cb) = &on_change {
914 cb(&!checked, window, cx);
915 }
916 });
917 }
918
919 if !self.is_disabled {
920 el = crate::util::record_focus_bounds(el, &focus_handle, window, cx);
921 }
922 let indent = w + px(12.);
925 let mut root = gpui::div()
926 .flex()
927 .flex_col()
928 .gap(px(4.))
929 .when(self.is_disabled, |root| root.opacity(disabled_opacity))
930 .child(el)
931 .when_some(self.description, |root, description| {
932 root.child(
933 gpui::div()
934 .pl(indent)
935 .child(crate::field::Description::new(description)),
936 )
937 });
938 let error = validity.first();
939 if let Some(error) = crate::anim::field_error_panel(&self.id, error, window, cx) {
940 root = root.child(gpui::div().pl(indent).child(error));
941 }
942 crate::util::apply_sx(root, &self.sx).into_any_element()
943 }
944}
945
946#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
953#[derive(IntoElement)]
954pub struct SwitchGroup {
955 orientation: herogpui_core::Orientation,
956 items: Vec<AnyElement>,
957 sx: Option<Box<gpui::StyleRefinement>>,
959}
960
961impl SwitchGroup {
962 pub fn new() -> Self {
964 Self {
965 orientation: herogpui_core::Orientation::Vertical,
967 items: Vec::new(),
968 sx: None,
969 }
970 }
971
972 pub fn orientation(mut self, orientation: herogpui_core::Orientation) -> Self {
974 self.orientation = orientation;
975 self
976 }
977
978 pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
983 crate::util::refine_sx(&mut self.sx, style);
984 self
985 }
986
987 pub fn child(mut self, el: impl IntoElement) -> Self {
989 self.items.push(el.into_any_element());
990 self
991 }
992}
993
994impl Default for SwitchGroup {
995 fn default() -> Self {
996 Self::new()
997 }
998}
999
1000impl ParentElement for SwitchGroup {
1001 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1002 self.items.extend(elements);
1003 }
1004}
1005
1006impl RenderOnce for SwitchGroup {
1007 fn render(self, _window: &mut Window, _cx: &mut App) -> impl IntoElement {
1008 let vertical = self.orientation == herogpui_core::Orientation::Vertical;
1009 let root = gpui::div().flex().flex_col().gap(px(24.)).child(
1010 gpui::div()
1011 .flex()
1012 .map(|el| {
1013 if vertical {
1014 el.flex_col()
1015 } else {
1016 el.flex_row()
1017 }
1018 })
1019 .gap(px(16.))
1020 .children(self.items),
1021 );
1022 crate::util::apply_sx(root, &self.sx)
1023 }
1024}
1025
1026crate::util::impl_component_styled!(Switch, SwitchGroup);