Skip to main content

herogpui_components/
switch.rs

1//! Switch — port of `@heroui/switch`.
2
3use 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/// State handed to Switch's children render function.
19#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
20#[non_exhaustive]
21pub struct SwitchState {
22    /// Whether the switch is on.
23    pub is_selected: bool,
24    /// Whether the pointer is over the switch.
25    pub is_hovered: bool,
26    /// Whether the switch is being pressed.
27    pub is_pressed: bool,
28    /// Whether the switch has keyboard focus.
29    pub is_focused: bool,
30    /// Whether the focus ring is visible (keyboard focus).
31    pub is_focus_visible: bool,
32    /// Whether the switch is disabled.
33    pub is_disabled: bool,
34    /// Whether the switch is read-only.
35    pub is_read_only: bool,
36    /// Whether the switch is invalid.
37    pub is_invalid: bool,
38    /// Whether the switch is required.
39    pub is_required: bool,
40}
41
42/// `.switch__thumb` transitions its margin for 300ms with
43/// `--ease-out-fluid`. The current fraction lives outside the animation
44/// element so reversing mid-flight starts from the rendered position rather
45/// than jumping back to the previous endpoint.
46const THUMB_TRANSITION_MS: u64 = 300;
47/// `.switch__control` transitions its background color for 250ms with
48/// `--ease-smooth`. The changing animation id belongs to the listener-free
49/// fill child, so the track keeps its stable interaction path.
50const TRACK_TRANSITION_MS: u64 = 250;
51/// `.switch__thumb` transitions its background color for 200ms with
52/// `--ease-out`, independently of the 300ms margin travel.
53const 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)] // one parameter per motion track channel
183fn 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/// HeroUI Switch (`<Switch>`).
263#[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` — what this control submits when checked. HTML's default is
267    /// `"on"`.
268    value: Option<gpui::SharedString>,
269    /// `validationBehavior` — carried on this control's form field.
270    validation_behavior: crate::form::ValidationBehavior,
271    /// `name` — the name this control submits under; read back by
272    /// [`Self::form_field`].
273    name: Option<gpui::SharedString>,
274    id: gpui::ElementId,
275    /// v3's `children`-as-a-function: handed the interactive state and drawn in
276    /// place of the label.
277    content: Option<std::sync::Arc<dyn Fn(SwitchState) -> AnyElement + 'static>>,
278    /// `isSelected` — `None` leaves the component holding the state, seeded
279    /// from `defaultSelected`.
280    checked: Option<bool>,
281    default_checked: bool,
282    size: Size,
283    /// The label's and content's font size, in place of `text-sm`.
284    text_size: Option<gpui::Pixels>,
285    is_disabled: bool,
286    is_invalid: bool,
287    /// `validate` — run by the component, not the caller.
288    validate: Option<crate::validation::Validator<bool>>,
289    /// `validationErrors` — messages from a server round-trip.
290    validation_errors: Vec<gpui::SharedString>,
291    is_required: bool,
292    is_read_only: bool,
293    label: Option<AnyElement>,
294    /// `Description` — v3 composes it as a sibling of the button row, indented
295    /// to sit under the label.
296    description: Option<gpui::SharedString>,
297    /// `Switch.Thumb` children — v3 draws an icon inside the thumb, one per
298    /// state (`.switch__thumb > *` is a centred, full-size box).
299    thumb_off: Option<AnyElement>,
300    thumb_on: Option<AnyElement>,
301    /// Whether the label comes before the control. v3 gets this from the order
302    /// of `Switch.Content`'s children.
303    label_first: bool,
304    /// `Arc` rather than `Box`: the handler is bound twice, once for the
305    /// pointer and once for Enter and Space.
306    on_change: Option<std::sync::Arc<dyn Fn(&bool, &mut Window, &mut App) + 'static>>,
307    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
308    /// The track's hover/press fill, in place of the checked / unchecked
309    /// hover token. The Tween motion is unchanged.
310    hover_bg: Option<gpui::Hsla>,
311    /// The `sx` slot, refined over the root style at the end of render.
312    sx: Option<Box<gpui::StyleRefinement>>,
313    recipes: Vec<gpui::SharedString>,
314}
315
316impl Switch {
317    /// `onPress` — the v3 name for [`Switch::on_change`], which already
318    /// reports the next state.
319    pub fn on_press(self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
320        self.on_change(handler)
321    }
322
323    /// `validate` — returns the message to show, or `None` when the state is fine.
324    ///
325    /// The component runs it and surfaces the result, so a caller does not have
326    /// to mirror the logic into `is_invalid`.
327    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    /// `validationErrors` — messages produced elsewhere, shown ahead of
333    /// whatever `validate` returns.
334    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    /// Sets the invalid state (v3 `isInvalid`).
343    pub fn is_invalid(mut self, v: bool) -> Self {
344        self.is_invalid = v;
345        self
346    }
347
348    /// Sets the required state (v3 `isRequired`).
349    pub fn is_required(mut self, v: bool) -> Self {
350        self.is_required = v;
351        self
352    }
353
354    /// Sets the read-only state (v3 `isReadOnly`).
355    pub fn is_read_only(mut self, v: bool) -> Self {
356        self.is_read_only = v;
357        self
358    }
359
360    /// Creates a switch with the given element id.
361    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    /// `value` — what this control submits when checked.
398    ///
399    /// An HTML checkbox submits `"on"` unless told otherwise; this is that
400    /// override, and it is read by [`Self::form_field`].
401    pub fn value(mut self, value: impl Into<gpui::SharedString>) -> Self {
402        self.value = Some(value.into());
403        self
404    }
405
406    /// `validationBehavior` — `Allow` shows the message without blocking form
407    /// submission. Carried on the [`Self::form_field`] this control produces.
408    pub fn validation_behavior(mut self, behavior: crate::form::ValidationBehavior) -> Self {
409        self.validation_behavior = behavior;
410        self
411    }
412
413    /// `name` — the name this control submits under.
414    pub fn name(mut self, name: impl Into<gpui::SharedString>) -> Self {
415        self.name = Some(name.into());
416        self
417    }
418
419    /// The `Form` field this control submits, when it has a `name`.
420    ///
421    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
422    /// its ancestor, so the control hands the pair over instead. Borrows, so the
423    /// control is still yours to place:
424    ///
425    /// ```
426    /// # use gpui::{prelude::*, Window};
427    /// # use herogpui_components::{Form, Switch};
428    /// # struct Demo;
429    /// # impl Render for Demo {
430    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
431    /// #         let form = Form::new();
432    /// #         let control = Switch::new("wifi").name("wifi");
433    /// let field = control.form_field();
434    /// form.field(field.unwrap()).child(control)
435    /// #     }
436    /// # }
437    /// # let mut tcx = gpui::TestAppContext::single();
438    /// # tcx.update(herogpui_theme::ThemeProvider::init);
439    /// # let _ = tcx.add_window_view(|_, _| Demo);
440    /// ```
441    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    /// Controlled checked state.
467    /// `isSelected` — the controlled state; `None` leaves the component
468    /// holding it, seeded from `defaultSelected`.
469    pub fn is_selected(mut self, v: bool) -> Self {
470        self.checked = Some(v);
471        self
472    }
473
474    /// `defaultSelected` — the uncontrolled initial state.
475    ///
476    /// Only consulted when `checked` is not supplied; the switch then owns the
477    /// state and toggles itself.
478    pub fn default_selected(mut self, v: bool) -> Self {
479        self.default_checked = v;
480        self
481    }
482
483    /// The track's fill while hovered or pressed, in place of the checked /
484    /// unchecked hover token. The Tween motion is unchanged.
485    pub fn hover_bg(mut self, color: impl Into<gpui::Hsla>) -> Self {
486        self.hover_bg = Some(color.into());
487        self
488    }
489
490    /// Sets the size (v3 `size`).
491    pub fn size(mut self, s: Size) -> Self {
492        self.size = s;
493        self
494    }
495
496    /// The label's (and `content` row's) font size, in place of the shared
497    /// label's `text-sm`. A 12/14/16px size takes v3's leading pair
498    /// (16/20/24); any other keeps the 20px leading. The track, thumb, gap and
499    /// description keep their metrics, so the override changes the text and
500    /// its line box only. Not a v3 prop: v3 sets it with a class on
501    /// `Switch.Content`.
502    pub fn text_size(mut self, size: impl Into<gpui::Pixels>) -> Self {
503        self.text_size = Some(size.into());
504        self
505    }
506
507    /// Named theme overlay from [`herogpui_theme::ComponentThemes::switch`].
508    /// Stackable; a missing name adds no override.
509    pub fn recipe(mut self, name: impl Into<gpui::SharedString>) -> Self {
510        self.recipes.push(name.into());
511        self
512    }
513
514    /// The one slot for caller-owned low-level styling: GPUI's styling methods
515    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
516    /// applied to the switch's root element after every value the size, the
517    /// state and the active theme chose, so they win.
518    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    /// Sets the disabled state (v3 `isDisabled`).
524    pub fn is_disabled(mut self, v: bool) -> Self {
525        self.is_disabled = v;
526        self
527    }
528
529    /// Text shown next to the track (children slot in React).
530    /// v3's render function for a switch's children, handed its complete field
531    /// state. Hover and press are a frame behind the pointer because gpui
532    /// reports them to a handler.
533    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    /// Sets the label content rendered beside the control.
539    pub fn label(mut self, el: impl IntoElement) -> Self {
540        self.label = Some(el.into_any_element());
541        self
542    }
543
544    /// `Description` — help text under the control and label.
545    pub fn description(mut self, text: impl Into<gpui::SharedString>) -> Self {
546        self.description = Some(text.into());
547        self
548    }
549
550    /// `Switch.Thumb` children — what the thumb shows in each state.
551    ///
552    /// v3 composes an icon inside the thumb and swaps it on selection, which is
553    /// its "With Icons" example.
554    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    /// Puts the label before the control, which v3 does by ordering the
561    /// children of `Switch.Content`.
562    pub fn label_first(mut self, v: bool) -> Self {
563        self.label_first = v;
564        self
565    }
566
567    /// Sets the handler called with the new selected state (v3 `onChange`).
568    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        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
577        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        // The keyboard's focus target. `use_keyed_state` takes `cx` mutably, so
615        // it precedes every borrow of the theme.
616        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        // The track's background transition and an optional `content` closure
620        // read the same one-frame-late hover/press state.
621        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        // v3 order: the controlled flag, then server errors, then `validate`.
626        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        // `.switch__control` and `.switch__thumb` state their sizes in `rem`,
643        // so at a 16px root: track 32x16 / 40x20 / 48x24, and a thumb that is a
644        // rounded *rectangle* 1.375x as wide as it is tall -- 16.5x12 / 22x16 /
645        // 27.5x20 -- inset `ms-0.5` (2px) at each end. The track radii are
646        // `rounded-lg` for `sm` and `rounded-xl` above it; the thumb's are
647        // `rounded-md` / `rounded-lg` / `rounded-xl`.
648        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        // `default` is the v3 unchecked track. A soft (alpha) mix vanishes on
679        // a white overlay, so the track uses the solid role colour.
680        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        // `Switch.Content` is the interactive, labelled button in v3. Keep
702        // the role and focus target on the row so a click on the label has the
703        // same result as a click on the track; the track itself is visual.
704        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            // HeroUI clips the animated fill, thumb shadow and custom icon to
713            // the rounded control perimeter. Vanilla GPUI clips
714            // `overflow_hidden()` to the track's rectangle, so each descendant
715            // layer carries the track radius itself (see
716            // `util::inner_fill_radius`); the track clip remains as the
717            // backstop for anything those layers do not cover.
718            .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        // v3's switch stylesheet has no invalid rule at all -- the state shows
725        // in the field error below, not as a danger ring on the track, so the
726        // ring this used to draw was an invention.
727
728        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        // Thumb sits at the end when checked, start when unchecked. v3 moves it
739        // by margin rather than transform; `ThumbMotionFrame` animates that
740        // margin and preserves its current fraction across a reversal.
741        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            // `.switch__thumb > *` is a centred, full-size box.
748            .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                // The checked thumb carries its own three-layer shadow.
762                .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        // Keep the animated color on the painted thumb while the outer slot
797        // owns the margin transition. This preserves the stable hit geometry
798        // and lets the two upstream transitions run at their own durations.
799        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        // The track's clip is load-bearing -- it is what keeps the checked
807        // thumb's three-layer shadow and a custom thumb icon inside the
808        // rounded perimeter -- so the focus ring, which hangs outside the box,
809        // cannot be one of its children. A carrier that takes the same box
810        // without clipping hosts it instead; the track keeps the id, the
811        // cursor and every listener, and the overlay only needs the focused
812        // flag. An `sx` refinement that breaks the corner symmetry has no one
813        // radius the overlay's scalar bands could take, so it keeps the spread
814        // shadow, which dilates the element's own per-corner shape.
815        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        // `.switch__content` is `gap-3`. v3 gets the label's side from the order
834        // of its children, so `label_first` puts it before the control.
835        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                // The label is the shared `Label` part, `.label` = `text-sm
874                // font-medium`. (The `.switch__label` `text-base` rule was
875                // never applied by `switch.tsx` and v3.2.6 deleted it.)
876                .text_size(px(14.))
877                // Tailwind pairs `text-sm` with a 20px leading.
878                .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            // A `content` closure stands in for the label wherever the label
892            // would have gone.
893            (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                // Uncontrolled: flip our own copy, or nothing could change it.
907                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        // Description and FieldError are direct siblings of Switch.Content.
923        // Both use the size-specific track width plus the 12px content gap.
924        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/// `SwitchGroup` — the layout v3 wraps a set of switches in.
947///
948/// `.switch-group` is `flex flex-col gap-6` around a `.switch-group__items`
949/// that is `flex gap-4`, and the orientation modifier is what turns that inner
950/// row into a column. The outer gap is for the label and description a caller
951/// puts beside the items.
952#[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    /// The `sx` slot, refined over the root style at the end of render.
958    sx: Option<Box<gpui::StyleRefinement>>,
959}
960
961impl SwitchGroup {
962    /// Creates an empty group with vertical orientation.
963    pub fn new() -> Self {
964        Self {
965            // v3 documents `vertical` as the default.
966            orientation: herogpui_core::Orientation::Vertical,
967            items: Vec::new(),
968            sx: None,
969        }
970    }
971
972    /// Sets the layout direction of the items (v3 `orientation`).
973    pub fn orientation(mut self, orientation: herogpui_core::Orientation) -> Self {
974        self.orientation = orientation;
975        self
976    }
977
978    /// The one slot for caller-owned low-level styling: GPUI's styling methods
979    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
980    /// applied to the group's root element after every value the orientation
981    /// chose, so it wins.
982    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    /// Appends an item to the group.
988    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);