Skip to main content

ui/color/
picker.rs

1//! [`ColorPicker`]: a saturation/value field, a hue strip, an optional alpha
2//! strip and a hex field, drawn in gpui on every platform.
3//!
4//! The picker is the body of a card, not the popover around it. It emits
5//! [`ColorPickerEvent::Changed`] on every drag step and on every hex entry
6//! that parses; [`ColorPicker::set_color`] moves it without emitting.
7//!
8//! ```ignore
9//! let picker = cx.new(|cx| ColorPicker::new(color, true, cx));
10//! cx.subscribe(&picker, |view, _, event, cx| match event {
11//!     ColorPickerEvent::Changed(color) => view.set_tint(*color, cx),
12//! })
13//! .detach();
14//! ```
15
16use std::cell::Cell;
17use std::rc::Rc;
18
19use gpui::{
20    Axis, Bounds, Context, DragMoveEvent, Entity, EntityId, EventEmitter, Hsla, MouseButton,
21    MouseDownEvent, Pixels, Point, Rgba, Subscription, Window, canvas, div, linear_color_stop,
22    linear_gradient, prelude::*, px,
23};
24use theme::{TextStyle, Theme, Typeset};
25
26use crate::input::{FieldEvent, TextField};
27use crate::{stack, widgets};
28
29const FIELD_HEIGHT: f32 = 96.0;
30const STRIP_HEIGHT: f32 = 12.0;
31const KNOB: f32 = 12.0;
32
33#[derive(Clone, Copy, Debug, PartialEq)]
34pub enum ColorPickerEvent {
35    Changed(Hsla),
36}
37
38/// Hue, saturation, value and alpha, each in `0..=1`. Hue is kept apart from
39/// the colour so a grey keeps the hue it was dragged at.
40#[derive(Clone, Copy, Debug, PartialEq)]
41struct Hsva {
42    h: f32,
43    s: f32,
44    v: f32,
45    a: f32,
46}
47
48impl Hsva {
49    fn from_hsla(color: Hsla) -> Self {
50        let Rgba { r, g, b, a } = color.to_rgb();
51        let max = r.max(g).max(b);
52        let delta = max - r.min(g).min(b);
53        let h = if delta <= f32::EPSILON {
54            color.h
55        } else if max == r {
56            ((g - b) / delta).rem_euclid(6.0) / 6.0
57        } else if max == g {
58            ((b - r) / delta + 2.0) / 6.0
59        } else {
60            ((r - g) / delta + 4.0) / 6.0
61        };
62        let s = if max <= f32::EPSILON {
63            0.0
64        } else {
65            delta / max
66        };
67        Self { h, s, v: max, a }
68    }
69
70    fn to_hsla(self) -> Hsla {
71        let Self { h, s, v, a } = self;
72        let sector = (h.rem_euclid(1.0)) * 6.0;
73        let chroma = v * s;
74        let x = chroma * (1.0 - (sector.rem_euclid(2.0) - 1.0).abs());
75        let (r, g, b) = match sector as u32 {
76            0 => (chroma, x, 0.0),
77            1 => (x, chroma, 0.0),
78            2 => (0.0, chroma, x),
79            3 => (0.0, x, chroma),
80            4 => (x, 0.0, chroma),
81            _ => (chroma, 0.0, x),
82        };
83        let m = v - chroma;
84        Rgba {
85            r: r + m,
86            g: g + m,
87            b: b + m,
88            a,
89        }
90        .into()
91    }
92}
93
94/// `#rrggbb`, or `#rrggbbaa` when `alpha` is set.
95pub fn to_hex(color: Hsla, alpha: bool) -> String {
96    let Rgba { r, g, b, a } = color.to_rgb();
97    let byte = |value: f32| (value.clamp(0.0, 1.0) * 255.0).round() as u8;
98    match alpha {
99        true => format!(
100            "#{:02x}{:02x}{:02x}{:02x}",
101            byte(r),
102            byte(g),
103            byte(b),
104            byte(a)
105        ),
106        false => format!("#{:02x}{:02x}{:02x}", byte(r), byte(g), byte(b)),
107    }
108}
109
110/// Six or eight hex digits, with or without a leading `#`.
111pub fn parse_hex(text: &str) -> Option<Hsla> {
112    let hex = text.trim();
113    let hex = hex.strip_prefix('#').unwrap_or(hex);
114    if !matches!(hex.len(), 6 | 8) || !hex.is_ascii() {
115        return None;
116    }
117    let byte = |at: usize| u8::from_str_radix(&hex[at..at + 2], 16).ok();
118    let alpha = match hex.len() {
119        8 => byte(6)?,
120        _ => 255,
121    };
122    let channel = |value: u8| value as f32 / 255.0;
123    Some(
124        Rgba {
125            r: channel(byte(0)?),
126            g: channel(byte(2)?),
127            b: channel(byte(4)?),
128            a: channel(alpha),
129        }
130        .into(),
131    )
132}
133
134#[derive(Clone, Copy, PartialEq, Eq)]
135enum Area {
136    Field,
137    Hue,
138    Alpha,
139}
140
141/// The drag payload. Carries the picker's entity so two pickers on screen do
142/// not both follow one drag.
143struct PickerDrag(EntityId, Area);
144
145pub struct ColorPicker {
146    hsva: Hsva,
147    alpha: bool,
148    hex: Entity<TextField>,
149    bounds: [Rc<Cell<Bounds<Pixels>>>; 3],
150    _hex: Subscription,
151}
152
153impl EventEmitter<ColorPickerEvent> for ColorPicker {}
154
155impl ColorPicker {
156    /// `alpha` shows the alpha strip and writes the hex with eight digits.
157    /// Without it, every emitted colour is opaque.
158    pub fn new(color: Hsla, alpha: bool, cx: &mut Context<Self>) -> Self {
159        let mut hsva = Hsva::from_hsla(color);
160        if !alpha {
161            hsva.a = 1.0;
162        }
163        let text = to_hex(hsva.to_hsla(), alpha);
164        let hex = cx.new(|cx| {
165            let mut field = TextField::new(cx).with_placeholder(match alpha {
166                true => "#rrggbbaa",
167                false => "#rrggbb",
168            });
169            field.set_content(text, cx);
170            field
171        });
172        let subscription = cx.subscribe(&hex, |this, field, event, cx| {
173            if !matches!(event, FieldEvent::Changed(_)) {
174                return;
175            }
176            // The picker's own writes come back here too, and change nothing.
177            let Some(color) = parse_hex(field.read(cx).content())
178                .filter(|color| to_hex(*color, this.alpha) != to_hex(this.color(), this.alpha))
179            else {
180                return;
181            };
182            let mut hsva = Hsva::from_hsla(color);
183            if !this.alpha {
184                hsva.a = 1.0;
185            }
186            hsva.h = match hsva.s <= f32::EPSILON {
187                true => this.hsva.h,
188                false => hsva.h,
189            };
190            this.hsva = hsva;
191            cx.emit(ColorPickerEvent::Changed(hsva.to_hsla()));
192            cx.notify();
193        });
194        Self {
195            hsva,
196            alpha,
197            hex,
198            bounds: Default::default(),
199            _hex: subscription,
200        }
201    }
202
203    pub fn color(&self) -> Hsla {
204        self.hsva.to_hsla()
205    }
206
207    /// Move to `color` without emitting. A colour equal to the current one
208    /// keeps the current hue.
209    pub fn set_color(&mut self, color: Hsla, cx: &mut Context<Self>) {
210        if to_hex(color, self.alpha) == to_hex(self.color(), self.alpha) {
211            return;
212        }
213        let mut hsva = Hsva::from_hsla(color);
214        if !self.alpha {
215            hsva.a = 1.0;
216        }
217        self.hsva = hsva;
218        self.sync_hex(cx);
219        cx.notify();
220    }
221
222    fn sync_hex(&mut self, cx: &mut Context<Self>) {
223        let text = to_hex(self.color(), self.alpha);
224        self.hex.update(cx, |field, cx| field.set_content(text, cx));
225    }
226
227    fn point(&mut self, area: Area, position: Point<Pixels>, cx: &mut Context<Self>) {
228        let mut bounds = self.bounds[area as usize].get();
229        if area != Area::Field {
230            // A strip's knob travels inside it, so its centre runs half a knob
231            // in from either end.
232            bounds.origin.x += px(KNOB / 2.0);
233            bounds.size.width -= px(KNOB);
234        }
235        let x = widgets::axis_fraction(position, bounds, Axis::Horizontal, 0.0);
236        match area {
237            Area::Field => {
238                self.hsva.s = x;
239                self.hsva.v = 1.0 - widgets::axis_fraction(position, bounds, Axis::Vertical, 0.0);
240            }
241            Area::Hue => self.hsva.h = x,
242            Area::Alpha => self.hsva.a = x,
243        }
244        self.sync_hex(cx);
245        cx.emit(ColorPickerEvent::Changed(self.color()));
246        cx.notify();
247    }
248
249    /// One draggable area: records its bounds each paint, jumps on press and
250    /// follows the drag.
251    fn area(
252        &self,
253        area: Area,
254        body: gpui::Div,
255        cx: &mut Context<Self>,
256    ) -> gpui::Stateful<gpui::Div> {
257        let entity = cx.entity_id();
258        let bounds = self.bounds[area as usize].clone();
259        body.id(("color-picker", area as usize))
260            .relative()
261            .cursor_pointer()
262            .child(
263                canvas(move |rect, _, _| bounds.set(rect), |_, _, _, _| {})
264                    .absolute()
265                    .size_full(),
266            )
267            .on_mouse_down(
268                MouseButton::Left,
269                cx.listener(move |this, event: &MouseDownEvent, _, cx| {
270                    this.point(area, event.position, cx)
271                }),
272            )
273            .on_drag(PickerDrag(entity, area), |_, _, _, cx| {
274                cx.new(|_| gpui::Empty)
275            })
276            .on_drag_move(
277                cx.listener(move |this, event: &DragMoveEvent<PickerDrag>, _, cx| {
278                    let PickerDrag(owner, dragged) = *event.drag(cx);
279                    if owner == entity && dragged == area {
280                        this.point(area, event.event.position, cx);
281                    }
282                }),
283            )
284    }
285}
286
287fn knob_face() -> gpui::Div {
288    div()
289        .absolute()
290        .size(px(KNOB))
291        .rounded_full()
292        .border_2()
293        .border_color(gpui::white())
294        .shadow_sm()
295}
296
297/// The field's knob, centred on the point it marks.
298fn field_knob(left: f32, top: f32) -> gpui::Div {
299    knob_face()
300        .left(gpui::relative(left))
301        .top(gpui::relative(top))
302        .ml(px(-KNOB / 2.0))
303        .mt(px(-KNOB / 2.0))
304}
305
306/// A strip's knob, kept inside the strip at either end.
307fn strip_knob(at: f32) -> gpui::Div {
308    div()
309        .absolute()
310        .top_0()
311        .bottom_0()
312        .left_0()
313        .right(px(KNOB))
314        .child(knob_face().top_0().left(gpui::relative(at)))
315}
316
317fn strip() -> gpui::Div {
318    div().w_full().h(px(STRIP_HEIGHT)).rounded_full().flex()
319}
320
321impl Render for ColorPicker {
322    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
323        let theme = Theme::of(cx).clone();
324        let Hsva { h, s, v, a } = self.hsva;
325        let pure = Hsva {
326            h,
327            s: 1.0,
328            v: 1.0,
329            a: 1.0,
330        }
331        .to_hsla();
332        let opaque = Hsva {
333            a: 1.0,
334            ..self.hsva
335        }
336        .to_hsla();
337
338        // Square: rounded layers each antialias their own corner, and the
339        // lower ones show through the black at the bottom.
340        let field = div()
341            .w_full()
342            .h(px(FIELD_HEIGHT))
343            .bg(linear_gradient(
344                90.0,
345                linear_color_stop(gpui::white(), 0.0),
346                linear_color_stop(pure, 1.0),
347            ))
348            .child(div().absolute().size_full().bg(linear_gradient(
349                180.0,
350                linear_color_stop(gpui::black().opacity(0.0), 0.0),
351                linear_color_stop(gpui::black(), 1.0),
352            )))
353            .child(field_knob(s, 1.0 - v));
354
355        // gpui gradients take two stops, so the hue runs as six segments.
356        let hue_at = |at: f32| {
357            Hsva {
358                h: at,
359                s: 1.0,
360                v: 1.0,
361                a: 1.0,
362            }
363            .to_hsla()
364        };
365        let hue = strip()
366            .children((0..6).map(|segment| {
367                let from = segment as f32 / 6.0;
368                div()
369                    .flex_1()
370                    .h_full()
371                    .when(segment == 0, |div| div.rounded_l_full())
372                    .when(segment == 5, |div| div.rounded_r_full())
373                    .bg(linear_gradient(
374                        90.0,
375                        linear_color_stop(hue_at(from), 0.0),
376                        linear_color_stop(hue_at(from + 1.0 / 6.0), 1.0),
377                    ))
378            }))
379            .child(strip_knob(h));
380
381        let alpha = self.alpha.then(|| {
382            let strip = strip()
383                .bg(theme.ink(0.12))
384                .child(
385                    div()
386                        .absolute()
387                        .size_full()
388                        .rounded_full()
389                        .bg(linear_gradient(
390                            90.0,
391                            linear_color_stop(opaque.opacity(0.0), 0.0),
392                            linear_color_stop(opaque, 1.0),
393                        )),
394                )
395                .child(strip_knob(a));
396            self.area(Area::Alpha, strip, cx)
397        });
398
399        stack::column()
400            .w_full()
401            .gap(px(8.0))
402            .child(self.area(Area::Field, field, cx))
403            .child(self.area(Area::Hue, hue, cx))
404            .children(alpha)
405            .child(
406                stack::row()
407                    .gap(px(8.0))
408                    .items_center()
409                    .child(
410                        div()
411                            .flex_none()
412                            .size(px(20.0))
413                            .rounded_full()
414                            .border_1()
415                            .border_color(theme.ink(0.25))
416                            .bg(self.color()),
417                    )
418                    .child(
419                        div()
420                            .flex_1()
421                            .min_w_0()
422                            .text_style(TextStyle::Callout)
423                            .font_family(theme.font_mono.clone())
424                            .child(self.hex.clone()),
425                    ),
426            )
427    }
428}