Skip to main content

herogpui_components/color_picker/
slider.rs

1//! ColorSlider.
2
3use super::*;
4
5// ColorSlider
6// ---------------------------------------------------------------------------
7
8/// State handed to `ColorSlider.Thumb`'s render function.
9#[derive(Clone, Copy, Debug, Default, PartialEq)]
10#[non_exhaustive]
11pub struct ColorSliderThumbState {
12    /// React Aria's ColorThumb render color excludes the alpha channel.
13    pub color: PickerColor,
14    /// Whether the thumb is being dragged.
15    pub is_dragging: bool,
16    /// Whether the slider is hovered.
17    pub is_hovered: bool,
18    /// Whether the slider has focus.
19    pub is_focused: bool,
20    /// Whether focus is visible (keyboard focus).
21    pub is_focus_visible: bool,
22    /// Whether the slider is disabled.
23    pub is_disabled: bool,
24}
25
26const COLOR_SLIDER_TRACK_INSET_PX: f32 = 10.0;
27const COLOR_SLIDER_THUMB_TRANSITION_MS: u64 = 250;
28
29#[derive(Clone)]
30struct ColorSliderThumbMotion {
31    target: f32,
32    generation: usize,
33    from: f32,
34    position: Rc<Cell<f32>>,
35}
36
37struct ColorSliderThumbMotionFrame {
38    base: ElementId,
39    generation: usize,
40    from: f32,
41    to: f32,
42    position: Rc<Cell<f32>>,
43    animate: bool,
44}
45
46impl ColorSliderThumbMotionFrame {
47    fn render(self, thumb: gpui::Div, travel: Pixels, vertical: bool) -> gpui::AnyElement {
48        if !self.animate {
49            self.position.set(self.to);
50            return thumb.into_any_element();
51        }
52
53        let base = self.base;
54        let generation = self.generation;
55        let from = self.from;
56        let to = self.to;
57        let position = self.position;
58        thumb
59            .relative()
60            .with_animation(
61                element_id::indexed(&base, "thumb-position", generation),
62                Animation::new(Duration::from_millis(COLOR_SLIDER_THUMB_TRANSITION_MS))
63                    .with_easing(|t| crate::anim::Curve::Out.at(t)),
64                move |thumb, delta| {
65                    let next = from + (to - from) * delta;
66                    position.set(next);
67                    // The wrapper has already moved to the target. Offset the
68                    // visual child back toward the live frame until the tween
69                    // reaches the endpoint. Vertical tracks measure from the
70                    // opposite edge, so their sign is reversed.
71                    let offset = color_slider_thumb_transition_offset(next, to, travel, vertical);
72                    if vertical {
73                        thumb.top(offset)
74                    } else {
75                        thumb.left(offset)
76                    }
77                },
78            )
79            .into_any_element()
80    }
81}
82
83fn color_slider_thumb_motion(
84    id: &ElementId,
85    target: f32,
86    window: &mut Window,
87    cx: &mut App,
88) -> ColorSliderThumbMotionFrame {
89    let state = window.use_keyed_state(element_id::scoped(id, "thumb-position"), cx, |_, _| {
90        ColorSliderThumbMotion {
91            target,
92            generation: 0,
93            from: target,
94            position: Rc::new(Cell::new(target)),
95        }
96    });
97    let mut current = state.read(cx).clone();
98    if (current.target - target).abs() > f32::EPSILON {
99        current.target = target;
100        current.generation = current.generation.wrapping_add(1);
101        current.from = current.position.get();
102        state.update(cx, |stored, _| *stored = current.clone());
103    }
104    if ActiveTheme::reduce_motion(cx) && (current.position.get() - target).abs() > f32::EPSILON {
105        current.from = target;
106        current.position.set(target);
107        state.update(cx, |stored, _| *stored = current.clone());
108    }
109    ColorSliderThumbMotionFrame {
110        base: id.clone(),
111        generation: current.generation,
112        from: current.from,
113        to: target,
114        position: current.position,
115        animate: current.generation != 0
116            && !ActiveTheme::reduce_motion(cx)
117            && (current.from - target).abs() > f32::EPSILON,
118    }
119}
120
121pub(super) fn color_slider_thumb_transition_offset(
122    from: f32,
123    to: f32,
124    travel: Pixels,
125    vertical: bool,
126) -> Pixels {
127    let offset = if vertical {
128        (to - from) * f32::from(travel)
129    } else {
130        (from - to) * f32::from(travel)
131    };
132    px(offset)
133}
134
135/// ColorSlider — adjusts a single channel along a gradient track.
136#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
137#[derive(IntoElement)]
138pub struct ColorSlider {
139    /// `name` — the name this control submits under; read back by
140    /// [`Self::form_field`].
141    name: Option<SharedString>,
142    /// `defaultValue` — set it to hand this component its own state.
143    default_value: Option<PickerColor>,
144    id: ElementId,
145    value: PickerColor,
146    channel: ColorChannel,
147    /// `colorSpace` — only saturation differs between HSB and HSL, so this
148    /// picks which one a saturation slider edits.
149    color_space: ColorSpace,
150    orientation: herogpui_core::Orientation,
151    length: Pixels,
152    show_label: bool,
153    /// `ColorSlider.Output`'s render props: the closure is handed the current
154    /// `color` and the formatted channel value.
155    output: Option<Arc<dyn Fn(PickerColor, &str) -> gpui::AnyElement + 'static>>,
156    thumb: Option<Arc<dyn Fn(ColorSliderThumbState) -> gpui::AnyElement + 'static>>,
157    is_disabled: bool,
158    on_change: Option<OnColorChange>,
159    on_change_end: Option<OnColorChange>,
160    form_state: Rc<RefCell<crate::form::LiveFormFieldState>>,
161}
162
163impl ColorSlider {
164    /// Creates a slider editing `channel` of `value`.
165    pub fn new(id: impl Into<ElementId>, value: PickerColor, channel: ColorChannel) -> Self {
166        Self {
167            name: None,
168            default_value: None,
169            id: id.into(),
170            value,
171            channel,
172            color_space: ColorSpace::default(),
173            orientation: herogpui_core::Orientation::Horizontal,
174            length: px(240.),
175            show_label: true,
176            output: None,
177            thumb: None,
178            is_disabled: false,
179            on_change: None,
180            on_change_end: None,
181            form_state: live_color_form_state(crate::form::FormValue::Number(0.0)),
182        }
183    }
184
185    /// `name` — the name this control submits under.
186    pub fn name(mut self, name: impl Into<SharedString>) -> Self {
187        self.name = Some(name.into());
188        self
189    }
190
191    /// The `Form` field this control submits, when it has a `name`.
192    ///
193    /// v3 discovers a field through the DOM; gpui gives a child no way to reach
194    /// its ancestor, so the control hands the pair over instead. Borrows, so the
195    /// control is still yours to place:
196    ///
197    /// ```
198    /// # use gpui::{prelude::*, Window};
199    /// # use herogpui_components::{ColorChannel, ColorSlider, Form, PickerColor};
200    /// # struct Demo;
201    /// # impl Render for Demo {
202    /// #     fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
203    /// #         let form = Form::new();
204    /// #         let color = PickerColor::hsb(210., 0.8, 0.9);
205    /// #         let control = ColorSlider::new("hue", color, ColorChannel::Hue).name("hue");
206    /// let field = control.form_field();
207    /// form.field(field.unwrap()).child(control)
208    /// #     }
209    /// # }
210    /// # let mut tcx = gpui::TestAppContext::single();
211    /// # tcx.update(herogpui_theme::ThemeProvider::init);
212    /// # let _ = tcx.add_window_view(|_, _| Demo);
213    /// ```
214    pub fn form_field(&self) -> Option<crate::form::FormField> {
215        let name = self.name.clone()?;
216        let value = self.default_value.unwrap_or(self.value);
217        // A disabled range input is not a successful control in HTML, so the
218        // field stays registered and is omitted from FormData.
219        sync_color_form_state(
220            &self.form_state,
221            color_slider_form_value(value, self.channel, self.color_space),
222            !self.is_disabled,
223            false,
224        );
225        Some(crate::form::FormField::live(name, self.form_state.clone()).is_required(false))
226    }
227
228    /// `defaultValue` — the uncontrolled initial colour.
229    ///
230    /// Supplying it hands the component its own state: the constructor's
231    /// `value` becomes the seed, and a change moves the component's copy.
232    pub fn default_value(mut self, value: PickerColor) -> Self {
233        self.default_value = Some(value);
234        self
235    }
236
237    /// `orientation` — a vertical slider runs bottom to top.
238    /// `colorSpace` — the space the channel is read in. Defaults to HSB, the
239    /// space [`PickerColor`] stores.
240    pub fn color_space(mut self, space: ColorSpace) -> Self {
241        self.color_space = space;
242        self
243    }
244
245    /// Sets the slider orientation (`orientation`).
246    pub fn orientation(mut self, orientation: herogpui_core::Orientation) -> Self {
247        self.orientation = orientation;
248        self
249    }
250
251    /// `onChangeEnd` — fires once when the drag finishes.
252    pub fn on_change_end(
253        mut self,
254        handler: impl Fn(&PickerColor, &mut Window, &mut App) + 'static,
255    ) -> Self {
256        self.on_change_end = Some(Arc::new(handler));
257        self
258    }
259
260    /// Sets the length of the slider along its axis.
261    pub fn length(mut self, length: impl Into<Pixels>) -> Self {
262        self.length = length.into();
263        self
264    }
265
266    /// `ColorSlider.Output`'s render function — v3 hands it the `color`, which
267    /// is what this closure takes along with the value as v3 formats it.
268    pub fn output(
269        mut self,
270        render: impl Fn(PickerColor, &str) -> gpui::AnyElement + 'static,
271    ) -> Self {
272        self.output = Some(Arc::new(render));
273        self
274    }
275
276    /// `ColorSlider.Thumb`'s render function — the closure receives the shared
277    /// ColorThumb interaction state while the built-in thumb retains its
278    /// positioning, focus and drag behavior.
279    pub fn thumb(
280        mut self,
281        render: impl Fn(ColorSliderThumbState) -> gpui::AnyElement + 'static,
282    ) -> Self {
283        self.thumb = Some(Arc::new(render));
284        self
285    }
286
287    /// Sets whether the label is shown.
288    pub fn show_label(mut self, v: bool) -> Self {
289        self.show_label = v;
290        self
291    }
292
293    /// Sets whether the slider is disabled (`isDisabled`).
294    pub fn is_disabled(mut self, v: bool) -> Self {
295        self.is_disabled = v;
296        self
297    }
298
299    /// Sets the handler called with the new color as the thumb moves (`onChange`).
300    pub fn on_change(
301        mut self,
302        handler: impl Fn(&PickerColor, &mut Window, &mut App) + 'static,
303    ) -> Self {
304        self.on_change = Some(Arc::new(handler));
305        self
306    }
307
308    /// React Stately's `getDisplayColor`: hue uses the saturated spectrum,
309    /// and only the alpha channel displays transparency.
310    fn display_color(&self) -> PickerColor {
311        match self.channel {
312            ColorChannel::Hue => PickerColor::hsb(self.value.hue, 1.0, 1.0),
313            ColorChannel::Alpha => self.value,
314            _ => self.value.with_alpha(1.0),
315        }
316    }
317
318    /// The two ends of the displayed gradient, shared by the ramp and caps.
319    fn gradient_ends(&self) -> (Hsla, Hsla) {
320        let (min, max) = self.channel.range();
321        let color = self.display_color();
322        (
323            color
324                .with_channel_in(self.channel, self.color_space, min)
325                .to_hsla(),
326            color
327                .with_channel_in(self.channel, self.color_space, max)
328                .to_hsla(),
329        )
330    }
331}
332
333/// Two gradient halves, `start -> middle` then `middle -> end`.
334///
335/// `inset` is the fraction of the box at each end that must hold a constant
336/// colour (0.0 for `ColorArea`, `cap / length` for `ColorSlider`). The halves
337/// travel over `[inset, 1 - inset]`, but each is *stretched* out to its box
338/// edge and compensates with stop percentages, so the end zones are flat
339/// colour without any extra element: GPUI's shader remaps `t` by
340/// `(t - stop0) / (stop1 - stop0)` and clamps to `[0, 1]`, which holds the end
341/// colour over the stretched part. A separate cap element would break under
342/// `opacity()` (GPUI applies opacity per element, not per group) and could not
343/// carry the r10 curve anyway, because GPUI clamps a corner radius to half the
344/// element's shortest side.
345///
346/// Each half carries the clip radius on its exterior corners: vanilla GPUI
347/// clips `overflow_hidden()` to the rectangle (see `util::inner_fill_radius`).
348/// Interior corners meet mid-gradient where rounding would show, so only the
349/// outer pair rounds.
350pub(super) fn three_stop_gradient(
351    vertical: bool,
352    start: Hsla,
353    middle: Hsla,
354    end: Hsla,
355    radius: Pixels,
356    inset: f32,
357) -> gpui::Div {
358    let inset = inset.clamp(0.0, 0.45);
359    let half = (1.0 - inset * 2.0) / 2.0;
360    let extent = gpui::relative(half + inset);
361    let total = half + inset;
362    let (near_from, far_to) = if total > 0.0 {
363        (inset / total, half / total)
364    } else {
365        (0.0, 1.0)
366    };
367    if vertical {
368        // Band 0 (`start`) is the bottom half.
369        div()
370            .absolute()
371            .inset_0()
372            .child(
373                div()
374                    .absolute()
375                    .top_0()
376                    .left_0()
377                    .right_0()
378                    .rounded_tl(radius)
379                    .rounded_tr(radius)
380                    .h(extent)
381                    .bg(gpui::linear_gradient(
382                        0.0,
383                        gpui::linear_color_stop(middle, 0.0),
384                        gpui::linear_color_stop(end, far_to),
385                    )),
386            )
387            .child(
388                div()
389                    .absolute()
390                    .bottom_0()
391                    .left_0()
392                    .right_0()
393                    .rounded_bl(radius)
394                    .rounded_br(radius)
395                    .h(extent)
396                    .bg(gpui::linear_gradient(
397                        0.0,
398                        gpui::linear_color_stop(start, near_from),
399                        gpui::linear_color_stop(middle, 1.0),
400                    )),
401            )
402    } else {
403        div()
404            .absolute()
405            .inset_0()
406            .child(
407                div()
408                    .absolute()
409                    .top_0()
410                    .bottom_0()
411                    .left_0()
412                    .rounded_tl(radius)
413                    .rounded_bl(radius)
414                    .w(extent)
415                    .bg(gpui::linear_gradient(
416                        90.0,
417                        gpui::linear_color_stop(start, near_from),
418                        gpui::linear_color_stop(middle, 1.0),
419                    )),
420            )
421            .child(
422                div()
423                    .absolute()
424                    .top_0()
425                    .bottom_0()
426                    .right_0()
427                    .rounded_tr(radius)
428                    .rounded_br(radius)
429                    .w(extent)
430                    .bg(gpui::linear_gradient(
431                        90.0,
432                        gpui::linear_color_stop(middle, 0.0),
433                        gpui::linear_color_stop(end, far_to),
434                    )),
435            )
436    }
437}
438
439impl RenderOnce for ColorSlider {
440    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
441        // `defaultValue` opts into the component holding its own colour;
442        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
443        let (resolved, own) = util::controlled(
444            window,
445            cx,
446            element_id::scoped(&self.id, "slider-value"),
447            match self.default_value {
448                Some(_) => None,
449                None => Some(self.value),
450            },
451            self.default_value.unwrap_or(self.value),
452        );
453        self.value = resolved;
454        let form_default = window.use_keyed_state(
455            element_id::scoped(&self.id, "slider-form-default"),
456            cx,
457            |_, _| None::<PickerColor>,
458        );
459        if form_default.read(cx).is_none() {
460            let initial = self.value;
461            form_default.update(cx, |slot, cx| {
462                *slot = Some(initial);
463                cx.notify();
464            });
465        }
466        let restore_default = form_default.read(cx).unwrap_or(self.value);
467        // Submit the resolved colour's channel. Uncontrolled keyed state is
468        // current after interaction; a controlled owner must accept the change
469        // before the next render writes it here.
470        sync_color_form_state(
471            &self.form_state,
472            color_slider_form_value(self.value, self.channel, self.color_space),
473            !self.is_disabled,
474            false,
475        );
476        let restore_own = own.clone();
477        let restore_on_change = self.on_change.clone();
478        let restore_form_state = Rc::downgrade(&self.form_state);
479        let restore_channel = self.channel;
480        let restore_space = self.color_space;
481        let restore_is_disabled = self.is_disabled;
482        let restore: Arc<dyn Fn(&mut Window, &mut App)> = util::shared(move |window, cx| {
483            if let Some(own) = &restore_own {
484                own.update(cx, |current, cx| {
485                    *current = restore_default;
486                    cx.notify();
487                });
488            }
489            if let Some(callback) = &restore_on_change {
490                callback(&restore_default, window, cx);
491            }
492            if let Some(state) = restore_form_state.upgrade() {
493                sync_color_form_state(
494                    &state,
495                    color_slider_form_value(restore_default, restore_channel, restore_space),
496                    !restore_is_disabled,
497                    false,
498                );
499            }
500        });
501        self.form_state.borrow_mut().restore = Some(restore);
502        // The handle the keys arrive on. `use_keyed_state` takes `cx` mutably, so
503        // it precedes the theme tokens.
504        let focus_handle =
505            window.use_keyed_state(element_id::scoped(&self.id, "slider-focus"), cx, |_, cx| {
506                cx.focus_handle().tab_stop(true)
507            });
508        let focus_handle = focus_handle.read(cx).clone();
509        self.form_state.borrow_mut().focus = Some(focus_handle.clone());
510        let bounds_slot =
511            window.use_keyed_state(element_id::scoped(&self.id, "slider-bounds"), cx, |_, _| {
512                Bounds::<f32> {
513                    origin: gpui::point(0., 0.),
514                    size: gpui::size(0., 0.),
515                }
516            });
517        let dragging = window.use_keyed_state(
518            element_id::scoped(&self.id, "slider-dragging"),
519            cx,
520            |_, _| false,
521        );
522        let thumb_hovered = window.use_keyed_state(
523            element_id::scoped(&self.id, "slider-thumb-hovered"),
524            cx,
525            |_, _| false,
526        );
527        let (min, max) = self.channel.range();
528        // Read in the requested space: HSL and HSB saturation are different
529        // numbers for the same colour.
530        let raw = self.value.channel_in(self.channel, self.color_space);
531        let norm = ((raw - min) / (max - min)).clamp(0.0, 1.0);
532        let thumb_motion = color_slider_thumb_motion(&self.id, norm, window, cx);
533        let default_thumb_color = cx.colors().default.color;
534        // `.color-slider__track` is `relative rounded-2xl` with the gradient
535        // inside it; `.color-slider__output` is the value read-out above.
536        let track_h = px(20.);
537
538        let vertical = !self.orientation.is_horizontal();
539        let mut track = div()
540            .id(self.id.clone())
541            .relative()
542            .rounded(px(COLOR_SLIDER_TRACK_INSET_PX));
543        track = if vertical {
544            track.w(track_h).h(self.length)
545        } else {
546            track.w(self.length).h(track_h)
547        };
548
549        let recorder_bounds = bounds_slot.clone();
550        track = track.child(
551            gpui::canvas(
552                move |bounds: Bounds<Pixels>, _, cx| {
553                    recorder_bounds.update(cx, |slot, _| {
554                        *slot = Bounds {
555                            origin: gpui::point(
556                                f32::from(bounds.origin.x),
557                                f32::from(bounds.origin.y),
558                            ),
559                            size: gpui::size(
560                                f32::from(bounds.size.width),
561                                f32::from(bounds.size.height),
562                            ),
563                        };
564                    });
565                    bounds
566                },
567                |_, _, _, _| {},
568            )
569            .absolute()
570            .inset_0(),
571        );
572
573        // Clip the complete painted track once, as ColorArea does. Edge
574        // shadows and the thumb remain siblings outside this clip.
575        let track_r = px(COLOR_SLIDER_TRACK_INSET_PX);
576        let mut layers = div()
577            .absolute()
578            .inset_0()
579            .rounded(track_r)
580            .overflow_hidden();
581
582        if self.channel == ColorChannel::Alpha {
583            // Light cells ride the rounded base; dark cells are one clipped
584            // silhouette (see `transparency_checker_cells`), so the
585            // translucent end keeps exact corners on vanilla GPUI.
586            let (checker_w, checker_h) = if vertical {
587                (track_h, self.length)
588            } else {
589                (self.length, track_h)
590            };
591            layers = layers
592                .bg(gpui::rgb(CHECKER_LIGHT))
593                .child(transparency_checker_cells(
594                    checker_w,
595                    checker_h,
596                    track_r,
597                    if self.is_disabled {
598                        cx.layout().disabled_opacity
599                    } else {
600                        1.0
601                    },
602                ));
603        }
604
605        // The ramp is one full-length element, and the 10px constant-colour
606        // end zones come from gradient stop *percentages* rather than from cap
607        // elements painted over or under it. Two vanilla-GPUI facts force that:
608        //
609        // * `opacity()` is applied per element, not per group, so a disabled
610        //   track's translucent ramp would let anything underneath show
611        //   through as solid blocks at the ends; and
612        // * a corner radius is clamped to half the element's shortest side, so
613        //   a 10px-wide cap tops out at r5 and cannot paint the track's r10
614        //   curve. A full-length ramp's box is `length x 20`, where r10 is
615        //   never clamped.
616        //
617        // GPUI's shader remaps `t` by `(t - stop0) / (stop1 - stop0)` and
618        // clamps to `[0, 1]` (`shaders.metal`, "Adjust t based on the stop
619        // percentages"), so a start stop at `f = cap / length` holds the start
620        // colour over the first `cap` pixels and an end stop at `1 - f` holds
621        // the end colour over the last `cap` pixels, while the travel in
622        // between is unchanged. Nothing but the alpha checkerboard is painted
623        // under the ramp, and nothing at all over it.
624        let (start_color, end_color) = self.gradient_ends();
625        let inset =
626            (COLOR_SLIDER_TRACK_INSET_PX / f32::from(self.length).max(1.0)).clamp(0.0, 0.45);
627        let ramp = div().absolute().inset_0().rounded(track_r);
628        let ramp = if self.channel == ColorChannel::Lightness {
629            let (start, middle, end) =
630                lightness_gradient_colors(self.display_color(), self.color_space, min, max);
631            ramp.child(three_stop_gradient(
632                vertical, start, middle, end, track_r, inset,
633            ))
634        } else if self.channel == ColorChannel::Hue {
635            ramp.child(hue_gradient(
636                self.display_color(),
637                self.color_space,
638                vertical,
639                track_r,
640                inset,
641            ))
642        } else {
643            ramp.bg(gpui::linear_gradient(
644                if vertical { 0.0 } else { 90.0 },
645                gpui::linear_color_stop(start_color, inset),
646                gpui::linear_color_stop(end_color, 1.0 - inset),
647            ))
648        };
649        layers = layers.child(ramp);
650
651        track = track.child(layers);
652
653        // HeroUI uses inset edge shadows, without a solid track border. A
654        // separate outline over the antialiased fill leaves a colored fringe.
655        // Keep the edge shading below the thumb, including at its endpoints.
656        track = track.child(
657            div()
658                .absolute()
659                .inset_0()
660                .rounded(px(COLOR_SLIDER_TRACK_INSET_PX))
661                .shadow(color_track_shadows()),
662        );
663
664        // A vertical slider's zero end is at the bottom, so the offset is
665        // measured from the far edge.
666        let travel = (f32::from(self.length) - COLOR_SLIDER_TRACK_INSET_PX * 2.0).max(0.0);
667        let thumb_offset = px(COLOR_SLIDER_TRACK_INSET_PX
668            + travel * if vertical { 1.0 - norm } else { norm }
669            - 8.0);
670        let is_dragging = !self.is_disabled && *dragging.read(cx);
671        let is_focused = !self.is_disabled && focus_handle.is_focused(window);
672        let is_focus_visible = is_focused && util::focus_visible(cx);
673        let thumb_state = ColorSliderThumbState {
674            color: self.value.with_alpha(1.0),
675            is_dragging,
676            is_hovered: !self.is_disabled && *thumb_hovered.read(cx),
677            is_focused,
678            is_focus_visible,
679            is_disabled: self.is_disabled,
680        };
681        let thumb_content = self.thumb.as_ref().map(|render| render(thumb_state));
682        let focus_motion = color_focus_ring_motion(
683            &element_id::scoped(&self.id, "slider-thumb"),
684            is_focus_visible,
685            window,
686            cx,
687        );
688        let thumb_surface = focus_motion.render(
689            div()
690                // `.color-slider__thumb` is `size-4`.
691                .size(px(16.))
692                .rounded(px(16.))
693                .border(px(3.))
694                .border_color(gpui::white())
695                .bg(if self.is_disabled {
696                    default_thumb_color
697                } else {
698                    self.value.with_alpha(1.0).to_hsla()
699                })
700                .when_some(thumb_content, |thumb, content| thumb.child(content)),
701            color_thumb_shadows(),
702            true,
703            cx,
704        );
705        let thumb_visual = div()
706            .size(px(16.))
707            .flex()
708            .items_center()
709            .justify_center()
710            .child(thumb_surface);
711        let mut thumb = div()
712            .id(element_id::scoped(&self.id, "slider-thumb"))
713            .absolute()
714            .when(vertical, |t| t.left(px(2.)).top(thumb_offset))
715            .when(!vertical, |t| t.top(px(2.)).left(thumb_offset))
716            .size(px(16.))
717            .when(!self.is_disabled, |t| {
718                t.cursor(if is_dragging {
719                    gpui::CursorStyle::ClosedHand
720                } else {
721                    gpui::CursorStyle::OpenHand
722                })
723            })
724            .child(thumb_motion.render(thumb_visual, px(travel), vertical));
725        if !self.is_disabled {
726            let hovered = thumb_hovered;
727            thumb = thumb.on_hover(move |is_hovered, _, cx| {
728                hovered.update(cx, |value, cx| {
729                    if *value != *is_hovered {
730                        *value = *is_hovered;
731                        cx.notify();
732                    }
733                });
734            });
735        }
736        track = track.child(thumb);
737
738        if self.is_disabled {
739            track = track.opacity(cx.layout().disabled_opacity);
740        } else {
741            let value = self.value;
742            let channel = self.channel;
743            let space = self.color_space;
744            track = util::cursor_interactive(track, cx);
745            // v3: the arrows step the channel, Home and End take it to its ends,
746            // and Page Up/Down move by a tenth of the range -- React Aria's page
747            // step. A colour slider with no keyboard is not the same control.
748            let keys_value = self.value;
749            let on_change_keys = self.on_change.clone();
750            let end_keys = self.on_change_end.clone();
751            let own_keys = own.clone();
752            // One step per unit for a 0-360 hue or an 0-255 byte, and a
753            // percentage point for the normalised channels.
754            let step = if max - min > 2.0 { 1.0 } else { 0.01 };
755            let page = ((max - min) / 10.0).max(step);
756            track = track
757                .track_focus(&focus_handle)
758                .key_context("ColorSlider")
759                .on_key_down(move |event, window, cx| {
760                    let current = keys_value.channel_in(channel, space);
761                    let next = match event.keystroke.key.as_str() {
762                        "right" | "up" => current + step,
763                        "left" | "down" => current - step,
764                        "pageup" => current + page,
765                        "pagedown" => current - page,
766                        "home" => min,
767                        "end" => max,
768                        _ => return,
769                    };
770                    let next = keys_value.with_channel_in(channel, space, next.clamp(min, max));
771                    if next == keys_value {
772                        return;
773                    }
774                    if let Some(held) = &own_keys {
775                        held.update(cx, |v, cx| {
776                            *v = next;
777                            cx.notify();
778                        });
779                    }
780                    if let Some(cb) = &on_change_keys {
781                        cb(&next, window, cx);
782                    }
783                    // A keystroke is a finished change, so `onChangeEnd` fires
784                    // with it rather than waiting for a release.
785                    if let Some(cb) = &end_keys {
786                        cb(&next, window, cx);
787                    }
788                });
789            if self.on_change.is_some() || self.on_change_end.is_some() || own.is_some() {
790                let down_bounds = bounds_slot.clone();
791                let down_dragging = dragging.clone();
792                let down_change = self.on_change.clone();
793                let down_own = own.clone();
794                let focus_for_press = focus_handle.clone();
795                track = track.on_mouse_down(
796                    gpui::MouseButton::Left,
797                    move |event: &MouseDownEvent, window, cx| {
798                        if event.modifiers.alt
799                            || event.modifiers.control
800                            || event.modifiers.platform
801                        {
802                            return;
803                        }
804                        down_dragging.update(cx, |value, cx| {
805                            if !*value {
806                                *value = true;
807                                cx.notify();
808                            }
809                        });
810                        window.focus(&focus_for_press, cx);
811                        if let Some(next) = slider_color_from_pointer(
812                            &down_bounds,
813                            event.position,
814                            vertical,
815                            value,
816                            channel,
817                            space,
818                            cx,
819                        ) {
820                            if next.changed {
821                                report_color_change(
822                                    next.value,
823                                    &down_own,
824                                    &down_change,
825                                    window,
826                                    cx,
827                                );
828                            }
829                        }
830                    },
831                );
832
833                let global_bounds = bounds_slot;
834                let global_dragging = dragging;
835                let global_change = self.on_change;
836                let global_end = self.on_change_end;
837                let global_own = own;
838                track = track.child(
839                    gpui::canvas(
840                        |bounds, _, _| bounds,
841                        move |_, _, window, _| {
842                            let move_bounds = global_bounds.clone();
843                            let move_dragging = global_dragging.clone();
844                            let move_change = global_change.clone();
845                            let move_own = global_own.clone();
846                            window.on_mouse_event(
847                                move |event: &MouseMoveEvent, phase, window, cx| {
848                                    if phase == gpui::DispatchPhase::Capture
849                                        && event.pressed_button == Some(gpui::MouseButton::Left)
850                                        && *move_dragging.read(cx)
851                                    {
852                                        if let Some(next) = slider_color_from_pointer(
853                                            &move_bounds,
854                                            event.position,
855                                            vertical,
856                                            value,
857                                            channel,
858                                            space,
859                                            cx,
860                                        ) {
861                                            if next.changed {
862                                                report_color_change(
863                                                    next.value,
864                                                    &move_own,
865                                                    &move_change,
866                                                    window,
867                                                    cx,
868                                                );
869                                            }
870                                        }
871                                    }
872                                },
873                            );
874
875                            let up_bounds = global_bounds.clone();
876                            let up_dragging = global_dragging.clone();
877                            let up_end = global_end.clone();
878                            window.on_mouse_event(
879                                move |event: &MouseUpEvent, phase, window, cx| {
880                                    if phase == gpui::DispatchPhase::Capture
881                                        && event.button == gpui::MouseButton::Left
882                                    {
883                                        finish_slider_drag(
884                                            &up_dragging,
885                                            &up_bounds,
886                                            event.position,
887                                            vertical,
888                                            value,
889                                            channel,
890                                            space,
891                                            &up_end,
892                                            window,
893                                            cx,
894                                        );
895                                    }
896                                },
897                            );
898                        },
899                    )
900                    .absolute()
901                    .inset_0(),
902                );
903            }
904        }
905
906        // `useColorSlider` is `useSlider` with one thumb: `role: 'slider'`
907        // on the interactive track, named by the channel.
908        track = track
909            .a11y_named(a11y::Role::Slider, &a11y::Name::maybe(self.name.clone()))
910            .a11y_orientation(self.orientation);
911        if !self.is_disabled {
912            track = util::record_focus_bounds(track, &focus_handle, window, cx);
913        }
914
915        if !self.show_label {
916            return div().child(track);
917        }
918
919        let display = match self.channel {
920            ColorChannel::Hue => format!("{}\u{00B0}", raw.round()),
921            ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue => {
922                format!("{}", raw.round())
923            }
924            _ => format!("{}%", (raw * 100.0).round()),
925        };
926        let output = match &self.output {
927            // `.color-slider__output`: v3's render prop is handed the colour,
928            // which is what a caller needs to draw a swatch or a different
929            // unit.
930            Some(render) => render(self.value, &display),
931            None => div()
932                .text_color(cx.colors().muted)
933                .child(display)
934                .into_any_element(),
935        };
936
937        div()
938            .flex()
939            .flex_col()
940            // `.color-slider` is `grid w-full gap-1`.
941            .gap(px(4.))
942            .child(
943                div()
944                    .flex()
945                    .flex_row()
946                    .items_center()
947                    .justify_between()
948                    .w(self.length)
949                    .text_size(px(14.))
950                    .line_height(px(20.))
951                    .font_weight(gpui::FontWeight::MEDIUM)
952                    .child(
953                        div()
954                            .text_color(cx.colors().foreground)
955                            .child(self.channel.localized_label(cx)),
956                    )
957                    // The disabled root dims Output through status-disabled,
958                    // while the stylesheet restores Label to full opacity.
959                    .child(
960                        div()
961                            .when(self.is_disabled, |output| {
962                                output.opacity(cx.layout().disabled_opacity)
963                            })
964                            .font_features(util::tabular_font_features())
965                            .child(output),
966                    ),
967            )
968            .child(track)
969    }
970}
971
972pub(super) fn lightness_gradient_colors(
973    value: PickerColor,
974    color_space: ColorSpace,
975    min: f32,
976    max: f32,
977) -> (Hsla, Hsla, Hsla) {
978    (
979        value
980            .with_channel_in(ColorChannel::Lightness, color_space, min)
981            .to_hsla(),
982        value
983            .with_channel_in(ColorChannel::Lightness, color_space, (max - min) / 2.0)
984            .to_hsla(),
985        value
986            .with_channel_in(ColorChannel::Lightness, color_space, max)
987            .to_hsla(),
988    )
989}
990
991#[allow(clippy::float_cmp)] // snapped channel values are exact state coordinates
992pub(super) fn slider_color_from_pointer(
993    bounds: &Entity<Bounds<f32>>,
994    position: gpui::Point<Pixels>,
995    vertical: bool,
996    value: PickerColor,
997    channel: ColorChannel,
998    color_space: ColorSpace,
999    cx: &App,
1000) -> Option<PointerColor> {
1001    let bounds = *bounds.read(cx);
1002    let (reach, extent) = if vertical {
1003        (
1004            bounds.origin.y + bounds.size.height
1005                - COLOR_SLIDER_TRACK_INSET_PX
1006                - f32::from(position.y),
1007            bounds.size.height - COLOR_SLIDER_TRACK_INSET_PX * 2.0,
1008        )
1009    } else {
1010        (
1011            f32::from(position.x) - bounds.origin.x - COLOR_SLIDER_TRACK_INSET_PX,
1012            bounds.size.width - COLOR_SLIDER_TRACK_INSET_PX * 2.0,
1013        )
1014    };
1015    if extent <= 0.0 {
1016        return None;
1017    }
1018    let fraction = (reach / extent).clamp(0.0, 1.0);
1019    let (min, max) = channel.range();
1020    let next = snap_color_channel(channel, min + fraction * (max - min));
1021    Some(PointerColor {
1022        changed: next != value.channel_in(channel, color_space),
1023        value: value.with_channel_in(channel, color_space, next),
1024    })
1025}
1026
1027#[allow(clippy::too_many_arguments)] // the channel and callback are the color-slider drag state
1028pub(super) fn finish_slider_drag(
1029    dragging: &Entity<bool>,
1030    bounds: &Entity<Bounds<f32>>,
1031    position: gpui::Point<Pixels>,
1032    vertical: bool,
1033    value: PickerColor,
1034    channel: ColorChannel,
1035    color_space: ColorSpace,
1036    on_change_end: &Option<OnColorChange>,
1037    window: &mut Window,
1038    cx: &mut App,
1039) {
1040    if !*dragging.read(cx) {
1041        return;
1042    }
1043    dragging.update(cx, |value, cx| {
1044        *value = false;
1045        cx.notify();
1046    });
1047    if let (Some(callback), Some(next)) = (
1048        on_change_end,
1049        slider_color_from_pointer(bounds, position, vertical, value, channel, color_space, cx),
1050    ) {
1051        callback(&next.value, window, cx);
1052    }
1053}
1054
1055// ---------------------------------------------------------------------------