Skip to main content

herogpui_components/color_picker/
mod.rs

1//! Colors — port of `@heroui/color-area`, `color-field`, `color-picker`,
2//! `color-slider`, `color-swatch` and `color-swatch-picker` (v3).
3//!
4//! All six components share the [`PickerColor`] value type and the
5//! [`ColorChannel`] / [`ColorSpace`] vocabulary that React Aria uses.
6
7use std::{
8    cell::{Cell, RefCell},
9    rc::Rc,
10    sync::Arc,
11    time::Duration,
12};
13
14use gpui::{
15    div, prelude::*, px, Animation, AnimationExt, App, Bounds, ElementId, Entity, Hsla,
16    InteractiveElement, IntoElement, MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels,
17    RenderOnce, SharedString, Styled, Window,
18};
19#[allow(unused_imports)] // children import these through `use super::*`
20use herogpui_core::{element_id, FieldVariant, Placement, SizeXl};
21use herogpui_theme::ActiveTheme;
22
23use crate::{
24    a11y::{self, A11y as _},
25    input::Input,
26    util,
27};
28
29/// HeroUI's color surfaces use an 8px checker cell pair (a 16px repeating
30/// tile) beneath translucent values. GPUI has no repeating-conic background,
31/// so the shared color controls compose the same tile from clipped child
32/// squares. Keeping this helper here makes swatches and alpha sliders use one
33/// palette and one phase at every size and orientation.
34pub(super) const CHECKER_CELL: f32 = 8.0;
35pub(super) const CHECKER_LIGHT: u32 = 0xefefef;
36pub(super) const CHECKER_DARK: u32 = 0xf7f7f7;
37
38/// The dark half of the checkerboard as one clipped monochrome silhouette.
39///
40/// Vanilla GPUI clips `overflow_hidden()` to the rectangle, so the square
41/// cells of [`transparency_checker`] bleed through rounded corners wherever
42/// a translucent fill reveals them. GPUI's `Svg` paints through an alpha
43/// mask (`Window::paint_svg` → `render_alpha_mask`), which rules out a
44/// multicolor checker SVG -- but a single tint works: the light cells stay a
45/// rounded div background (an element's own `bg` always follows its radius)
46/// while the dark cells become one SVG under a `clipPath` curve, tinted dark
47/// through `text_color`. Pair with a light rounded base -- a sketch of the
48/// layering, not a compilable example: this helper is `pub(super)`, so
49/// rustdoc never builds a fence here whatever the fence claims, and
50/// `.shots/fence_audit.py` exists to stop a block claiming a check that does
51/// not happen.
52///
53/// ```text
54/// div().absolute().inset_0().rounded(radius).bg(LIGHT)
55///     .child(transparency_checker_cells(width, height, radius, 1.0))
56/// ```
57///
58/// A multicolor SVG (one document, two grays, one clip) renders as a single
59/// silhouette instead -- verified against `gpui-pre` 0.3.5 sources -- so the
60/// two layers are structural, not stylistic.
61pub(super) fn transparency_checker_cells(
62    width: Pixels,
63    height: Pixels,
64    radius: Pixels,
65    opacity: f32,
66) -> gpui::Svg {
67    use std::fmt::Write as _;
68    const CELL: f32 = CHECKER_CELL;
69    let w = f32::from(width);
70    let h = f32::from(height);
71    let r = f32::from(radius);
72    let columns = (w / CELL).ceil().max(1.0) as usize;
73    let rows = (h / CELL).ceil().max(1.0) as usize;
74    // Same phase as [`transparency_checker`]: dark where row+column is odd.
75    let mut cells = String::new();
76    for row in 0..rows {
77        for column in 0..columns {
78            if (row + column) % 2 == 0 {
79                continue;
80            }
81            let _ = write!(
82                cells,
83                "<rect x=\"{:.2}\" y=\"{:.2}\" width=\"{CELL:.2}\" height=\"{CELL:.2}\"/>",
84                column as f32 * CELL,
85                row as f32 * CELL,
86            );
87        }
88    }
89    let doc = format!(
90        "<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"{w:.2}\" height=\"{h:.2}\" \
91         viewBox=\"0 0 {w:.2} {h:.2}\">\
92         <defs><clipPath id=\"c\"><rect width=\"{w:.2}\" height=\"{h:.2}\" rx=\"{r:.2}\"/></clipPath></defs>\
93         <g clip-path=\"url(#c)\" fill=\"#000000\">{cells}</g></svg>"
94    );
95    let bytes = doc.into_bytes();
96    // Explicit size like every icon svg (zero bounds paint nothing), and the
97    // dark tint: `Svg::data` only paints with a text color set, which becomes
98    // the silhouette fill. An ancestor's `opacity()` does not reach the
99    // silhouette (only quads inherit it), so a dimmed owner passes its
100    // opacity here and the tint carries it instead.
101    gpui::svg()
102        .data(&bytes)
103        .absolute()
104        .top_0()
105        .left_0()
106        .w(width)
107        .h(height)
108        .text_color(gpui::rgb(CHECKER_DARK).alpha(opacity.clamp(0.0, 1.0)))
109}
110
111/// HeroUI's color surfaces use a one-pixel translucent inset edge rather than
112/// a semantic theme border. GPUI's shadow primitive needs a small blur to
113/// produce a visible raster edge, so the one-pixel spread is kept exact while
114/// the blur stays at the smallest drawable value.
115pub(super) fn color_inner_shadow() -> gpui::BoxShadow {
116    gpui::BoxShadow {
117        color: gpui::black().alpha(0.1),
118        offset: gpui::point(px(0.), px(0.)),
119        blur_radius: px(1.),
120        spread_radius: px(1.),
121        inset: true,
122    }
123}
124
125/// The ColorArea/ColorSlider thumb depth treatment: a one-pixel outer hairline
126/// plus the matching inset hairline inside the white ring.
127pub(super) fn color_thumb_shadows() -> Vec<gpui::BoxShadow> {
128    let edge = gpui::black().alpha(0.1);
129    vec![
130        gpui::BoxShadow {
131            color: edge,
132            offset: gpui::point(px(0.), px(0.)),
133            blur_radius: px(1.),
134            spread_radius: px(1.),
135            inset: false,
136        },
137        gpui::BoxShadow {
138            color: edge,
139            offset: gpui::point(px(0.), px(0.)),
140            blur_radius: px(1.),
141            spread_radius: px(1.),
142            inset: true,
143        },
144    ]
145}
146
147/// HeroUI shades the track's two long edges and each cap's outer edge with
148/// unblurred one-pixel inset shadows. One complete capsule has all four edges.
149pub(super) fn color_track_shadows() -> Vec<gpui::BoxShadow> {
150    let edge = gpui::black().alpha(0.1);
151    let offsets = [
152        (px(1.), px(0.)),
153        (px(-1.), px(0.)),
154        (px(0.), px(1.)),
155        (px(0.), px(-1.)),
156    ];
157    offsets
158        .into_iter()
159        .map(|(x, y)| gpui::BoxShadow {
160            color: edge,
161            offset: gpui::point(x, y),
162            blur_radius: px(0.),
163            spread_radius: px(0.),
164            inset: true,
165        })
166        .collect()
167}
168
169/// Tailwind's placement-specific `slide-in-from-*` offsets for the color
170/// picker popover. The floating engine may later flip a requested placement;
171/// the caller records that remaining resolved-placement limitation in its
172/// reference metadata while still matching every requested side.
173pub(super) fn color_picker_entry_offset(placement: Placement) -> (f32, f32) {
174    if placement.is_above() {
175        (0.0, 4.0)
176    } else if placement.is_side() {
177        if placement.is_start_side() {
178            (4.0, 0.0)
179        } else {
180            (-4.0, 0.0)
181        }
182    } else {
183        (0.0, -4.0)
184    }
185}
186
187/// HeroUI transitions a color thumb's focus-ring shadow over 150ms. GPUI's
188/// shared `with_focus_ring` helper resolves the shadow list immediately, so
189/// color controls keep a small keyed opacity tween for the focus layers while
190/// preserving their component-specific depth shadows.
191const COLOR_FOCUS_RING_TRANSITION_MS: u64 = 150;
192
193#[derive(Clone)]
194struct ColorFocusRingMotion {
195    focused: bool,
196    generation: usize,
197    from: f32,
198    opacity: Rc<Cell<f32>>,
199}
200
201pub(super) struct ColorFocusRingMotionFrame {
202    base: ElementId,
203    generation: usize,
204    from: f32,
205    to: f32,
206    opacity: Rc<Cell<f32>>,
207    animate: bool,
208}
209
210fn color_focus_ring_shadows(
211    base: &[gpui::BoxShadow],
212    ring: &[gpui::BoxShadow],
213    opacity: f32,
214) -> Vec<gpui::BoxShadow> {
215    if opacity <= f32::EPSILON {
216        return base.to_vec();
217    }
218    let mut shadows = base.to_vec();
219    shadows.extend(ring.iter().cloned().map(|mut shadow| {
220        shadow.color = shadow.color.alpha(opacity);
221        shadow
222    }));
223    shadows
224}
225
226impl ColorFocusRingMotionFrame {
227    fn render<T>(
228        self,
229        element: T,
230        base_shadows: Vec<gpui::BoxShadow>,
231        offset: bool,
232        cx: &App,
233    ) -> gpui::AnyElement
234    where
235        T: Styled + IntoElement + 'static,
236    {
237        let ring_shadows = util::focus_ring_shadows(offset, cx);
238        let paint = move |element: T, opacity: f32| {
239            element.shadow(color_focus_ring_shadows(
240                &base_shadows,
241                &ring_shadows,
242                opacity,
243            ))
244        };
245        if !self.animate {
246            self.opacity.set(self.to);
247            return paint(element, self.to).into_any_element();
248        }
249
250        let opacity = self.opacity;
251        let from = self.from;
252        let to = self.to;
253        element
254            .with_animation(
255                element_id::indexed(&self.base, "focus-ring", self.generation),
256                Animation::new(Duration::from_millis(COLOR_FOCUS_RING_TRANSITION_MS))
257                    .with_easing(crate::anim::ease_out()),
258                move |element, delta| {
259                    let next = from + (to - from) * delta;
260                    opacity.set(next);
261                    paint(element, next)
262                },
263            )
264            .into_any_element()
265    }
266}
267
268pub(super) fn color_focus_ring_motion(
269    id: &ElementId,
270    focused: bool,
271    window: &mut Window,
272    cx: &mut App,
273) -> ColorFocusRingMotionFrame {
274    let state = window.use_keyed_state(element_id::scoped(id, "focus-ring-motion"), cx, |_, _| {
275        ColorFocusRingMotion {
276            focused,
277            generation: 0,
278            from: if focused { 1.0 } else { 0.0 },
279            opacity: Rc::new(Cell::new(if focused { 1.0 } else { 0.0 })),
280        }
281    });
282    let mut current = state.read(cx).clone();
283    let to = if focused { 1.0 } else { 0.0 };
284    if current.focused != focused {
285        current.focused = focused;
286        current.generation = current.generation.wrapping_add(1);
287        current.from = current.opacity.get();
288        state.update(cx, |stored, _| *stored = current.clone());
289    }
290    let reduced_motion = ActiveTheme::reduce_motion(cx);
291    if reduced_motion && (current.opacity.get() - to).abs() > f32::EPSILON {
292        current.from = to;
293        current.opacity.set(to);
294        state.update(cx, |stored, _| *stored = current.clone());
295    }
296    ColorFocusRingMotionFrame {
297        base: id.clone(),
298        generation: current.generation,
299        from: current.from,
300        to,
301        opacity: current.opacity,
302        animate: current.generation != 0
303            && !reduced_motion
304            && (current.from - to).abs() > f32::EPSILON,
305    }
306}
307
308// ---------------------------------------------------------------------------
309// Value model
310// ---------------------------------------------------------------------------
311
312/// The color space a channel belongs to.
313#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
314pub enum ColorSpace {
315    /// Hue, saturation and brightness.
316    #[default]
317    Hsb,
318    /// Hue, saturation and lightness.
319    Hsl,
320    /// Red, green and blue.
321    Rgb,
322}
323
324impl ColorSpace {
325    /// Every color space, in display order.
326    pub const ALL: [ColorSpace; 3] = [ColorSpace::Hsb, ColorSpace::Hsl, ColorSpace::Rgb];
327
328    /// The uppercase name of this color space.
329    pub fn label(self) -> &'static str {
330        match self {
331            ColorSpace::Hsb => "HSB",
332            ColorSpace::Hsl => "HSL",
333            ColorSpace::Rgb => "RGB",
334        }
335    }
336
337    /// The two channels a colour area edits in this space.
338    pub fn area_channels(self) -> (ColorChannel, ColorChannel) {
339        match self {
340            ColorSpace::Hsb => (ColorChannel::Saturation, ColorChannel::Brightness),
341            ColorSpace::Hsl => (ColorChannel::Saturation, ColorChannel::Lightness),
342            ColorSpace::Rgb => (ColorChannel::Red, ColorChannel::Green),
343        }
344    }
345}
346
347/// A single editable channel of a color.
348#[derive(Clone, Copy, Debug, PartialEq, Eq)]
349pub enum ColorChannel {
350    /// Hue, in degrees.
351    Hue,
352    /// Saturation.
353    Saturation,
354    /// HSB value / "brightness".
355    Brightness,
356    /// HSL lightness.
357    Lightness,
358    /// Opacity.
359    Alpha,
360    /// Red component.
361    Red,
362    /// Green component.
363    Green,
364    /// Blue component.
365    Blue,
366}
367
368impl ColorChannel {
369    /// The human-readable name of this channel.
370    pub fn label(self) -> &'static str {
371        match self {
372            ColorChannel::Hue => "Hue",
373            ColorChannel::Saturation => "Saturation",
374            ColorChannel::Brightness => "Brightness",
375            ColorChannel::Lightness => "Lightness",
376            ColorChannel::Alpha => "Alpha",
377            ColorChannel::Red => "Red",
378            ColorChannel::Green => "Green",
379            ColorChannel::Blue => "Blue",
380        }
381    }
382
383    /// The channel's name in the active chrome locale (React Stately's
384    /// colour dictionary; [`ColorChannel::label`] is the en-US name).
385    pub fn localized_label(self, cx: &App) -> SharedString {
386        use crate::i18n::{ui_string, UiString};
387        ui_string(
388            match self {
389                ColorChannel::Hue => UiString::Hue,
390                ColorChannel::Saturation => UiString::Saturation,
391                ColorChannel::Brightness => UiString::Brightness,
392                ColorChannel::Lightness => UiString::Lightness,
393                ColorChannel::Alpha => UiString::Alpha,
394                ColorChannel::Red => UiString::Red,
395                ColorChannel::Green => UiString::Green,
396                ColorChannel::Blue => UiString::Blue,
397            },
398            cx,
399        )
400    }
401
402    /// The inclusive value range of this channel.
403    pub fn range(self) -> (f32, f32) {
404        match self {
405            ColorChannel::Hue => (0.0, 360.0),
406            ColorChannel::Saturation
407            | ColorChannel::Brightness
408            | ColorChannel::Lightness
409            | ColorChannel::Alpha => (0.0, 1.0),
410            ColorChannel::Red | ColorChannel::Green | ColorChannel::Blue => (0.0, 255.0),
411        }
412    }
413}
414
415/// The color value shared by every picker component — HSB plus alpha, matching
416/// React Aria's default working space.
417///
418/// HSB coordinates remain readable through dereferencing. Mutations go through
419/// `with_channel_in`, which preserves the selected color model's channels even
420/// at achromatic endpoints where converting through RGB/HSB would lose them.
421#[derive(Clone, Copy, Debug)]
422pub struct PickerColor {
423    coordinates: HsbCoordinates,
424    model: ColorModel,
425}
426
427/// The HSB representation of a `PickerColor`, with hue in degrees and the other components normalised to `0..1`.
428#[derive(Clone, Copy, Debug, PartialEq)]
429pub struct HsbCoordinates {
430    /// Hue in degrees, `0..360`.
431    pub hue: f32,
432    /// Saturation, `0..1`.
433    pub saturation: f32,
434    /// Brightness (HSB value), `0..1`.
435    pub brightness: f32,
436    /// Alpha, `0..1`.
437    pub alpha: f32,
438}
439
440#[derive(Clone, Copy, Debug, PartialEq)]
441enum ColorModel {
442    Hsb,
443    Hsl { saturation: f32, lightness: f32 },
444}
445
446impl std::ops::Deref for PickerColor {
447    type Target = HsbCoordinates;
448
449    fn deref(&self) -> &Self::Target {
450        &self.coordinates
451    }
452}
453
454impl PartialEq for PickerColor {
455    fn eq(&self, other: &Self) -> bool {
456        self.coordinates == other.coordinates
457    }
458}
459
460impl Default for PickerColor {
461    fn default() -> Self {
462        // React Aria's documented default working color.
463        Self::hsb(210.0, 1.0, 1.0)
464    }
465}
466
467impl PickerColor {
468    /// Creates an opaque color from `hue` (wrapped to `0..360`), `saturation` and `brightness` (clamped to `0..1`).
469    pub fn hsb(hue: f32, saturation: f32, brightness: f32) -> Self {
470        Self {
471            coordinates: HsbCoordinates {
472                hue: normalize_hue(hue),
473                saturation: saturation.clamp(0.0, 1.0),
474                brightness: brightness.clamp(0.0, 1.0),
475                alpha: 1.0,
476            },
477            model: ColorModel::Hsb,
478        }
479    }
480
481    /// Returns the color with its alpha replaced, clamped to `0..1`.
482    pub fn with_alpha(mut self, alpha: f32) -> Self {
483        self.coordinates.alpha = alpha.clamp(0.0, 1.0);
484        self
485    }
486
487    /// Parses `#rgb`, `#rrggbb` or `#rrggbbaa`.
488    pub fn from_hex(text: &str) -> Option<Self> {
489        let hex = text.trim().trim_start_matches('#');
490        let expand = |c: char| {
491            let d = c.to_digit(16)? as f32;
492            Some(d * 17.0 / 255.0)
493        };
494        let (r, g, b, a) = match hex.len() {
495            3 => {
496                let mut it = hex.chars();
497                (
498                    expand(it.next()?)?,
499                    expand(it.next()?)?,
500                    expand(it.next()?)?,
501                    1.0,
502                )
503            }
504            6 | 8 => {
505                let byte = |i: usize| {
506                    u8::from_str_radix(hex.get(i..i + 2)?, 16)
507                        .ok()
508                        .map(|v| v as f32 / 255.0)
509                };
510                (
511                    byte(0)?,
512                    byte(2)?,
513                    byte(4)?,
514                    if hex.len() == 8 { byte(6)? } else { 1.0 },
515                )
516            }
517            _ => return None,
518        };
519        Some(Self::from_rgb(r, g, b).with_alpha(a))
520    }
521
522    /// Builds a color from normalised sRGB components.
523    // `max` is `r.max(g).max(b)`, so `max == r` asks which channel won, not
524    // whether two computed floats are near each other. An epsilon here would
525    // make two equally-large channels both match.
526    #[allow(clippy::float_cmp)]
527    pub fn from_rgb(r: f32, g: f32, b: f32) -> Self {
528        let max = r.max(g).max(b);
529        let min = r.min(g).min(b);
530        let delta = max - min;
531        // `max` is by construction one of `r`/`g`/`b`, so these comparisons
532        // select a branch rather than test a quantity; comparing within a
533        // tolerance would pick the wrong one when two channels are merely close.
534        #[allow(clippy::float_cmp)]
535        let hue = if delta <= f32::EPSILON {
536            0.0
537        } else if max == r {
538            60.0 * (((g - b) / delta) % 6.0)
539        } else if max == g {
540            60.0 * ((b - r) / delta + 2.0)
541        } else {
542            60.0 * ((r - g) / delta + 4.0)
543        };
544        Self {
545            coordinates: HsbCoordinates {
546                hue: normalize_hue(hue),
547                saturation: if max <= f32::EPSILON {
548                    0.0
549                } else {
550                    delta / max
551                },
552                brightness: max,
553                alpha: 1.0,
554            },
555            model: ColorModel::Hsb,
556        }
557    }
558
559    /// Normalised sRGB components.
560    pub fn to_rgb(self) -> (f32, f32, f32) {
561        let c = self.brightness * self.saturation;
562        let h = self.hue / 60.0;
563        let x = c * (1.0 - ((h % 2.0) - 1.0).abs());
564        let (r, g, b) = match h as u32 % 6 {
565            0 => (c, x, 0.0),
566            1 => (x, c, 0.0),
567            2 => (0.0, c, x),
568            3 => (0.0, x, c),
569            4 => (x, 0.0, c),
570            _ => (c, 0.0, x),
571        };
572        let m = self.brightness - c;
573        (r + m, g + m, b + m)
574    }
575
576    /// The gpui color for this value.
577    pub fn to_hsla(self) -> Hsla {
578        let (r, g, b) = self.to_rgb();
579        Hsla::from(gpui::Rgba {
580            r,
581            g,
582            b,
583            a: self.alpha,
584        })
585    }
586
587    /// `#rrggbb`, or `#rrggbbaa` when the color is translucent.
588    pub fn to_hex(self) -> String {
589        let (r, g, b) = self.to_rgb();
590        let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
591        if self.alpha >= 1.0 {
592            format!("#{:02X}{:02X}{:02X}", q(r), q(g), q(b))
593        } else {
594            format!("#{:02X}{:02X}{:02X}{:02X}", q(r), q(g), q(b), q(self.alpha))
595        }
596    }
597
598    /// Reads one channel in its own units.
599    /// HSL saturation, which is a different quantity from the stored HSB
600    /// saturation for every colour that is not fully saturated or achromatic.
601    pub fn hsl_saturation(self) -> f32 {
602        if let ColorModel::Hsl { saturation, .. } = self.model {
603            return saturation;
604        }
605        let l = self.brightness * (1.0 - self.saturation / 2.0);
606        let denom = l.min(1.0 - l);
607        if denom <= f32::EPSILON {
608            0.0
609        } else {
610            ((self.brightness - l) / denom).clamp(0.0, 1.0)
611        }
612    }
613
614    /// Replaces the HSL saturation, holding hue and HSL lightness.
615    pub fn with_hsl_saturation(self, s: f32) -> Self {
616        let s = s.clamp(0.0, 1.0);
617        let l = self.hsl_lightness();
618        self.with_hsl_channels(s, l)
619    }
620
621    pub(super) fn with_hsl_lightness(self, l: f32) -> Self {
622        self.with_hsl_channels(self.hsl_saturation(), l.clamp(0.0, 1.0))
623    }
624
625    pub(super) fn with_hsl_channels(self, s: f32, l: f32) -> Self {
626        let v = l + s * l.min(1.0 - l);
627        let sv = if v <= f32::EPSILON {
628            0.0
629        } else {
630            (2.0 * (1.0 - l / v)).clamp(0.0, 1.0)
631        };
632        Self {
633            coordinates: HsbCoordinates {
634                saturation: sv,
635                brightness: v,
636                ..self.coordinates
637            },
638            model: ColorModel::Hsl {
639                saturation: s,
640                lightness: l,
641            },
642        }
643    }
644
645    pub(super) fn hsl_lightness(self) -> f32 {
646        match self.model {
647            ColorModel::Hsl { lightness, .. } => lightness,
648            ColorModel::Hsb => self.brightness * (1.0 - self.saturation / 2.0),
649        }
650    }
651
652    /// [`PickerColor::channel`] read in `space`.
653    pub fn channel_in(self, channel: ColorChannel, space: ColorSpace) -> f32 {
654        match (channel, space) {
655            (ColorChannel::Saturation, ColorSpace::Hsl) => self.hsl_saturation(),
656            (ColorChannel::Lightness, ColorSpace::Hsl) => self.hsl_lightness(),
657            _ => self.channel(channel),
658        }
659    }
660
661    /// [`PickerColor::with_channel`] written in `space`.
662    pub fn with_channel_in(self, channel: ColorChannel, space: ColorSpace, value: f32) -> Self {
663        match (channel, space) {
664            (ColorChannel::Saturation, ColorSpace::Hsl) => self.with_hsl_saturation(value),
665            (ColorChannel::Lightness, ColorSpace::Hsl) => self.with_hsl_lightness(value),
666            (ColorChannel::Hue, ColorSpace::Hsl) => Self {
667                coordinates: HsbCoordinates {
668                    hue: normalize_hue(value),
669                    ..self.coordinates
670                },
671                ..self
672            },
673            (ColorChannel::Alpha, ColorSpace::Hsl) => self.with_alpha(value),
674            _ => self.with_channel(channel, value),
675        }
676    }
677
678    /// The value of `channel`: hue in degrees, red/green/blue on `0..255`, the others on `0..1`.
679    pub fn channel(self, channel: ColorChannel) -> f32 {
680        let (r, g, b) = self.to_rgb();
681        match channel {
682            ColorChannel::Hue => self.hue,
683            ColorChannel::Saturation => self.saturation,
684            ColorChannel::Brightness => self.brightness,
685            // HSB -> HSL lightness.
686            ColorChannel::Lightness => self.hsl_lightness(),
687            ColorChannel::Alpha => self.alpha,
688            ColorChannel::Red => r * 255.0,
689            ColorChannel::Green => g * 255.0,
690            ColorChannel::Blue => b * 255.0,
691        }
692    }
693
694    /// Returns a copy with one channel replaced.
695    pub fn with_channel(self, channel: ColorChannel, value: f32) -> Self {
696        let (min, max) = channel.range();
697        let value = value.clamp(min, max);
698        let (r, g, b) = self.to_rgb();
699        match channel {
700            ColorChannel::Hue => Self {
701                coordinates: HsbCoordinates {
702                    hue: normalize_hue(value),
703                    ..self.coordinates
704                },
705                model: ColorModel::Hsb,
706            },
707            ColorChannel::Saturation => Self {
708                coordinates: HsbCoordinates {
709                    saturation: value,
710                    ..self.coordinates
711                },
712                model: ColorModel::Hsb,
713            },
714            ColorChannel::Brightness => Self {
715                coordinates: HsbCoordinates {
716                    brightness: value,
717                    ..self.coordinates
718                },
719                model: ColorModel::Hsb,
720            },
721            ColorChannel::Lightness => self.with_hsl_lightness(value),
722            ColorChannel::Alpha => Self {
723                coordinates: HsbCoordinates {
724                    alpha: value,
725                    ..self.coordinates
726                },
727                ..self
728            },
729            ColorChannel::Red => Self::from_rgb(value / 255.0, g, b).with_alpha(self.alpha),
730            ColorChannel::Green => Self::from_rgb(r, value / 255.0, b).with_alpha(self.alpha),
731            ColorChannel::Blue => Self::from_rgb(r, g, value / 255.0).with_alpha(self.alpha),
732        }
733    }
734}
735
736// React Stately preserves the exact endpoint 360 as a distinct slider
737// position even though it renders the same color as 0.
738#[allow(clippy::float_cmp)]
739pub(super) fn normalize_hue(hue: f32) -> f32 {
740    if hue == 360.0 {
741        hue
742    } else {
743        hue.rem_euclid(360.0)
744    }
745}
746
747pub(super) type OnColorChange = Arc<dyn Fn(&PickerColor, &mut Window, &mut App) + 'static>;
748
749pub(super) fn color_swatch_indicator_color(swatch: PickerColor) -> Hsla {
750    let (r, g, b) = swatch.to_rgb();
751    let luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b;
752    if luminance > 0.5 {
753        gpui::black()
754    } else {
755        gpui::white()
756    }
757}
758
759/// `ColorField`'s `onChange`, which reports `None` when the text is not a
760/// colour -- v3 types it `(color: Color | null) => void`.
761pub(super) type OnColorFieldChange =
762    Arc<dyn Fn(&Option<PickerColor>, &mut Window, &mut App) + 'static>;
763
764/// Shape of a swatch.
765#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
766pub enum SwatchShape {
767    /// A circular swatch.
768    #[default]
769    Circle,
770    /// A square swatch.
771    Square,
772}
773
774impl SwatchShape {
775    /// Both swatch shapes, in display order.
776    pub const ALL: [SwatchShape; 2] = [SwatchShape::Circle, SwatchShape::Square];
777
778    /// The human-readable name of this shape.
779    pub fn label(self) -> &'static str {
780        match self {
781            SwatchShape::Circle => "Circle",
782            SwatchShape::Square => "Square",
783        }
784    }
785}
786
787mod area;
788mod field;
789mod picker;
790mod slider;
791mod swatch;
792mod swatch_picker;
793
794pub use area::*;
795#[allow(unused_imports)]
796pub(super) use area::*;
797pub use field::*;
798#[allow(unused_imports)]
799pub(super) use field::*;
800pub use picker::*;
801#[allow(unused_imports)]
802pub(super) use picker::*;
803pub use slider::*;
804#[allow(unused_imports)]
805pub(super) use slider::*;
806pub use swatch::*;
807#[allow(unused_imports)]
808pub(super) use swatch::*;
809pub use swatch_picker::*;
810#[allow(unused_imports)]
811pub(super) use swatch_picker::*;
812
813#[cfg(test)]
814mod tests {
815    use super::*;
816
817    #[test]
818    fn hsl_and_hsb_saturation_differ_and_round_trip() {
819        // Mid-brightness, half-saturated: the two spaces disagree here, which
820        // is exactly the case a colorSpace-unaware slider got wrong.
821        let c = PickerColor::hsb(210.0, 0.5, 0.6);
822        let hsl_s = c.hsl_saturation();
823        assert!(
824            (hsl_s - c.saturation).abs() > 0.05,
825            "expected the two saturations to differ, got {hsl_s} vs {}",
826            c.saturation
827        );
828
829        // Writing an HSL saturation and reading it back is stable.
830        for target in [0.0, 0.25, 0.5, 0.75, 1.0] {
831            let out = c.with_hsl_saturation(target);
832            assert!(
833                (out.hsl_saturation() - target).abs() < 1e-3,
834                "hsl saturation {target} round-tripped to {}",
835                out.hsl_saturation()
836            );
837            // Hue and HSL lightness are held.
838            assert!((out.hue - c.hue).abs() < 1e-3);
839        }
840    }
841
842    #[test]
843    fn equality_uses_public_hsb_coordinates_not_color_model_history() {
844        let hsl_black = PickerColor::hsb(40.0, 0.5, 0.5)
845            .with_channel_in(ColorChannel::Saturation, ColorSpace::Hsl, 0.75)
846            .with_channel_in(ColorChannel::Lightness, ColorSpace::Hsl, 0.0);
847        let hsb_black = PickerColor::hsb(hsl_black.hue, 0.0, 0.0);
848        assert_eq!(hsl_black, hsb_black);
849        assert_eq!(hsl_black.to_hex(), hsb_black.to_hex());
850    }
851
852    #[test]
853    fn hue_gradients_have_seven_stops_and_reverse_on_the_vertical_axis() {
854        let stops = hue_stop_colors(PickerColor::hsb(0.0, 1.0, 1.0), ColorSpace::Hsb);
855        assert_eq!(stops[0], stops[6]);
856        for pair in stops.windows(2).take(5) {
857            assert_ne!(pair[0], pair[1]);
858        }
859        assert!((hue_band_offset(0, false, 0.0) - 0.0).abs() < f32::EPSILON);
860        assert!((hue_band_offset(5, false, 0.0) - 5.0 / 6.0).abs() < f32::EPSILON);
861        assert!((hue_band_offset(0, true, 0.0) - 5.0 / 6.0).abs() < f32::EPSILON);
862        assert!((hue_band_offset(5, true, 0.0) - 0.0).abs() < f32::EPSILON);
863        for index in 0..6 {
864            assert!((hue_band_extent(index, 0.0) - 1.0 / 6.0).abs() < 1e-6);
865        }
866    }
867
868    #[test]
869    fn inset_hue_bands_stretch_only_the_two_end_bands() {
870        // A 240px slider with 10px caps.
871        let inset = 10.0 / 240.0;
872        let width = (1.0 - inset * 2.0) / 6.0;
873        // The end bands reach the box edges; the interior ones keep their
874        // travel width, so the ramp is unchanged over the thumb's travel.
875        assert!((hue_band_offset(0, false, inset) - 0.0).abs() < 1e-6);
876        assert!((hue_band_extent(0, inset) - (width + inset)).abs() < 1e-6);
877        assert!((hue_band_extent(5, inset) - (width + inset)).abs() < 1e-6);
878        for index in 1..5 {
879            assert!((hue_band_extent(index, inset) - width).abs() < 1e-6);
880            assert!(
881                (hue_band_offset(index, false, inset) - (inset + index as f32 * width)).abs()
882                    < 1e-6
883            );
884        }
885        // Vertical mirrors: band 0 is flush with the bottom, band 5 the top.
886        assert!((hue_band_offset(5, true, inset) - 0.0).abs() < 1e-6);
887        assert!(
888            (hue_band_offset(0, true, inset) - (1.0 - width - inset)).abs() < 1e-6,
889            "band 0 must end at the bottom edge"
890        );
891        let total: f32 = (0..6).map(|i| hue_band_extent(i, inset)).sum();
892        assert!((total - 1.0).abs() < 1e-5, "the bands must tile the box");
893    }
894
895    #[test]
896    fn corner_arc_inset_is_zero_outside_the_corner_and_full_at_the_edge() {
897        let r = 16.0;
898        assert!((corner_arc_inset(0.0, r) - r).abs() < 1e-6);
899        assert!((corner_arc_inset(r, r) - 0.0).abs() < f32::EPSILON);
900        assert!((corner_arc_inset(r + 5.0, r) - 0.0).abs() < f32::EPSILON);
901        assert!((corner_arc_inset(1.0, 0.0) - 0.0).abs() < f32::EPSILON);
902        let mut previous = f32::INFINITY;
903        for step in 0..=32 {
904            let d = r * step as f32 / 32.0;
905            let inset = corner_arc_inset(d, r);
906            assert!(inset <= previous + 1e-6, "must decrease with distance");
907            assert!((0.0..=r).contains(&inset));
908            // Strictly inside the arc: the point (d, inset) is on the circle
909            // centred at (r, r), so the strip never crosses the curve.
910            let dx = r - d;
911            let dy = r - inset;
912            assert!((dx * dx + dy * dy).sqrt() <= r + 1e-3);
913            previous = inset;
914        }
915        assert!((previous - 0.0).abs() < 1e-5);
916    }
917
918    #[test]
919    fn the_slider_ramp_is_one_full_length_element_over_nothing_but_the_checkerboard() {
920        let source = include_str!("slider.rs");
921        // The stop-percentage design replaced the end bases, the alpha end
922        // piece and the short-track fallback caps: with `opacity()` applied
923        // per element, anything under the ramp shows through when disabled.
924        for gone in [
925            "END_BASE_PX",
926            "wide_ends",
927            "wide_opaque_ends",
928            "start_cap",
929            "end_cap",
930        ] {
931            assert!(
932                !source.contains(gone),
933                "`{gone}` must not come back: it paints under or over the ramp"
934            );
935        }
936        // Exactly one child is added to the clip besides the ramp, and it is
937        // the alpha checkerboard.
938        let painted: Vec<&str> = source
939            .lines()
940            .map(str::trim)
941            .filter(|line| line.starts_with("layers = layers"))
942            .collect();
943        assert_eq!(
944            painted,
945            vec!["layers = layers", "layers = layers.child(ramp);"],
946            "only the checkerboard and the ramp may be painted into the clip"
947        );
948        // The ramp is full-length, so its r10 corners are never clamped.
949        assert!(source.contains("let ramp = div().absolute().inset_0().rounded(track_r);"));
950        assert!(source.contains("gpui::linear_color_stop(start_color, inset)"));
951        assert!(source.contains("gpui::linear_color_stop(end_color, 1.0 - inset)"));
952    }
953
954    #[test]
955    fn lightness_midpoint_preserves_the_current_hue() {
956        let value = PickerColor::hsb(0.0, 1.0, 1.0);
957        let (start, middle, end) = lightness_gradient_colors(value, ColorSpace::Hsl, 0.0, 1.0);
958        assert_eq!(start, gpui::black());
959        assert_eq!(middle, value.to_hsla());
960        assert_eq!(end, gpui::white());
961    }
962
963    #[test]
964    #[allow(clippy::float_cmp)] // the two spaces must agree bit for bit
965    fn channel_in_only_diverges_for_saturation() {
966        let c = PickerColor::hsb(30.0, 0.4, 0.8);
967        for ch in [
968            ColorChannel::Hue,
969            ColorChannel::Brightness,
970            ColorChannel::Lightness,
971            ColorChannel::Alpha,
972            ColorChannel::Red,
973            ColorChannel::Green,
974            ColorChannel::Blue,
975        ] {
976            let (hsl, hsb) = (
977                c.channel_in(ch, ColorSpace::Hsl),
978                c.channel_in(ch, ColorSpace::Hsb),
979            );
980            assert!(
981                (hsl - hsb).abs() < 1e-6,
982                "{ch:?} should not depend on the colour space"
983            );
984        }
985        assert_ne!(
986            c.channel_in(ColorChannel::Saturation, ColorSpace::Hsl),
987            c.channel_in(ColorChannel::Saturation, ColorSpace::Hsb)
988        );
989    }
990
991    #[test]
992    fn area_channels_match_the_space() {
993        assert_eq!(
994            ColorSpace::Hsl.area_channels(),
995            (ColorChannel::Saturation, ColorChannel::Lightness)
996        );
997        assert_eq!(
998            ColorSpace::Hsb.area_channels(),
999            (ColorChannel::Saturation, ColorChannel::Brightness)
1000        );
1001        assert_eq!(
1002            ColorSpace::Rgb.area_channels(),
1003            (ColorChannel::Red, ColorChannel::Green)
1004        );
1005    }
1006
1007    #[test]
1008    fn hex_round_trips() {
1009        for hex in [
1010            "#FF0000", "#00FF00", "#0000FF", "#123456", "#FFFFFF", "#000000",
1011        ] {
1012            let c = PickerColor::from_hex(hex).expect(hex);
1013            assert_eq!(c.to_hex(), hex, "{hex}");
1014        }
1015    }
1016
1017    #[test]
1018    fn short_hex_expands() {
1019        assert_eq!(PickerColor::from_hex("#f00").unwrap().to_hex(), "#FF0000");
1020    }
1021
1022    #[test]
1023    fn hex_with_alpha_round_trips() {
1024        let c = PickerColor::from_hex("#11223344").unwrap();
1025        assert_eq!(c.to_hex(), "#11223344");
1026    }
1027
1028    #[test]
1029    fn rejects_bad_hex() {
1030        assert!(PickerColor::from_hex("#12345").is_none());
1031        assert!(PickerColor::from_hex("nope").is_none());
1032    }
1033
1034    #[test]
1035    fn color_surfaces_keep_the_pinned_depth_layers() {
1036        let inner = color_inner_shadow();
1037        assert!(inner.inset);
1038        assert_eq!(inner.offset, gpui::point(px(0.), px(0.)));
1039        assert_eq!(inner.spread_radius, px(1.));
1040        assert!((inner.color.a - 0.1).abs() < f32::EPSILON);
1041
1042        let thumb = color_thumb_shadows();
1043        assert_eq!(thumb.len(), 2);
1044        assert!(!thumb[0].inset);
1045        assert!(thumb[1].inset);
1046        assert!(thumb.iter().all(|shadow| {
1047            shadow.offset == gpui::point(px(0.), px(0.))
1048                && shadow.spread_radius == px(1.)
1049                && (shadow.color.a - 0.1).abs() < f32::EPSILON
1050        }));
1051
1052        let track = color_track_shadows();
1053        assert_eq!(track.len(), 4);
1054        assert!(track.iter().all(|shadow| shadow.inset
1055            && shadow.blur_radius == px(0.)
1056            && shadow.spread_radius == px(0.)));
1057
1058        assert_eq!(
1059            color_slider_thumb_transition_offset(0.25, 0.75, px(200.), false,),
1060            px(-100.)
1061        );
1062        assert_eq!(
1063            color_slider_thumb_transition_offset(0.25, 0.75, px(200.), true,),
1064            px(100.)
1065        );
1066    }
1067
1068    #[test]
1069    fn color_focus_ring_transition_preserves_depth_and_fades_ring_layers() {
1070        let base = color_thumb_shadows();
1071        let ring = vec![gpui::BoxShadow {
1072            color: gpui::white(),
1073            offset: gpui::point(px(0.), px(0.)),
1074            blur_radius: px(1.),
1075            spread_radius: px(2.),
1076            inset: false,
1077        }];
1078
1079        let resting = color_focus_ring_shadows(&base, &ring, 0.0);
1080        assert_eq!(resting, base);
1081
1082        let halfway = color_focus_ring_shadows(&base, &ring, 0.5);
1083        assert_eq!(halfway.len(), base.len() + 1);
1084        assert!((halfway.last().expect("ring layer").color.a - 0.5).abs() < 1e-6);
1085
1086        let focused = color_focus_ring_shadows(&base, &ring, 1.0);
1087        assert_eq!(focused.len(), base.len() + 1);
1088        assert_eq!(focused.last().expect("ring layer").color, gpui::white());
1089    }
1090
1091    #[test]
1092    fn color_picker_entry_offsets_follow_the_requested_side() {
1093        assert_eq!(
1094            color_picker_entry_offset(Placement::BottomStart),
1095            (0.0, -4.0)
1096        );
1097        assert_eq!(color_picker_entry_offset(Placement::TopEnd), (0.0, 4.0));
1098        assert_eq!(color_picker_entry_offset(Placement::Left), (4.0, 0.0));
1099        assert_eq!(color_picker_entry_offset(Placement::Right), (-4.0, 0.0));
1100    }
1101
1102    #[test]
1103    fn channel_edits_are_isolated() {
1104        let c = PickerColor::hsb(120.0, 0.5, 0.5);
1105        let hue = c.with_channel(ColorChannel::Hue, 240.0);
1106        assert!((hue.hue - 240.0).abs() < 1e-3);
1107        assert!((hue.saturation - c.saturation).abs() < 1e-3);
1108        assert!((hue.brightness - c.brightness).abs() < 1e-3);
1109    }
1110
1111    #[test]
1112    #[allow(clippy::float_cmp)] // the clamp bounds are exact
1113    fn channel_values_stay_in_range() {
1114        let c = PickerColor::default();
1115        assert!((c.with_channel(ColorChannel::Alpha, 5.0).alpha - 1.0).abs() < 1e-6);
1116        assert_eq!(c.with_channel(ColorChannel::Alpha, -1.0).alpha, 0.0);
1117        assert!(
1118            c.with_channel(ColorChannel::Red, 999.0)
1119                .channel(ColorChannel::Red)
1120                <= 255.0
1121        );
1122    }
1123
1124    #[test]
1125    fn rgb_channel_edit_matches_readback() {
1126        let c = PickerColor::from_hex("#204060").unwrap();
1127        let next = c.with_channel(ColorChannel::Green, 128.0);
1128        assert!((next.channel(ColorChannel::Green) - 128.0).abs() < 1.0);
1129    }
1130
1131    #[test]
1132    fn dark_saturated_blue_uses_a_white_selected_indicator() {
1133        let blue = PickerColor::from_hex("#0000FF").unwrap();
1134        assert_eq!(color_swatch_indicator_color(blue), gpui::white());
1135    }
1136
1137    #[test]
1138    fn mid_gray_uses_a_black_selected_indicator() {
1139        let gray = PickerColor::from_hex("#808080").unwrap();
1140        assert_eq!(color_swatch_indicator_color(gray), gpui::black());
1141    }
1142}