Skip to main content

ravenrs_widgets/
color_picker.rs

1//! Choosing a color: a field that opens a palette, a shade square and a hue bar.
2
3use std::cell::Cell;
4use std::fmt;
5use std::rc::Rc;
6
7use ravenrs_core::{Border, Color, CursorIcon, Insets, Key, LinearGradient, Point, Quad, Rect, Role};
8use ravenrs_layout::{Align, Axis, LayoutStyle};
9use ravenrs_reactive::{Binding, State};
10use ravenrs_style::{Semantic, TextStyle};
11
12use crate::context::{BuildCx, DragPhase, NodeId, Paint, Placement, QuadPaint, Semantics};
13use crate::overlay::panel;
14use crate::{Button, ButtonSize, ButtonStyle, ColorSwatch, IconName, TextField, View, util};
15
16const PANEL_WIDTH: f32 = 248.0;
17/// The least it can show, since it fills what it is in unless given a width.
18const FIELD_MIN_WIDTH: f32 = 140.0;
19const SHADE_HEIGHT: f32 = 148.0;
20const BAR_HEIGHT: f32 = 14.0;
21const THUMB: f32 = 12.0;
22/// The colors of the hue bar, in order, a sixth of the way each.
23const HUES: [Color; 7] = [
24    Color::rgb(1.0, 0.0, 0.0),
25    Color::rgb(1.0, 1.0, 0.0),
26    Color::rgb(0.0, 1.0, 0.0),
27    Color::rgb(0.0, 1.0, 1.0),
28    Color::rgb(0.0, 0.0, 1.0),
29    Color::rgb(1.0, 0.0, 1.0),
30    Color::rgb(1.0, 0.0, 0.0),
31];
32
33/// A color as people think of it. A gray keeps its hue, so moving through one
34/// does not throw the choice back to red.
35#[derive(Clone, Copy, Debug, PartialEq)]
36pub(crate) struct Hsv {
37    /// 0 to 360.
38    pub(crate) hue: f32,
39    /// 0 to 1.
40    pub(crate) saturation: f32,
41    /// 0 to 1.
42    pub(crate) value: f32,
43    /// 0 to 1.
44    pub(crate) alpha: f32,
45}
46
47#[cfg_attr(feature = "live", ravenrs_live::expose)]
48impl Hsv {
49    fn of(color: Color) -> Self {
50        let (r, g, b) = (color.r, color.g, color.b);
51        let top = r.max(g).max(b);
52        let span = top - r.min(g).min(b);
53        let hue = if span <= f32::EPSILON {
54            0.0
55        } else if top == r {
56            60.0 * ((g - b) / span).rem_euclid(6.0)
57        } else if top == g {
58            60.0 * ((b - r) / span + 2.0)
59        } else {
60            60.0 * ((r - g) / span + 4.0)
61        };
62        let saturation = if top <= f32::EPSILON { 0.0 } else { span / top };
63        Self { hue, saturation, value: top, alpha: color.a }
64    }
65
66    pub(crate) fn color(self) -> Color {
67        let chroma = self.value * self.saturation;
68        let part = self.hue.rem_euclid(360.0) / 60.0;
69        let second = chroma * (1.0 - (part.rem_euclid(2.0) - 1.0).abs());
70        let (r, g, b) = match part as u32 {
71            0 => (chroma, second, 0.0),
72            1 => (second, chroma, 0.0),
73            2 => (0.0, chroma, second),
74            3 => (0.0, second, chroma),
75            4 => (second, 0.0, chroma),
76            _ => (chroma, 0.0, second),
77        };
78        let lift = self.value - chroma;
79        Color::rgba(r + lift, g + lift, b + lift, self.alpha)
80    }
81}
82
83/// `#RRGGBB`, or `#RRGGBBAA` when it is not opaque.
84pub(crate) fn to_hex(color: Color) -> String {
85    let byte = |channel: f32| (channel.clamp(0.0, 1.0) * 255.0).round() as u8;
86    let rgb = format!("#{:02X}{:02X}{:02X}", byte(color.r), byte(color.g), byte(color.b));
87    if byte(color.a) == 255 { rgb } else { format!("{rgb}{:02X}", byte(color.a)) }
88}
89
90/// Reads `RGB`, `RRGGBB` or `RRGGBBAA`, with or without the `#`.
91fn parse_hex(text: &str) -> Option<Color> {
92    let digits = text.trim().trim_start_matches('#');
93    if !digits.chars().all(|c| c.is_ascii_hexdigit()) {
94        return None;
95    }
96    let number = u32::from_str_radix(digits, 16).ok();
97    match digits.len() {
98        3 => number.map(|n| {
99            let spread = |d: u32| d * 0x11;
100            Color::hex(spread(n >> 8) << 16 | spread((n >> 4) & 0xF) << 8 | spread(n & 0xF))
101        }),
102        6 => number.map(Color::hex),
103        8 => number.map(Color::hex_alpha),
104        _ => None,
105    }
106}
107
108/// Picks a color: from a palette, on a shade square and a hue bar, or typed in
109/// hexadecimal. The square moves with the arrows, the bars with Left and Right.
110///
111/// ```
112/// use ravenrs_core::Color;
113/// use ravenrs_reactive::State;
114/// use ravenrs_widgets::{ColorPicker, Ui};
115///
116/// let color = State::new(Some(Color::hex(0x2563EB)));
117/// let picker = ColorPicker::new(color.binding()).alpha(true);
118/// let mut ui = Ui::new(picker, ravenrs_style::Theme::raven_dark());
119/// ui.update();
120/// ```
121#[cfg_attr(feature = "live", ravenrs_live::expose(part = "fields"))]
122pub struct ColorPicker {
123    value: Binding<Option<Color>>,
124    palette: Vec<(Color, String)>,
125    alpha: bool,
126    width: Option<f32>,
127    label: Option<String>,
128    state: util::EditorState,
129}
130
131impl fmt::Debug for ColorPicker {
132    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
133        f.debug_struct("ColorPicker").field("alpha", &self.alpha).finish_non_exhaustive()
134    }
135}
136
137#[cfg_attr(feature = "live", ravenrs_live::expose)]
138impl ColorPicker {
139    /// A field bound to a color, or to none yet.
140    pub fn new(value: Binding<Option<Color>>) -> Self {
141        Self {
142            value,
143            palette: Vec::new(),
144            alpha: false,
145            width: None,
146            label: None,
147            state: util::EditorState::default(),
148        }
149    }
150
151    /// Named colors to pick with one click.
152    pub fn palette(mut self, colors: impl IntoIterator<Item = (Color, impl Into<String>)>) -> Self {
153        self.palette = colors.into_iter().map(|(color, name)| (color, name.into())).collect();
154        self
155    }
156
157    /// Lets the color be see-through, with one more bar.
158    pub fn alpha(mut self, alpha: bool) -> Self {
159        self.alpha = alpha;
160        self
161    }
162
163    /// Width in logical pixels. 180 by default.
164    pub fn width(mut self, width: f32) -> Self {
165        self.width = Some(width);
166        self
167    }
168
169    /// The name screen readers announce. Inside a [`crate::FormField`], its label is used instead.
170    pub fn label(mut self, label: impl Into<String>) -> Self {
171        self.label = Some(label.into());
172        self
173    }
174
175    /// Turns the field off: it fades, leaves the tab order and ignores input.
176    pub fn disabled(mut self, disabled: bool) -> Self {
177        self.state.disabled = disabled;
178        self
179    }
180
181    /// Shows the color without letting it change; it can still be focused and read.
182    pub fn read_only(mut self, read_only: bool) -> Self {
183        self.state.read_only = read_only;
184        self
185    }
186}
187
188/// The choice being made: what the panel moves, and the field it writes to.
189#[derive(Clone)]
190struct Draft {
191    hsv: State<Hsv>,
192    value: Binding<Option<Color>>,
193}
194
195#[cfg_attr(feature = "live", ravenrs_live::expose)]
196impl Draft {
197    fn change(&self, f: impl FnOnce(&mut Hsv)) {
198        let mut hsv = *self.hsv.peek();
199        f(&mut hsv);
200        self.hsv.set(hsv);
201        self.value.set(Some(hsv.color()));
202    }
203}
204
205fn thumb(scene: &mut ravenrs_core::Scene, center: Point, fill: Color, ring: Color) {
206    let half = THUMB / 2.0;
207    let frame = Rect::new(center.x - half, center.y - half, THUMB, THUMB);
208    scene.push_quad(Quad::new(frame, fill).radius(half).border(2.0, ring));
209}
210
211/// Focus and drags go by view, so each control of the panel is one.
212struct ShadeSquare(Draft);
213
214impl View for ShadeSquare {
215    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
216        shade_square(cx, &self.0)
217    }
218}
219
220fn shade_square(cx: &mut BuildCx<'_>, draft: &Draft) -> NodeId {
221    let theme = *cx.theme();
222    let hsv = draft.hsv.get();
223    let ring = Color::WHITE;
224    let node = cx.container(
225        LayoutStyle { height: Some(SHADE_HEIGHT), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
226        &[],
227        Paint::Custom(Rc::new(move |f: Rect, scene| {
228            let radius = theme.radii.sm;
229            let pure = Hsv { saturation: 1.0, value: 1.0, alpha: 1.0, ..hsv }.color();
230            let across = LinearGradient {
231                start: Point::new(0.0, 0.0),
232                end: Point::new(1.0, 0.0),
233                from: Color::WHITE,
234                to: pure,
235            };
236            scene.push_quad(Quad::new(f, across).radius(radius));
237            let down = LinearGradient::vertical(Color::BLACK.with_alpha(0.0), Color::BLACK);
238            scene.push_quad(Quad::new(f, down).radius(radius));
239            let at = Point::new(f.x() + hsv.saturation * f.width(), f.y() + (1.0 - hsv.value) * f.height());
240            thumb(scene, at, Hsv { alpha: 1.0, ..hsv }.color(), ring);
241        })),
242    );
243    let name = cx.labels().color_shade.clone();
244    cx.semantics(
245        node,
246        Semantics::new(Role::Slider).name(name).value(format!(
247            "{}%, {}%",
248            (hsv.saturation * 100.0).round(),
249            (hsv.value * 100.0).round()
250        )),
251    );
252    cx.focusable(node, theme.radii.sm);
253    cx.cursor(node, CursorIcon::Pointer);
254    let dragged = draft.clone();
255    cx.on_drag(
256        node,
257        Rc::new(move |d| {
258            if d.phase == DragPhase::End {
259                return;
260            }
261            let x = ((d.position.x - d.frame.x()) / d.frame.width().max(1.0)).clamp(0.0, 1.0);
262            let y = ((d.position.y - d.frame.y()) / d.frame.height().max(1.0)).clamp(0.0, 1.0);
263            dragged.change(|hsv| {
264                hsv.saturation = x;
265                hsv.value = 1.0 - y;
266            });
267        }),
268    );
269    let keyed = draft.clone();
270    cx.on_key(
271        node,
272        Rc::new(move |key, _| {
273            let (x, y) = match key {
274                Key::ArrowLeft => (-0.02, 0.0),
275                Key::ArrowRight => (0.02, 0.0),
276                Key::ArrowUp => (0.0, 0.02),
277                Key::ArrowDown => (0.0, -0.02),
278                _ => return false,
279            };
280            keyed.change(|hsv| {
281                hsv.saturation = (hsv.saturation + x).clamp(0.0, 1.0);
282                hsv.value = (hsv.value + y).clamp(0.0, 1.0);
283            });
284            true
285        }),
286    );
287    node
288}
289
290type BarPaint = Rc<dyn Fn(Rect, &mut ravenrs_core::Scene)>;
291
292/// A bar from 0 to `max`.
293struct ColorBar {
294    name: String,
295    value: f32,
296    max: f32,
297    step: f32,
298    paint: BarPaint,
299    set: Rc<dyn Fn(f32)>,
300}
301
302impl View for ColorBar {
303    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
304        let theme = *cx.theme();
305        let (value, max, step) = (self.value, self.max, self.step);
306        let paint = self.paint.clone();
307        let node = cx.container(
308            LayoutStyle { height: Some(BAR_HEIGHT), align_self: Some(Align::Stretch), ..LayoutStyle::leaf() },
309            &[],
310            Paint::Custom(Rc::new(move |f: Rect, scene| {
311                paint(f, scene);
312                let at = Point::new(f.x() + value / max * f.width(), f.center().y);
313                thumb(scene, at, Color::TRANSPARENT, Color::WHITE);
314            })),
315        );
316        cx.semantics(
317            node,
318            Semantics::new(Role::Slider).name(self.name.clone()).numeric(
319                f64::from(value),
320                0.0,
321                f64::from(max),
322                Some(f64::from(step)),
323            ),
324        );
325        cx.focusable(node, theme.radii.full);
326        cx.cursor(node, CursorIcon::Pointer);
327        let dragged = self.set.clone();
328        cx.on_drag(
329            node,
330            Rc::new(move |d| {
331                if d.phase != DragPhase::End {
332                    dragged(d.fraction_x() * max);
333                }
334            }),
335        );
336        let set = self.set.clone();
337        cx.on_key(
338            node,
339            Rc::new(move |key, _| {
340                match key {
341                    Key::ArrowLeft | Key::ArrowDown => set((value - step).max(0.0)),
342                    Key::ArrowRight | Key::ArrowUp => set((value + step).min(max)),
343                    _ => return false,
344                }
345                true
346            }),
347        );
348        node
349    }
350}
351
352struct ColorPanel<'a> {
353    picker: &'a ColorPicker,
354    draft: Draft,
355    open: State<bool>,
356}
357
358impl View for ColorPanel<'_> {
359    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
360        let theme = *cx.theme();
361        let labels = cx.labels();
362        let draft = &self.draft;
363        let hsv = draft.hsv.get();
364        let mut rows = Vec::new();
365        if !self.picker.palette.is_empty() {
366            let current = self.picker.value.get();
367            let mut swatches = Vec::with_capacity(self.picker.palette.len());
368            for (i, (color, name)) in self.picker.palette.iter().enumerate() {
369                let (d, color) = (draft.clone(), *color);
370                let on = current.is_some_and(|c| to_hex(c) == to_hex(color));
371                swatches.push(cx.keyed_child(
372                    i as u64,
373                    &ColorSwatch::new(color, name.clone()).selected(on).on_click(move || {
374                        d.hsv.set(Hsv::of(color));
375                        d.value.set(Some(color));
376                    }),
377                ));
378            }
379            rows.push(cx.container(
380                LayoutStyle { wrap: true, ..LayoutStyle::stack(Axis::Horizontal) },
381                &swatches,
382                Paint::None,
383            ));
384        }
385        rows.push(cx.child(&ShadeSquare(draft.clone())));
386        let radius = theme.radii.full;
387        let d = draft.clone();
388        rows.push(cx.child(&ColorBar {
389            name: labels.color_hue.clone(),
390            value: hsv.hue,
391            max: 360.0,
392            step: 3.0,
393            paint: Rc::new(move |f, scene| {
394                let part = f.width() / (HUES.len() - 1) as f32;
395                for (i, pair) in HUES.windows(2).enumerate() {
396                    let piece = Rect::new(f.x() + i as f32 * part, f.y(), part + 0.5, f.height());
397                    let gradient = LinearGradient {
398                        start: Point::new(0.0, 0.0),
399                        end: Point::new(1.0, 0.0),
400                        from: pair[0],
401                        to: pair[1],
402                    };
403                    scene.push_quad(Quad::new(piece, gradient));
404                }
405            }),
406            set: Rc::new(move |value| d.change(|hsv| hsv.hue = value.min(359.9))),
407        }));
408        if self.picker.alpha {
409            let d = draft.clone();
410            let solid = Hsv { alpha: 1.0, ..hsv }.color();
411            rows.push(cx.child(&ColorBar {
412                name: labels.color_alpha.clone(),
413                value: hsv.alpha,
414                max: 1.0,
415                step: 0.02,
416                paint: Rc::new(move |f, scene| {
417                    let gradient = LinearGradient {
418                        start: Point::new(0.0, 0.0),
419                        end: Point::new(1.0, 0.0),
420                        from: solid.with_alpha(0.0),
421                        to: solid,
422                    };
423                    scene.push_quad(Quad::new(f, gradient).radius(radius));
424                }),
425                set: Rc::new(move |value| d.change(|hsv| hsv.alpha = value)),
426            }));
427        }
428        // Holds what is being typed, which is only a color once it is complete.
429        let typing: State<Option<String>> = cx.use_state(|| None);
430        let (shown, kept, d, opaque) = (typing.clone(), typing.clone(), draft.clone(), !self.picker.alpha);
431        let current = to_hex(hsv.color());
432        let hex = Binding::new(
433            move || shown.get().unwrap_or_else(|| current.clone()),
434            move |text: String| {
435                if let Some(color) = parse_hex(&text) {
436                    let color = if opaque { color.with_alpha(1.0) } else { color };
437                    d.hsv.set(Hsv::of(color));
438                    d.value.set(Some(color));
439                }
440                kept.set(Some(text));
441            },
442        );
443        let field =
444            cx.child(&TextField::new(hex).placeholder(labels.color_hex.clone()).width(116.0).compact());
445        let value = self.picker.value.clone();
446        let close = self.open.clone();
447        let none = cx.child(
448            &Button::new(labels.no_color.clone()).style(ButtonStyle::Ghost).size(ButtonSize::Small).on_click(
449                move || {
450                    value.set(None);
451                    close.set(false);
452                },
453            ),
454        );
455        rows.push(cx.container(
456            LayoutStyle { spacing: 8.0, align: Align::Center, ..LayoutStyle::stack(Axis::Horizontal) },
457            &[field, none],
458            Paint::None,
459        ));
460        let inner = cx.container(
461            LayoutStyle { spacing: 10.0, align: Align::Stretch, ..LayoutStyle::stack(Axis::Vertical) },
462            &rows,
463            Paint::None,
464        );
465        let node = panel(cx, &[inner], 12.0, PANEL_WIDTH);
466        cx.semantics(node, Semantics::new(Role::Dialog).name(labels.pick_color.clone()));
467        let closing = self.open.clone();
468        cx.on_key(
469            node,
470            Rc::new(move |key, _| {
471                let done = *key == Key::Escape;
472                if done {
473                    closing.set(false);
474                }
475                done
476            }),
477        );
478        node
479    }
480}
481
482impl View for ColorPicker {
483    fn build(&self, cx: &mut BuildCx<'_>) -> NodeId {
484        let theme = *cx.theme();
485        let open: State<bool> = cx.use_state(|| false);
486        let state = self.state;
487        let is_open = open.get() && state.accepts_input();
488        let hovered = cx.is_hovered() && state.accepts_input();
489        let current = self.value.get();
490        let start = current.unwrap_or(Color::hex(0x3B82F6));
491        let hsv: State<Hsv> = cx.use_state(move || Hsv::of(start));
492        // Something outside changed the color.
493        if let Some(color) = current
494            && to_hex(hsv.peek().color()) != to_hex(color)
495        {
496            hsv.set_if_changed(Hsv::of(color));
497        }
498        let (text, color) = match current {
499            Some(color) => (to_hex(color), theme.color(Semantic::Ink)),
500            None => (cx.labels().no_color.clone(), theme.color(Semantic::InkMuted)),
501        };
502        let edge = theme.color(Semantic::Border);
503        let sample = cx.container(
504            LayoutStyle { width: Some(20.0), height: Some(20.0), ..LayoutStyle::leaf() },
505            &[],
506            Paint::Quad(QuadPaint {
507                corner_radius: theme.radii.sm,
508                border: Some(Border { width: 1.0, color: edge }),
509                ..QuadPaint::new(state.ink(current.unwrap_or(Color::TRANSPARENT)))
510            }),
511        );
512        let label = util::label(cx, &text, TextStyle::Body, None, state.ink(color));
513        cx.semantics(label, Semantics::hidden());
514        let chevron = util::icon(cx, IconName::ChevronDown, 16.0, state.ink(theme.color(Semantic::InkMuted)));
515        let filler = cx.container(LayoutStyle { grow: 1.0, ..LayoutStyle::leaf() }, &[], Paint::None);
516        let border = if is_open {
517            Border { width: 1.5, color: theme.color(Semantic::Accent) }
518        } else {
519            Border {
520                width: 1.0,
521                color: state.ink(if hovered { edge.lerp(theme.color(Semantic::Ink), 0.25) } else { edge }),
522            }
523        };
524        let node = cx.container(
525            LayoutStyle {
526                width: self.width,
527                min_width: Some(FIELD_MIN_WIDTH),
528                height: Some(40.0),
529                padding: Insets::symmetric(0.0, 12.0),
530                spacing: 8.0,
531                align: Align::Center,
532                align_self: Some(if self.width.is_some() { Align::Fit } else { Align::Stretch }),
533                ..LayoutStyle::stack(Axis::Horizontal)
534            },
535            &[sample, label, filler, chevron],
536            Paint::Quad(QuadPaint {
537                corner_radius: theme.radii.md,
538                border: Some(border),
539                ..QuadPaint::new(state.fill(theme.color(Semantic::Control)))
540            }),
541        );
542        let mut semantics = Semantics::new(Role::ComboBox).value(text).expanded(is_open);
543        if let Some(label) = &self.label {
544            semantics = semantics.name(label.clone());
545        }
546        cx.semantics(node, state.semantics(semantics));
547        if state.disabled {
548            return node;
549        }
550        cx.focusable(node, theme.radii.md);
551        if !state.accepts_input() {
552            return node;
553        }
554        let toggle = open.clone();
555        cx.on_click(node, Rc::new(move || toggle.update(|o| *o = !*o)));
556        let opener = open.clone();
557        cx.on_key(
558            node,
559            Rc::new(move |key, _| {
560                let opens = matches!(key, Key::ArrowDown | Key::ArrowUp);
561                if opens {
562                    opener.set(true);
563                }
564                opens
565            }),
566        );
567        cx.cursor(node, CursorIcon::Pointer);
568        let was_open = cx.use_state(|| Rc::new(Cell::new(false))).get();
569        if is_open {
570            was_open.set(true);
571            let close = open.clone();
572            let body = ColorPanel {
573                picker: self,
574                draft: Draft { hsv, value: self.value.clone() },
575                open: open.clone(),
576            };
577            cx.overlay(node, Placement::Below, &body, Some(Rc::new(move || close.set(false))));
578        } else if was_open.replace(false) {
579            cx.request_focus();
580        }
581        node
582    }
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    #[test]
590    fn colors_go_to_hsv_and_back() {
591        for hex in [0xFF0000, 0x00FF00, 0x0000FF, 0x2563EB, 0xF59E0B, 0x808080, 0x000000, 0xFFFFFF] {
592            let color = Color::hex(hex);
593            assert_eq!(to_hex(Hsv::of(color).color()), to_hex(color));
594        }
595    }
596
597    #[test]
598    fn known_hues() {
599        assert_eq!(Hsv::of(Color::hex(0xFF0000)).hue, 0.0);
600        assert_eq!(Hsv::of(Color::hex(0x00FF00)).hue, 120.0);
601        assert_eq!(Hsv::of(Color::hex(0x0000FF)).hue, 240.0);
602        assert_eq!(Hsv::of(Color::hex(0x808080)).saturation, 0.0);
603    }
604
605    #[test]
606    fn hexadecimal_is_read_in_every_length() {
607        assert_eq!(parse_hex("#2563EB").map(to_hex).as_deref(), Some("#2563EB"));
608        assert_eq!(parse_hex("2563eb").map(to_hex).as_deref(), Some("#2563EB"));
609        assert_eq!(parse_hex("#F0A").map(to_hex).as_deref(), Some("#FF00AA"));
610        assert_eq!(parse_hex("#2563EB80").map(to_hex).as_deref(), Some("#2563EB80"));
611        for wrong in ["", "#12", "#12345", "#GGGGGG", "+12345", "#1234567"] {
612            assert_eq!(parse_hex(wrong), None, "{wrong}");
613        }
614    }
615}