Skip to main content

herogpui_components/color_picker/
area.rs

1//! ColorArea.
2
3use super::*;
4
5// ColorArea
6// ---------------------------------------------------------------------------
7
8/// State handed to `ColorArea.Thumb`'s render function.
9#[derive(Clone, Copy, Debug, Default, PartialEq)]
10#[non_exhaustive]
11pub struct ColorAreaThumbState {
12    /// The color at the thumb.
13    pub color: PickerColor,
14    /// Whether the thumb is being dragged.
15    pub is_dragging: bool,
16    /// Whether the area is hovered.
17    pub is_hovered: bool,
18    /// Whether the area has focus.
19    pub is_focused: bool,
20    /// Whether focus is visible (keyboard focus).
21    pub is_focus_visible: bool,
22    /// Whether the area is disabled.
23    pub is_disabled: bool,
24}
25
26const COLOR_AREA_THUMB_IDLE_PX: f32 = 16.0;
27const COLOR_AREA_THUMB_DRAGGING_PX: f32 = 20.0;
28const COLOR_AREA_THUMB_TRANSITION_MS: u64 = 150;
29
30#[derive(Clone)]
31pub(super) struct ColorAreaThumbMotion {
32    dragging: bool,
33    generation: usize,
34    from: f32,
35    size: Rc<Cell<f32>>,
36}
37
38pub(super) struct ColorAreaThumbMotionFrame {
39    base: ElementId,
40    generation: usize,
41    from: f32,
42    to: f32,
43    size: Rc<Cell<f32>>,
44    animate: bool,
45}
46
47impl ColorAreaThumbMotionFrame {
48    fn render(self, thumb: gpui::Div) -> gpui::AnyElement {
49        if !self.animate {
50            self.size.set(self.to);
51            return place_color_area_thumb(thumb, self.to).into_any_element();
52        }
53
54        let size = self.size;
55        let from = self.from;
56        let to = self.to;
57        thumb
58            .with_animation(
59                element_id::indexed(&self.base, "thumb-size", self.generation),
60                Animation::new(Duration::from_millis(COLOR_AREA_THUMB_TRANSITION_MS))
61                    .with_easing(|t| crate::anim::Curve::Out.at(t)),
62                move |thumb, delta| {
63                    let next = from + (to - from) * delta;
64                    size.set(next);
65                    place_color_area_thumb(thumb, next)
66                },
67            )
68            .into_any_element()
69    }
70}
71
72pub(super) fn place_color_area_thumb(thumb: gpui::Div, size: f32) -> gpui::Div {
73    let inset = (COLOR_AREA_THUMB_DRAGGING_PX - size) / 2.0;
74    thumb.size(px(size)).ml(px(inset)).mt(px(inset))
75}
76
77pub(super) fn color_area_thumb_motion(
78    id: &ElementId,
79    dragging: bool,
80    window: &mut Window,
81    cx: &mut App,
82) -> ColorAreaThumbMotionFrame {
83    let state = window.use_keyed_state(element_id::scoped(id, "thumb-motion"), cx, |_, _| {
84        ColorAreaThumbMotion {
85            dragging,
86            generation: 0,
87            from: if dragging {
88                COLOR_AREA_THUMB_DRAGGING_PX
89            } else {
90                COLOR_AREA_THUMB_IDLE_PX
91            },
92            size: Rc::new(Cell::new(if dragging {
93                COLOR_AREA_THUMB_DRAGGING_PX
94            } else {
95                COLOR_AREA_THUMB_IDLE_PX
96            })),
97        }
98    });
99    let mut current = state.read(cx).clone();
100    let to = if dragging {
101        COLOR_AREA_THUMB_DRAGGING_PX
102    } else {
103        COLOR_AREA_THUMB_IDLE_PX
104    };
105    if current.dragging != dragging {
106        current.dragging = dragging;
107        current.generation = current.generation.wrapping_add(1);
108        current.from = current.size.get();
109        state.update(cx, |stored, _| *stored = current.clone());
110    }
111    if ActiveTheme::reduce_motion(cx) && (current.size.get() - to).abs() > f32::EPSILON {
112        current.from = to;
113        current.size.set(to);
114        state.update(cx, |stored, _| *stored = current.clone());
115    }
116    ColorAreaThumbMotionFrame {
117        base: id.clone(),
118        generation: current.generation,
119        from: current.from,
120        to,
121        size: current.size,
122        animate: current.generation != 0
123            && !ActiveTheme::reduce_motion(cx)
124            && (current.from - to).abs() > f32::EPSILON,
125    }
126}
127
128/// ColorArea — a two-dimensional gradient for picking two channels at once.
129#[must_use = "a component does nothing until it is rendered: add it as a child or return it from `render`"]
130#[derive(IntoElement)]
131pub struct ColorArea {
132    /// `defaultValue` — set it to hand this component its own state.
133    default_value: Option<PickerColor>,
134    id: ElementId,
135    value: PickerColor,
136    /// `colorSpace` — set explicitly, it selects the channel pair; an explicit
137    /// `x_channel`/`y_channel` still wins.
138    color_space: Option<ColorSpace>,
139    x_channel: ColorChannel,
140    y_channel: ColorChannel,
141    width: Pixels,
142    height: Pixels,
143    is_disabled: bool,
144    show_dots: bool,
145    thumb: Option<Arc<dyn Fn(ColorAreaThumbState) -> gpui::AnyElement + 'static>>,
146    on_change: Option<OnColorChange>,
147    on_change_end: Option<OnColorChange>,
148    /// The `sx` slot, refined over the root style at the end of render.
149    sx: Option<Box<gpui::StyleRefinement>>,
150}
151
152impl ColorArea {
153    /// Creates a color area showing `value`.
154    pub fn new(id: impl Into<ElementId>, value: PickerColor) -> Self {
155        Self {
156            default_value: None,
157            id: id.into(),
158            value,
159            color_space: None,
160            x_channel: ColorChannel::Saturation,
161            y_channel: ColorChannel::Brightness,
162            width: px(224.),
163            height: px(224.),
164            is_disabled: false,
165            show_dots: false,
166            thumb: None,
167            on_change: None,
168            on_change_end: None,
169            sx: None,
170        }
171    }
172
173    /// `defaultValue` — the uncontrolled initial colour.
174    ///
175    /// Supplying it hands the component its own state: the constructor's
176    /// `value` becomes the seed, and a change moves the component's copy.
177    pub fn default_value(mut self, value: PickerColor) -> Self {
178        self.default_value = Some(value);
179        self
180    }
181
182    /// `colorSpace` — the space whose channels the area edits.
183    ///
184    /// Sets both axes to that space's pair; call `x_channel`/`y_channel`
185    /// afterwards to override either one.
186    pub fn color_space(mut self, space: ColorSpace) -> Self {
187        let (x, y) = space.area_channels();
188        self.color_space = Some(space);
189        self.x_channel = x;
190        self.y_channel = y;
191        self
192    }
193
194    /// Sets the channel edited along the horizontal axis.
195    pub fn x_channel(mut self, channel: ColorChannel) -> Self {
196        self.x_channel = channel;
197        self
198    }
199
200    /// Sets the channel edited along the vertical axis.
201    pub fn y_channel(mut self, channel: ColorChannel) -> Self {
202        self.y_channel = channel;
203        self
204    }
205
206    /// Sets the width and height of the area.
207    pub fn size(mut self, width: impl Into<Pixels>, height: impl Into<Pixels>) -> Self {
208        self.width = width.into();
209        self.height = height.into();
210        self
211    }
212
213    /// The one slot for caller-owned low-level styling: GPUI's styling methods
214    /// (`bg`, `text_color`, `w`, `h`, `p`, `rounded`, `border_color`, …)
215    /// applied to the area's root element after every value the channels and
216    /// the active theme chose, so they win.
217    pub fn sx(mut self, style: impl FnOnce(gpui::Div) -> gpui::Div) -> Self {
218        util::refine_sx(&mut self.sx, style);
219        self
220    }
221
222    /// Sets whether the area is disabled (`isDisabled`).
223    pub fn is_disabled(mut self, v: bool) -> Self {
224        self.is_disabled = v;
225        self
226    }
227
228    /// `showDots` — overlays a dot grid for finer visual positioning.
229    pub fn show_dots(mut self, v: bool) -> Self {
230        self.show_dots = v;
231        self
232    }
233
234    /// `ColorArea.Thumb`'s render function — the closure receives the live
235    /// color and interaction state while the built-in thumb keeps ownership
236    /// of positioning, focus and pointer behavior.
237    pub fn thumb(
238        mut self,
239        render: impl Fn(ColorAreaThumbState) -> gpui::AnyElement + 'static,
240    ) -> Self {
241        self.thumb = Some(Arc::new(render));
242        self
243    }
244
245    /// Sets the handler called with the new color as the thumb moves (`onChange`).
246    pub fn on_change(
247        mut self,
248        handler: impl Fn(&PickerColor, &mut Window, &mut App) + 'static,
249    ) -> Self {
250        self.on_change = Some(Arc::new(handler));
251        self
252    }
253
254    /// `onChangeEnd` — fires once when the drag finishes.
255    pub fn on_change_end(
256        mut self,
257        handler: impl Fn(&PickerColor, &mut Window, &mut App) + 'static,
258    ) -> Self {
259        self.on_change_end = Some(Arc::new(handler));
260        self
261    }
262}
263
264impl RenderOnce for ColorArea {
265    fn render(mut self, window: &mut Window, cx: &mut App) -> impl IntoElement {
266        // `defaultValue` opts into the component holding its own colour;
267        // `controlled` takes `cx` mutably, so it precedes the theme tokens.
268        let (resolved, own) = util::controlled(
269            window,
270            cx,
271            element_id::scoped(&self.id, "area-value"),
272            match self.default_value {
273                Some(_) => None,
274                None => Some(self.value),
275            },
276            self.default_value.unwrap_or(self.value),
277        );
278        self.value = resolved;
279        // `.color-area__thumb[data-focus-visible]` is `status-focused`.
280        // `use_keyed_state` takes `cx` mutably, so the handle precedes the
281        // theme.
282        let area_focus =
283            util::tab_stop_handle(element_id::scoped(&self.id, "area-focus"), window, cx);
284        let bounds_slot =
285            window.use_keyed_state(element_id::scoped(&self.id, "area-bounds"), cx, |_, _| {
286                Bounds::<f32> {
287                    origin: gpui::point(0., 0.),
288                    size: gpui::size(0., 0.),
289                }
290            });
291        let dragging =
292            window.use_keyed_state(element_id::scoped(&self.id, "area-dragging"), cx, |_, _| {
293                false
294            });
295        let thumb_hovered = window.use_keyed_state(
296            element_id::scoped(&self.id, "area-thumb-hovered"),
297            cx,
298            |_, _| false,
299        );
300        let colors = cx.colors();
301        let border_color = util::sx_border_color(&self.sx).unwrap_or(colors.border);
302        // `.color-area` is `rounded-2xl`, which is `soft_radius`.
303        let radius = util::soft_radius(cx);
304
305        let (x_min, x_max) = self.x_channel.range();
306        let (y_min, y_max) = self.y_channel.range();
307        let color_space = self.color_space.unwrap_or_default();
308        let x_norm = ((self.value.channel_in(self.x_channel, color_space) - x_min)
309            / (x_max - x_min))
310            .clamp(0.0, 1.0);
311        let y_norm = ((self.value.channel_in(self.y_channel, color_space) - y_min)
312            / (y_max - y_min))
313            .clamp(0.0, 1.0);
314
315        // Saturation left-to-right over the hue, brightness/lightness
316        // bottom-to-top. RGB uses sampled vertical ramps because gpui has no
317        // screen blend mode, which React Aria uses to combine its axes.
318        let mut area = div()
319            .id(self.id.clone())
320            .when(!self.is_disabled, |el| el.track_focus(&area_focus))
321            .relative()
322            .w(self.width)
323            .h(self.height)
324            .rounded(radius);
325
326        // `.color-area` is `overflow: visible` -- the thumb is meant to hang
327        // over the edge, and upstream can allow that because its gradient stack
328        // is the element's own `background` plus an `::after`, both of which
329        // take the radius. Here the stack is real children, so the clip that
330        // holds them inside the corner goes on this one inner layer rather than
331        // on the area: clipping the area cut the thumb in half at every edge.
332        let mut layers = div().absolute().inset_0().rounded(radius).overflow_hidden();
333
334        // The gradient stack stays div fills, each carrying the area radius
335        // itself: GPUI's `Svg` element renders through an alpha mask, so a
336        // multicolor gradient SVG can only ever paint a monochrome
337        // silhouette (`window.rs`: `render_alpha_mask`). The single-curve
338        // alternative does not exist on vanilla; the per-layer radii below
339        // coincide because every layer shares one box and one radius value.
340        layers =
341            if self.x_channel == ColorChannel::Hue || self.y_channel == ColorChannel::Hue {
342                layers.child(color_area_hue_layers(
343                    self.value,
344                    color_space,
345                    self.x_channel,
346                    self.y_channel,
347                    radius,
348                ))
349            } else {
350                match (color_space, self.x_channel, self.y_channel) {
351                    (ColorSpace::Hsb, ColorChannel::Saturation, ColorChannel::Brightness) => layers
352                        .bg(gpui::linear_gradient(
353                            90.0,
354                            gpui::linear_color_stop(gpui::white(), 0.0),
355                            gpui::linear_color_stop(
356                                PickerColor::hsb(self.value.hue, 1.0, 1.0).to_hsla(),
357                                1.0,
358                            ),
359                        ))
360                        .child(div().absolute().inset_0().rounded(radius).bg(
361                            gpui::linear_gradient(
362                                180.0,
363                                gpui::linear_color_stop(gpui::transparent_black(), 0.0),
364                                gpui::linear_color_stop(gpui::black(), 1.0),
365                            ),
366                        )),
367                    (ColorSpace::Hsl, ColorChannel::Saturation, ColorChannel::Lightness) => {
368                        let gray = self.value.with_hsl_channels(0.0, 0.5).to_hsla();
369                        let hue = self.value.with_hsl_channels(1.0, 0.5).to_hsla();
370                        layers
371                            .bg(gpui::linear_gradient(
372                                90.0,
373                                gpui::linear_color_stop(gray, 0.0),
374                                gpui::linear_color_stop(hue, 1.0),
375                            ))
376                            .child(
377                                div()
378                                    .absolute()
379                                    .top_0()
380                                    .left_0()
381                                    .right_0()
382                                    .rounded(radius)
383                                    .h(px(f32::from(self.height) / 2.0))
384                                    .bg(gpui::linear_gradient(
385                                        180.0,
386                                        gpui::linear_color_stop(gpui::white(), 0.0),
387                                        gpui::linear_color_stop(gpui::transparent_white(), 1.0),
388                                    )),
389                            )
390                            .child(
391                                div()
392                                    .absolute()
393                                    .bottom_0()
394                                    .left_0()
395                                    .right_0()
396                                    .rounded(radius)
397                                    .h(px(f32::from(self.height) / 2.0))
398                                    .bg(gpui::linear_gradient(
399                                        180.0,
400                                        gpui::linear_color_stop(gpui::transparent_black(), 0.0),
401                                        gpui::linear_color_stop(gpui::black(), 1.0),
402                                    )),
403                            )
404                    }
405                    _ => layers.child(color_area_channel_grid(
406                        self.value,
407                        color_space,
408                        self.x_channel,
409                        self.y_channel,
410                        radius,
411                        self.width,
412                        self.height,
413                    )),
414                }
415            };
416
417        // `showDots` — the dot-grid overlay. gpui has no repeating background,
418        // so the grid is drawn as rows of small translucent dots. Dots sit on
419        // 8px cell centers, clear of the corner curve.
420        if self.show_dots {
421            const STEP: f32 = 8.0;
422            let cols = (f32::from(self.width) / STEP).floor().max(1.0) as usize;
423            let rows = (f32::from(self.height) / STEP).floor().max(1.0) as usize;
424            let mut grid = div().absolute().inset_0().flex().flex_col();
425            for r in 0..rows {
426                let mut line = div().flex().h(px(STEP));
427                for c in 0..cols {
428                    let _ = c;
429                    line = line.child(
430                        div()
431                            .w(px(STEP))
432                            .flex()
433                            .items_center()
434                            .justify_center()
435                            .child(
436                                div()
437                                    .size(px(2.))
438                                    .rounded_full()
439                                    .bg(gpui::white().alpha(0.25)),
440                            ),
441                    );
442                }
443                let _ = r;
444                grid = grid.child(line);
445            }
446            layers = layers.child(grid);
447        }
448
449        let recorder_bounds = bounds_slot.clone();
450        area = area.child(
451            gpui::canvas(
452                move |bounds: Bounds<Pixels>, _, cx| {
453                    recorder_bounds.update(cx, |slot, _| {
454                        *slot = Bounds {
455                            origin: gpui::point(
456                                f32::from(bounds.origin.x),
457                                f32::from(bounds.origin.y),
458                            ),
459                            size: gpui::size(
460                                f32::from(bounds.size.width),
461                                f32::from(bounds.size.height),
462                            ),
463                        };
464                    });
465                    bounds
466                },
467                |_, _, _, _| {},
468            )
469            .absolute()
470            .inset_0(),
471        );
472
473        area = area.child(layers);
474
475        // GPUI paints an element's border after its children. Keeping the
476        // visible border as a child lets the thumb, which is appended after
477        // this overlay, remain above the border at the edge of the area.
478        // The gradient stack remains clipped by `layers`, while the root
479        // itself stays overflow-visible so a boundary thumb can hang over it.
480        area = area.child(
481            div()
482                .absolute()
483                .inset_0()
484                .rounded(radius)
485                .border(cx.layout().border_width)
486                .border_color(border_color)
487                .shadow(vec![color_inner_shadow()]),
488        );
489
490        let is_dragging = !self.is_disabled && *dragging.read(cx);
491        let is_focused = !self.is_disabled && area_focus.is_focused(window);
492        let is_focus_visible = is_focused && util::focus_visible(cx);
493        let thumb_state = ColorAreaThumbState {
494            color: self.value,
495            is_dragging,
496            is_hovered: !self.is_disabled && *thumb_hovered.read(cx),
497            is_focused,
498            is_focus_visible,
499            is_disabled: self.is_disabled,
500        };
501        let thumb_content = self.thumb.as_ref().map(|render| render(thumb_state));
502        let thumb_motion = color_area_thumb_motion(&self.id, is_dragging, window, cx);
503        // The ring goes on the visual child, not on the 20px hover wrapper:
504        // this is the element that carries the thumb's `rounded()` and whose
505        // circle the ring has to stay concentric with. The wrapper is square
506        // and larger, so a ring on it would read as a box around the knob.
507        let thumb_visual = util::with_focus_ring_overlay(
508            div()
509                // `.color-area__thumb` is `rounded-xl`, which is circular at
510                // both the idle and dragging sizes.
511                .rounded(px(12.))
512                // `.color-area__thumb` is `border: 3px solid white`.
513                .border(px(3.))
514                .border_color(gpui::white())
515                .shadow(color_thumb_shadows())
516                .bg(self.value.to_hsla()),
517            is_focus_visible,
518            true,
519            px(12.),
520            Vec::new(),
521            cx,
522        );
523        // The stable 20px wrapper owns hover. The changing animation id lives
524        // on its listener-free visual child, so a drag transition cannot drop
525        // the interaction path or a caller-provided child.
526        let mut thumb = div()
527            .id(element_id::scoped(&self.id, "area-thumb"))
528            .absolute()
529            .left(px(
530                f32::from(self.width) * x_norm - COLOR_AREA_THUMB_DRAGGING_PX / 2.0
531            ))
532            .top(px(
533                f32::from(self.height) * (1.0 - y_norm) - COLOR_AREA_THUMB_DRAGGING_PX / 2.0
534            ))
535            .size(px(COLOR_AREA_THUMB_DRAGGING_PX))
536            .child(thumb_motion.render(thumb_visual))
537            .when_some(thumb_content, |thumb, content| thumb.child(content));
538        if !self.is_disabled {
539            let hovered = thumb_hovered;
540            thumb = thumb.on_hover(move |is_hovered, _, cx| {
541                hovered.update(cx, |value, cx| {
542                    if *value != *is_hovered {
543                        *value = *is_hovered;
544                        cx.notify();
545                    }
546                });
547            });
548        }
549        area = area.child(thumb);
550
551        if self.is_disabled {
552            return area.opacity(cx.layout().disabled_opacity);
553        }
554
555        // v3's ColorArea inherits React Aria's keyboard: the arrows move the
556        // thumb, left/right on the x channel and up/down on the y, and Page
557        // Up/Down move the y while Home/End move the x by the page step
558        // (React Aria's `useColorArea` shortcuts step by the page, not to the
559        // edge). A two-axis control that answered no key could only be moved
560        // with the pointer; the step sizes are ColorSlider's own, so the two
561        // colour controls agree.
562        let keys_value = self.value;
563        let on_change_keys = self.on_change.clone();
564        let end_keys = self.on_change_end.clone();
565        let own_keys = own.clone();
566        let (x_channel, y_channel) = (self.x_channel, self.y_channel);
567        let key_space = color_space;
568        let (x_min, x_max) = x_channel.range();
569        let (y_min, y_max) = y_channel.range();
570        // One step per unit for a wide channel (hue, an 8-bit byte), a
571        // percentage point for the normalised ones -- the same rule
572        // ColorSlider's keys use.
573        let x_step = if x_max - x_min > 2.0 { 1.0 } else { 0.01 };
574        let y_step = if y_max - y_min > 2.0 { 1.0 } else { 0.01 };
575        // A tenth of the range, never less than a step -- React Aria's page.
576        let x_page = ((x_max - x_min) / 10.0).max(x_step);
577        let y_page = ((y_max - y_min) / 10.0).max(y_step);
578        area = area
579            .key_context("ColorArea")
580            .on_key_down(move |event, window, cx| {
581                let x_now = keys_value.channel_in(x_channel, key_space);
582                let y_now = keys_value.channel_in(y_channel, key_space);
583                let (nx, ny) = match event.keystroke.key.as_str() {
584                    "left" => (x_now - x_step, y_now),
585                    "right" => (x_now + x_step, y_now),
586                    "down" => (x_now, y_now - y_step),
587                    "up" => (x_now, y_now + y_step),
588                    "pageup" => (x_now, y_now + y_page),
589                    "pagedown" => (x_now, y_now - y_page),
590                    "home" => (x_now - x_page, y_now),
591                    "end" => (x_now + x_page, y_now),
592                    _ => return,
593                };
594                let next = keys_value
595                    .with_channel_in(x_channel, key_space, nx.clamp(x_min, x_max))
596                    .with_channel_in(y_channel, key_space, ny.clamp(y_min, y_max));
597                if next == keys_value {
598                    return;
599                }
600                // Uncontrolled: move our own copy, as the pointer path does.
601                if let Some(held) = &own_keys {
602                    held.update(cx, |v, cx| {
603                        *v = next;
604                        cx.notify();
605                    });
606                }
607                if let Some(cb) = &on_change_keys {
608                    cb(&next, window, cx);
609                }
610                // A keystroke is a finished change, so `onChangeEnd` fires
611                // with it rather than waiting for a release that never comes.
612                if let Some(cb) = &end_keys {
613                    cb(&next, window, cx);
614                }
615            });
616
617        if self.on_change.is_some() || self.on_change_end.is_some() || own.is_some() {
618            let down_bounds = bounds_slot.clone();
619            let down_dragging = dragging.clone();
620            let down_focus = area_focus.clone();
621            let down_change = self.on_change.clone();
622            let down_own = own.clone();
623            let down_value = self.value;
624            let (x_channel, y_channel) = (self.x_channel, self.y_channel);
625            area = area.cursor(util::interactive_cursor(cx)).on_mouse_down(
626                gpui::MouseButton::Left,
627                move |event: &MouseDownEvent, window, cx| {
628                    if event.modifiers.alt || event.modifiers.control || event.modifiers.platform {
629                        return;
630                    }
631                    down_dragging.update(cx, |value, cx| {
632                        if !*value {
633                            *value = true;
634                            cx.notify();
635                        }
636                    });
637                    window.focus(&down_focus, cx);
638                    if let Some(next) = area_color_from_pointer(
639                        &down_bounds,
640                        event.position,
641                        down_value,
642                        color_space,
643                        x_channel,
644                        y_channel,
645                        cx,
646                    ) {
647                        if next.changed {
648                            report_color_change(next.value, &down_own, &down_change, window, cx);
649                        }
650                    }
651                },
652            );
653
654            let global_bounds = bounds_slot;
655            let global_dragging = dragging;
656            let global_change = self.on_change;
657            let global_end = self.on_change_end;
658            let global_own = own;
659            let global_value = self.value;
660            area = area.child(
661                gpui::canvas(
662                    |bounds, _, _| bounds,
663                    move |_, _, window, _| {
664                        let move_bounds = global_bounds.clone();
665                        let move_dragging = global_dragging.clone();
666                        let move_change = global_change.clone();
667                        let move_own = global_own.clone();
668                        window.on_mouse_event(move |event: &MouseMoveEvent, phase, window, cx| {
669                            if phase == gpui::DispatchPhase::Capture
670                                && event.pressed_button == Some(gpui::MouseButton::Left)
671                                && *move_dragging.read(cx)
672                            {
673                                if let Some(next) = area_color_from_pointer(
674                                    &move_bounds,
675                                    event.position,
676                                    global_value,
677                                    color_space,
678                                    x_channel,
679                                    y_channel,
680                                    cx,
681                                ) {
682                                    if next.changed {
683                                        report_color_change(
684                                            next.value,
685                                            &move_own,
686                                            &move_change,
687                                            window,
688                                            cx,
689                                        );
690                                    }
691                                }
692                            }
693                        });
694
695                        let up_bounds = global_bounds.clone();
696                        let up_dragging = global_dragging.clone();
697                        let up_end = global_end.clone();
698                        window.on_mouse_event(move |event: &MouseUpEvent, phase, window, cx| {
699                            if phase == gpui::DispatchPhase::Capture
700                                && event.button == gpui::MouseButton::Left
701                            {
702                                finish_area_drag(
703                                    &up_dragging,
704                                    &up_bounds,
705                                    event.position,
706                                    global_value,
707                                    color_space,
708                                    x_channel,
709                                    y_channel,
710                                    &up_end,
711                                    window,
712                                    cx,
713                                );
714                            }
715                        });
716                    },
717                )
718                .absolute()
719                .inset_0(),
720            );
721        }
722
723        area = util::apply_sx(area, &self.sx);
724        if !self.is_disabled {
725            area = util::record_focus_bounds(area, &area_focus, window, cx);
726        }
727        // `useColorArea` is `role: 'group'` on the area; the thumb is
728        // `role: 'presentation'` and has no node of its own.
729        area.a11y(a11y::Role::Group)
730    }
731}
732
733#[allow(clippy::too_many_arguments)] // the two axes and callback are the color-area drag state
734pub(super) fn finish_area_drag(
735    dragging: &Entity<bool>,
736    bounds: &Entity<Bounds<f32>>,
737    position: gpui::Point<Pixels>,
738    value: PickerColor,
739    color_space: ColorSpace,
740    x_channel: ColorChannel,
741    y_channel: ColorChannel,
742    on_change_end: &Option<OnColorChange>,
743    window: &mut Window,
744    cx: &mut App,
745) {
746    if !*dragging.read(cx) {
747        return;
748    }
749    dragging.update(cx, |value, cx| {
750        *value = false;
751        cx.notify();
752    });
753    if let (Some(callback), Some(next)) = (
754        on_change_end,
755        area_color_from_pointer(
756            bounds,
757            position,
758            value,
759            color_space,
760            x_channel,
761            y_channel,
762            cx,
763        ),
764    ) {
765        callback(&next.value, window, cx);
766    }
767}
768
769/// The hue-axis gradient stack: hue bands plus the other channel's overlay.
770///
771/// Each painted layer carries the area radius itself: vanilla GPUI clips
772/// `overflow_hidden()` to the rectangle (see `util::inner_fill_radius`).
773pub(super) fn color_area_hue_layers(
774    value: PickerColor,
775    color_space: ColorSpace,
776    x_channel: ColorChannel,
777    y_channel: ColorChannel,
778    radius: Pixels,
779) -> gpui::Div {
780    let hue_is_vertical = y_channel == ColorChannel::Hue;
781    let other = if x_channel == ColorChannel::Hue {
782        y_channel
783    } else {
784        x_channel
785    };
786    let other_is_vertical = y_channel == other;
787    let base = match (color_space, other) {
788        (ColorSpace::Hsl, ColorChannel::Saturation) => value
789            .with_hsl_channels(1.0, value.hsl_lightness())
790            .with_alpha(1.0),
791        (ColorSpace::Hsl, ColorChannel::Lightness) => value
792            .with_hsl_channels(value.hsl_saturation(), 0.5)
793            .with_alpha(1.0),
794        (ColorSpace::Hsb, ColorChannel::Saturation) => PickerColor::hsb(0.0, 1.0, value.brightness),
795        (ColorSpace::Hsb, ColorChannel::Brightness) => PickerColor::hsb(0.0, value.saturation, 1.0),
796        _ => PickerColor::hsb(0.0, 1.0, 1.0),
797    };
798
799    let mut layers = div().absolute().inset_0().child(hue_gradient(
800        base,
801        color_space,
802        hue_is_vertical,
803        radius,
804        0.0,
805    ));
806    layers = match other {
807        ColorChannel::Saturation => {
808            let start = base
809                .with_channel_in(other, color_space, other.range().0)
810                .to_hsla();
811            layers.child(
812                div()
813                    .absolute()
814                    .inset_0()
815                    .rounded(radius)
816                    .bg(gpui::linear_gradient(
817                        if other_is_vertical { 0.0 } else { 90.0 },
818                        gpui::linear_color_stop(start, 0.0),
819                        gpui::linear_color_stop(start.alpha(0.0), 1.0),
820                    )),
821            )
822        }
823        ColorChannel::Brightness => layers.child(div().absolute().inset_0().rounded(radius).bg(
824            gpui::linear_gradient(
825                if other_is_vertical { 0.0 } else { 90.0 },
826                gpui::linear_color_stop(gpui::black(), 0.0),
827                gpui::linear_color_stop(gpui::transparent_black(), 1.0),
828            ),
829        )),
830        ColorChannel::Lightness => layers.child(three_stop_gradient(
831            other_is_vertical,
832            gpui::black(),
833            gpui::transparent_black(),
834            gpui::white(),
835            radius,
836            0.0,
837        )),
838        _ => layers,
839    };
840    layers
841}
842
843/// Six hue bands laid across the box.
844///
845/// `inset` is the fraction of the box at each end that must hold a constant
846/// colour (0.0 for `ColorArea`, `cap / length` for `ColorSlider`). The bands
847/// travel over `[inset, 1 - inset]`, but the first and last bands are
848/// *stretched* out to the box edges and compensate with stop percentages, so
849/// the end zones are flat colour without any extra element: GPUI's shader
850/// remaps `t` by `(t - stop0) / (stop1 - stop0)` and clamps to `[0, 1]`, which
851/// holds the end colour over the stretched part. Painting a separate cap
852/// instead would break under `opacity()` (GPUI applies opacity per element,
853/// not per group) and could not carry the r10 curve anyway, because GPUI
854/// clamps a corner radius to half the element's shortest side.
855///
856/// The first and last bands own the exterior corners: vanilla GPUI clips
857/// `overflow_hidden()` to the rectangle (see `util::inner_fill_radius`), so
858/// each paints its outer pair. Interior bands stay square under their
859/// neighbours. Band 0 is the bottom sixth (vertical) or the left sixth
860/// (horizontal).
861pub(super) fn hue_gradient(
862    value: PickerColor,
863    color_space: ColorSpace,
864    vertical: bool,
865    radius: Pixels,
866    inset: f32,
867) -> gpui::Div {
868    let stops = hue_stop_colors(value, color_space);
869    let mut gradient = div().absolute().inset_0();
870    for index in 0..6 {
871        let (first, last) = (index == 0, index == 5);
872        let offset = hue_band_offset(index, vertical, inset);
873        let extent = hue_band_extent(index, inset);
874        let (from, to) = hue_band_stop_percentages(index, inset);
875        gradient = if vertical {
876            gradient.child(
877                div()
878                    .absolute()
879                    .left_0()
880                    .right_0()
881                    .top(gpui::relative(offset))
882                    .h(gpui::relative(extent))
883                    .when(first, |band| band.rounded_bl(radius).rounded_br(radius))
884                    .when(last, |band| band.rounded_tl(radius).rounded_tr(radius))
885                    .bg(gpui::linear_gradient(
886                        0.0,
887                        gpui::linear_color_stop(stops[index], from),
888                        gpui::linear_color_stop(stops[index + 1], to),
889                    )),
890            )
891        } else {
892            gradient.child(
893                div()
894                    .absolute()
895                    .top_0()
896                    .bottom_0()
897                    .left(gpui::relative(offset))
898                    .w(gpui::relative(extent))
899                    .when(first, |band| band.rounded_tl(radius).rounded_bl(radius))
900                    .when(last, |band| band.rounded_tr(radius).rounded_br(radius))
901                    .bg(gpui::linear_gradient(
902                        90.0,
903                        gpui::linear_color_stop(stops[index], from),
904                        gpui::linear_color_stop(stops[index + 1], to),
905                    )),
906            )
907        };
908    }
909    gradient
910}
911
912/// Fraction of the box spanned by hue band `index` under `inset`.
913pub(super) fn hue_band_extent(index: usize, inset: f32) -> f32 {
914    let (start, end) = hue_band_span(index, inset);
915    end - start
916}
917
918/// `[start, end]` of hue band `index` measured from the band-0 end of the box.
919fn hue_band_span(index: usize, inset: f32) -> (f32, f32) {
920    let inset = inset.clamp(0.0, 0.45);
921    let width = (1.0 - inset * 2.0) / 6.0;
922    let start = if index == 0 {
923        0.0
924    } else {
925        inset + index as f32 * width
926    };
927    let end = if index == 5 {
928        1.0
929    } else {
930        inset + (index + 1) as f32 * width
931    };
932    (start, end)
933}
934
935/// Gradient stop percentages for hue band `index`. The stretched end bands
936/// keep their travel unchanged and hold their outer colour over the inset.
937fn hue_band_stop_percentages(index: usize, inset: f32) -> (f32, f32) {
938    let inset = inset.clamp(0.0, 0.45);
939    let width = (1.0 - inset * 2.0) / 6.0;
940    let total = width + inset;
941    if total <= 0.0 {
942        return (0.0, 1.0);
943    }
944    match index {
945        0 => (inset / total, 1.0),
946        5 => (0.0, width / total),
947        _ => (0.0, 1.0),
948    }
949}
950
951/// CSS-side offset of hue band `index`: `top` when vertical (band 0 is the
952/// bottom band, so the axis is mirrored), `left` when horizontal.
953pub(super) fn hue_band_offset(index: usize, vertical: bool, inset: f32) -> f32 {
954    let (start, end) = hue_band_span(index, inset);
955    if vertical {
956        1.0 - end
957    } else {
958        start
959    }
960}
961
962pub(super) fn hue_stop_colors(value: PickerColor, color_space: ColorSpace) -> [Hsla; 7] {
963    [0.0, 60.0, 120.0, 180.0, 240.0, 300.0, 360.0].map(|hue| {
964        value
965            .with_channel_in(ColorChannel::Hue, color_space, hue)
966            .to_hsla()
967    })
968}
969
970/// Vertical distance a strip must give up at horizontal distance `d` from a
971/// rounded edge so it stays strictly inside a corner of radius `r`.
972///
973/// `r - sqrt(r^2 - (r - d)^2)`: the full radius at the very edge, zero once
974/// the strip clears the corner zone, monotone decreasing in between.
975pub(super) fn corner_arc_inset(d: f32, r: f32) -> f32 {
976    if r <= 0.0 || d >= r {
977        return 0.0;
978    }
979    let d = d.max(0.0);
980    let leg = r - d;
981    r - (r * r - leg * leg).max(0.0).sqrt()
982}
983
984/// Sampled channel grid: 64 vertical strips, each its own bottom-to-top
985/// gradient.
986///
987/// The exterior corners cannot ride on the edge strips. A strip is
988/// `width / 64` wide -- 2.5px on the default 160px area -- and GPUI clamps a
989/// corner radius to half the element's shortest side, so a strip's own radius
990/// collapses to about a pixel and all four corners render square. Instead a
991/// base strip `2 * radius` wide sits at each edge, sampled at that edge's `x`
992/// and wide enough to escape the clamp, and the 64 sampled strips paint over
993/// it. Strips inside a corner zone are pulled in vertically by
994/// `corner_arc_inset` at their outermost `x`, so they never poke past the arc;
995/// the base shows through those slivers, at most a couple of samples' worth of
996/// colour away from the strip it backs.
997pub(super) fn color_area_channel_grid(
998    value: PickerColor,
999    color_space: ColorSpace,
1000    x_channel: ColorChannel,
1001    y_channel: ColorChannel,
1002    radius: Pixels,
1003    width: Pixels,
1004    height: Pixels,
1005) -> gpui::Div {
1006    const STRIPS: usize = 64;
1007    let (x_min, x_max) = x_channel.range();
1008    let (y_min, y_max) = y_channel.range();
1009    let width_px = f32::from(width).max(1.0);
1010    let height_px = f32::from(height).max(1.0);
1011    let radius_px = f32::from(radius)
1012        .max(0.0)
1013        .min(width_px / 2.0)
1014        .min(height_px / 2.0);
1015    // `inset_px` is how far the strip is shortened at each end. Its gradient
1016    // must still map bottom-to-top over the FULL area height, so the stops
1017    // are pushed past the shortened box: the shader remaps
1018    // `t = (t - p0) / (p1 - p0)` and clamps, which lands `t = 0` at
1019    // `inset / height` and `t = 1` at `1 - inset / height`.
1020    let ramp_at = |x: f32, inset_px: f32| {
1021        let at_x = value.with_channel_in(x_channel, color_space, x_min + x * (x_max - x_min));
1022        let bottom = at_x
1023            .with_channel_in(y_channel, color_space, y_min)
1024            .to_hsla();
1025        let top = at_x
1026            .with_channel_in(y_channel, color_space, y_max)
1027            .to_hsla();
1028        let visible = (height_px - 2.0 * inset_px).max(1.0);
1029        let overshoot = inset_px / visible;
1030        gpui::linear_gradient(
1031            0.0,
1032            gpui::linear_color_stop(bottom, -overshoot),
1033            gpui::linear_color_stop(top, 1.0 + overshoot),
1034        )
1035    };
1036
1037    let base_w = px((radius_px * 2.0).min(width_px));
1038    // The base only ever shows in the corner slivers, which lie within
1039    // `radius` of the edge, so sample it at the middle of that zone rather
1040    // than at the edge itself: the colour error across the sliver halves.
1041    let base_sample = (radius_px / 2.0 / width_px).min(0.5);
1042    let mut grid = div()
1043        .absolute()
1044        .inset_0()
1045        .child(
1046            div()
1047                .absolute()
1048                .top_0()
1049                .bottom_0()
1050                .left_0()
1051                .w(base_w)
1052                .rounded_tl(radius)
1053                .rounded_bl(radius)
1054                .bg(ramp_at(base_sample, 0.0)),
1055        )
1056        .child(
1057            div()
1058                .absolute()
1059                .top_0()
1060                .bottom_0()
1061                .right_0()
1062                .w(base_w)
1063                .rounded_tr(radius)
1064                .rounded_br(radius)
1065                .bg(ramp_at(1.0 - base_sample, 0.0)),
1066        );
1067
1068    let strip_w = width_px / STRIPS as f32;
1069    for index in 0..STRIPS {
1070        let x = index as f32 / (STRIPS - 1) as f32;
1071        let left = index as f32 * strip_w;
1072        // The outermost x of the strip is its own outer edge.
1073        let distance = left.min(width_px - (left + strip_w));
1074        let inset_px = corner_arc_inset(distance, radius_px);
1075        let inset = px(inset_px);
1076        grid = grid.child(
1077            div()
1078                .absolute()
1079                .left(px(left))
1080                .w(px(strip_w))
1081                .top(inset)
1082                .bottom(inset)
1083                .bg(ramp_at(x, inset_px)),
1084        );
1085    }
1086    grid
1087}
1088
1089pub(super) struct PointerColor {
1090    pub(super) value: PickerColor,
1091    pub(super) changed: bool,
1092}
1093
1094#[allow(clippy::float_cmp)] // snapped channel values are exact state coordinates
1095pub(super) fn area_color_from_pointer(
1096    bounds: &Entity<Bounds<f32>>,
1097    position: gpui::Point<Pixels>,
1098    value: PickerColor,
1099    color_space: ColorSpace,
1100    x_channel: ColorChannel,
1101    y_channel: ColorChannel,
1102    cx: &App,
1103) -> Option<PointerColor> {
1104    let bounds = *bounds.read(cx);
1105    if bounds.size.width <= 0.0 || bounds.size.height <= 0.0 {
1106        return None;
1107    }
1108    let x = ((f32::from(position.x) - bounds.origin.x) / bounds.size.width).clamp(0.0, 1.0);
1109    let y = ((f32::from(position.y) - bounds.origin.y) / bounds.size.height).clamp(0.0, 1.0);
1110    let (x_min, x_max) = x_channel.range();
1111    let (y_min, y_max) = y_channel.range();
1112    let x_value = snap_color_channel(x_channel, x_min + x * (x_max - x_min));
1113    let y_value = snap_color_channel(y_channel, y_min + (1.0 - y) * (y_max - y_min));
1114    Some(PointerColor {
1115        changed: x_value != value.channel_in(x_channel, color_space)
1116            || y_value != value.channel_in(y_channel, color_space),
1117        value: value
1118            .with_channel_in(x_channel, color_space, x_value)
1119            .with_channel_in(y_channel, color_space, y_value),
1120    })
1121}
1122
1123pub(super) fn report_color_change(
1124    next: PickerColor,
1125    own: &Option<Entity<PickerColor>>,
1126    on_change: &Option<OnColorChange>,
1127    window: &mut Window,
1128    cx: &mut App,
1129) {
1130    if let Some(held) = own {
1131        held.update(cx, |value, cx| {
1132            *value = next;
1133            cx.notify();
1134        });
1135    }
1136    if let Some(callback) = on_change {
1137        callback(&next, window, cx);
1138    }
1139}
1140
1141pub(super) fn live_color_form_state(
1142    value: crate::form::FormValue,
1143) -> Rc<RefCell<crate::form::LiveFormFieldState>> {
1144    Rc::new(RefCell::new(crate::form::LiveFormFieldState {
1145        value,
1146        is_invalid: false,
1147        is_successful: true,
1148        focus: None,
1149        restore: None,
1150    }))
1151}
1152
1153pub(super) fn sync_color_form_state(
1154    state: &Rc<RefCell<crate::form::LiveFormFieldState>>,
1155    value: crate::form::FormValue,
1156    is_successful: bool,
1157    is_invalid: bool,
1158) {
1159    let mut state = state.borrow_mut();
1160    state.value = value;
1161    state.is_successful = is_successful;
1162    state.is_invalid = is_invalid;
1163}
1164
1165/// React Aria ColorSlider submits the channel number of a hidden range input.
1166pub(super) fn color_slider_form_value(
1167    value: PickerColor,
1168    channel: ColorChannel,
1169    space: ColorSpace,
1170) -> crate::form::FormValue {
1171    let value = value.channel_in(channel, space);
1172    let value = if matches!(
1173        channel,
1174        ColorChannel::Saturation | ColorChannel::Brightness | ColorChannel::Lightness
1175    ) {
1176        value * 100.0
1177    } else {
1178        value
1179    };
1180    crate::form::FormValue::Number(f64::from(value))
1181}
1182
1183/// React Aria ColorField submits the hex text, or the channel number when
1184/// `channel` is set (ColorChannelField is a NumberField).
1185pub(super) fn color_field_form_value(
1186    value: Option<PickerColor>,
1187    channel: Option<ColorChannel>,
1188    space: ColorSpace,
1189) -> crate::form::FormValue {
1190    let Some(value) = value else {
1191        return crate::form::FormValue::Text(SharedString::default());
1192    };
1193    match channel {
1194        None => crate::form::FormValue::Text(value.to_hex().into()),
1195        Some(channel) => {
1196            crate::form::FormValue::Number(f64::from(value.channel_in(channel, space)))
1197        }
1198    }
1199}
1200
1201pub(super) fn color_field_display_text(
1202    value: Option<PickerColor>,
1203    channel: Option<ColorChannel>,
1204    space: ColorSpace,
1205) -> String {
1206    let Some(value) = value else {
1207        return String::new();
1208    };
1209    match channel {
1210        None => value.to_hex(),
1211        Some(channel) => format_color_channel_value(value, channel, space),
1212    }
1213}
1214
1215// ---------------------------------------------------------------------------
1216
1217crate::util::impl_component_styled!(ColorArea);