Skip to main content

elegance/
color_picker.rs

1//! Color picker — a swatch-button trigger that opens a themed popover.
2//!
3//! Bind to a [`Color32`] and drop it into any layout. The trigger renders
4//! as a small swatch plus the current hex value; clicking opens a popover
5//! containing any combination of:
6//!
7//! * a curated palette grid,
8//! * a recents row that auto-tracks the user's last picks,
9//! * a continuous saturation/value plane plus hue slider,
10//! * an alpha slider, and
11//! * a hex input.
12//!
13//! ```no_run
14//! # use elegance::ColorPicker;
15//! # use egui::Color32;
16//! # egui::__run_test_ui(|ui| {
17//! let mut accent = Color32::from_rgb(0x38, 0xbd, 0xf8);
18//! ui.add(
19//!     ColorPicker::new("brand_accent", &mut accent)
20//!         .palette(ColorPicker::default_palette())
21//!         .continuous(true),
22//! );
23//! # });
24//! ```
25
26use std::hash::Hash;
27
28use egui::{
29    ecolor::{Hsva, HsvaGamma},
30    epaint::Mesh,
31    lerp, pos2, vec2, Color32, CornerRadius, FontSelection, Id, Pos2, Rect, Response, Sense, Shape,
32    Stroke, StrokeKind, TextEdit, Ui, Vec2, Widget, WidgetInfo, WidgetText, WidgetType,
33};
34
35use crate::popover::{Popover, PopoverSide};
36use crate::theme::{with_alpha, Theme};
37
38const HEX_BUF_SUFFIX: &str = "color_picker::hex_buf";
39const HSV_CACHE_SUFFIX: &str = "color_picker::hsv_cache";
40const RECENTS_SUFFIX: &str = "color_picker::recents";
41
42/// A click-to-open color picker bound to a [`Color32`].
43///
44/// The widget paints a compact swatch-and-hex trigger; the picker UI lives
45/// in a [`Popover`](crate::Popover). Configure which sub-controls the
46/// popover shows via the builder. By default the popover contains a
47/// continuous picker (saturation/value plane plus hue slider), an alpha
48/// slider, and a hex input.
49#[must_use = "Add with `ui.add(...)`."]
50pub struct ColorPicker<'a> {
51    id_salt: Id,
52    color: &'a mut Color32,
53    label: Option<WidgetText>,
54    palette: Vec<Color32>,
55    palette_columns: usize,
56    show_continuous: bool,
57    show_alpha: bool,
58    show_hex_input: bool,
59    show_hex_label: bool,
60    show_recents: bool,
61    recents_max: usize,
62    side: PopoverSide,
63}
64
65impl<'a> std::fmt::Debug for ColorPicker<'a> {
66    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67        f.debug_struct("ColorPicker")
68            .field("id_salt", &self.id_salt)
69            .field("color", &*self.color)
70            .field("palette_len", &self.palette.len())
71            .field("palette_columns", &self.palette_columns)
72            .field("show_continuous", &self.show_continuous)
73            .field("show_alpha", &self.show_alpha)
74            .field("show_hex_input", &self.show_hex_input)
75            .field("show_hex_label", &self.show_hex_label)
76            .field("show_recents", &self.show_recents)
77            .field("recents_max", &self.recents_max)
78            .field("side", &self.side)
79            .finish()
80    }
81}
82
83impl<'a> ColorPicker<'a> {
84    /// Create a color picker keyed by `id_salt` and bound to `color`.
85    /// Defaults: continuous picker on, alpha slider on, hex input on,
86    /// recents tracked, opens below the trigger.
87    pub fn new(id_salt: impl Hash, color: &'a mut Color32) -> Self {
88        Self {
89            id_salt: Id::new(id_salt),
90            color,
91            label: None,
92            palette: Vec::new(),
93            palette_columns: 10,
94            show_continuous: true,
95            show_alpha: true,
96            show_hex_input: true,
97            show_hex_label: true,
98            show_recents: true,
99            recents_max: 10,
100            side: PopoverSide::Bottom,
101        }
102    }
103
104    /// Show a label above the trigger.
105    #[inline]
106    pub fn label(mut self, label: impl Into<WidgetText>) -> Self {
107        self.label = Some(label.into());
108        self
109    }
110
111    /// Supply a curated palette grid above the recents row. When unset
112    /// (the default) no palette is shown. Use [`ColorPicker::default_palette`]
113    /// for a 30-swatch starter palette inspired by Tailwind.
114    pub fn palette(mut self, palette: impl IntoIterator<Item = Color32>) -> Self {
115        self.palette = palette.into_iter().collect();
116        self
117    }
118
119    /// Number of columns in the palette grid. Default: 10.
120    #[inline]
121    pub fn palette_columns(mut self, n: usize) -> Self {
122        self.palette_columns = n.max(1);
123        self
124    }
125
126    /// Toggle the continuous saturation/value plane and hue slider. Default: on.
127    #[inline]
128    pub fn continuous(mut self, on: bool) -> Self {
129        self.show_continuous = on;
130        self
131    }
132
133    /// Toggle the alpha slider. Default: on. Disable for opaque-only colors.
134    #[inline]
135    pub fn alpha(mut self, on: bool) -> Self {
136        self.show_alpha = on;
137        self
138    }
139
140    /// Toggle the hex input row inside the popover. Default: on.
141    #[inline]
142    pub fn hex_input(mut self, on: bool) -> Self {
143        self.show_hex_input = on;
144        self
145    }
146
147    /// Show or hide the hex string next to the swatch on the trigger
148    /// button. Default: shown.
149    #[inline]
150    pub fn hex_label(mut self, on: bool) -> Self {
151        self.show_hex_label = on;
152        self
153    }
154
155    /// Toggle the recents row. The recent picks are persisted in egui
156    /// context memory keyed by the picker's `id_salt`. Default: on.
157    #[inline]
158    pub fn recents(mut self, on: bool) -> Self {
159        self.show_recents = on;
160        self
161    }
162
163    /// Maximum number of recent picks remembered. Default: 10.
164    #[inline]
165    pub fn recents_max(mut self, n: usize) -> Self {
166        self.recents_max = n.max(1);
167        self
168    }
169
170    /// Which side of the trigger the popover opens on. Default: below.
171    #[inline]
172    pub fn side(mut self, side: PopoverSide) -> Self {
173        self.side = side;
174        self
175    }
176
177    /// A 30-swatch curated palette: a row of neutrals, a row of cool
178    /// accents, and a row of warm accents. Pass to [`ColorPicker::palette`].
179    pub fn default_palette() -> Vec<Color32> {
180        vec![
181            // Neutrals.
182            Color32::from_rgb(0xe2, 0xe8, 0xf0),
183            Color32::from_rgb(0x94, 0xa3, 0xb8),
184            Color32::from_rgb(0x64, 0x74, 0x8b),
185            Color32::from_rgb(0x47, 0x55, 0x69),
186            Color32::from_rgb(0x33, 0x41, 0x55),
187            Color32::from_rgb(0x1e, 0x29, 0x3b),
188            Color32::from_rgb(0x0f, 0x17, 0x2a),
189            Color32::from_rgb(0x0b, 0x11, 0x20),
190            Color32::from_rgb(0x11, 0x1a, 0x2e),
191            Color32::from_rgb(0x18, 0x24, 0x38),
192            // Cools.
193            Color32::from_rgb(0x38, 0xbd, 0xf8),
194            Color32::from_rgb(0x0e, 0xa5, 0xe9),
195            Color32::from_rgb(0x25, 0x63, 0xeb),
196            Color32::from_rgb(0x63, 0x66, 0xf1),
197            Color32::from_rgb(0xc0, 0x84, 0xfc),
198            Color32::from_rgb(0xa8, 0x55, 0xf7),
199            Color32::from_rgb(0xf4, 0x72, 0xb6),
200            Color32::from_rgb(0xfb, 0x71, 0x85),
201            Color32::from_rgb(0xf8, 0x71, 0x71),
202            Color32::from_rgb(0xef, 0x44, 0x44),
203            // Warms / greens.
204            Color32::from_rgb(0xf5, 0x9e, 0x0b),
205            Color32::from_rgb(0xfb, 0xbf, 0x24),
206            Color32::from_rgb(0xfa, 0xcc, 0x15),
207            Color32::from_rgb(0xd9, 0x77, 0x06),
208            Color32::from_rgb(0xa3, 0xe6, 0x35),
209            Color32::from_rgb(0x86, 0xef, 0xac),
210            Color32::from_rgb(0x4a, 0xde, 0x80),
211            Color32::from_rgb(0x22, 0xc5, 0x5e),
212            Color32::from_rgb(0x14, 0xb8, 0xa6),
213            Color32::from_rgb(0x22, 0xd3, 0xee),
214        ]
215    }
216}
217
218impl<'a> Widget for ColorPicker<'a> {
219    fn ui(self, ui: &mut Ui) -> Response {
220        let theme = Theme::current(ui.ctx());
221        let p = &theme.palette;
222        let t = &theme.typography;
223
224        let label = self.label.clone();
225        let id_salt = self.id_salt;
226        let side = self.side;
227        let show_palette = !self.palette.is_empty();
228        let show_recents = self.show_recents;
229        let show_continuous = self.show_continuous;
230        let show_alpha = self.show_alpha;
231        let show_hex_input = self.show_hex_input;
232        let palette = self.palette.clone();
233        let palette_columns = self.palette_columns;
234        let recents_max = self.recents_max;
235
236        ui.vertical(|ui| {
237            if let Some(l) = &label {
238                let rich = egui::RichText::new(l.text())
239                    .color(p.text_muted)
240                    .size(t.label);
241                ui.add(egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend));
242                ui.add_space(2.0);
243            }
244
245            let mut response = paint_trigger(ui, &theme, id_salt, *self.color, self.show_hex_label);
246
247            // `discrete_pick` is set when the user makes a deliberate choice
248            // (palette swatch, recents swatch, hex committed). `continuous_pick`
249            // is set while a continuous control (SV plane / hue / alpha) is
250            // being dragged. `continuous_committed` is set on the frame the
251            // user releases the pointer on a continuous control — a
252            // pointer-up after a click *or* a drag. Recents are pushed on
253            // discrete picks and on continuous commits, but not on each
254            // intermediate frame of a drag — otherwise a single SV-plane
255            // sweep would carpet-bomb the recents row.
256            let mut discrete_pick: Option<Color32> = None;
257            let mut continuous_pick: Option<Color32> = None;
258            let mut continuous_committed = false;
259            Popover::new(("elegance::color_picker", id_salt))
260                .side(side)
261                .arrow(false)
262                .min_width(248.0)
263                .show(&response, |ui| {
264                    ui.spacing_mut().item_spacing = vec2(0.0, 8.0);
265
266                    let cur = *self.color;
267                    let mut hsv = current_hsv(ui.ctx(), id_salt, cur);
268
269                    if show_palette {
270                        ui.add(small_label(&theme, "Theme palette"));
271                        if let Some(picked) =
272                            paint_palette_grid(ui, &theme, cur, &palette, palette_columns)
273                        {
274                            discrete_pick = Some(picked);
275                        }
276                    }
277
278                    if show_recents {
279                        ui.add(small_label(&theme, "Recent"));
280                        let recents: Vec<Color32> = ui
281                            .ctx()
282                            .data(|d| d.get_temp(recents_id(id_salt)))
283                            .unwrap_or_default();
284                        if let Some(picked) = paint_recents_row(
285                            ui,
286                            &theme,
287                            cur,
288                            &recents,
289                            palette_columns,
290                            recents_max,
291                        ) {
292                            discrete_pick = Some(picked);
293                        }
294                    }
295
296                    if show_continuous {
297                        let (changed, ended) = paint_sv_plane(ui, &theme, &mut hsv);
298                        if changed {
299                            continuous_pick = Some(Color32::from(hsv));
300                        }
301                        continuous_committed |= ended;
302                        let (changed, ended) = paint_hue_strip(ui, &theme, &mut hsv);
303                        if changed {
304                            continuous_pick = Some(Color32::from(hsv));
305                        }
306                        continuous_committed |= ended;
307                    }
308
309                    if show_alpha {
310                        let (changed, ended) = paint_alpha_slider(ui, &theme, &mut hsv);
311                        if changed {
312                            continuous_pick = Some(Color32::from(hsv));
313                        }
314                        continuous_committed |= ended;
315                    }
316
317                    if show_hex_input {
318                        if let Some(picked) = paint_hex_input(ui, &theme, id_salt, cur) {
319                            discrete_pick = Some(picked);
320                        }
321                    }
322
323                    if let Some(next) = discrete_pick.or(continuous_pick) {
324                        // For discrete picks the chosen color is authoritative;
325                        // re-derive HSV so the cache matches. For continuous
326                        // picks the mutated HSV is authoritative; preserve it
327                        // so dragging value to zero doesn't collapse the hue.
328                        if discrete_pick.is_some() {
329                            hsv = HsvaGamma::from(next);
330                        }
331                        set_hsv(ui.ctx(), id_salt, hsv);
332                    }
333                });
334
335            let next_color = discrete_pick.or(continuous_pick);
336            if let Some(picked) = next_color {
337                if picked != *self.color {
338                    *self.color = picked;
339                    response.mark_changed();
340                }
341            }
342            // Push to recents on a deliberate commit: a discrete pick this
343            // frame, or the moment a continuous control releases (after a
344            // click or a drag). The current bound color is the right value
345            // to record either way.
346            if discrete_pick.is_some() || continuous_committed {
347                push_recent(ui.ctx(), id_salt, *self.color, recents_max);
348            }
349
350            let label_text = label
351                .as_ref()
352                .map(|l| l.text().to_string())
353                .unwrap_or_else(|| "Color".to_string());
354            response
355                .widget_info(|| WidgetInfo::labeled(WidgetType::ColorButton, true, &label_text));
356            response
357        })
358        .inner
359    }
360}
361
362// --- trigger ---------------------------------------------------------------
363
364fn paint_trigger(
365    ui: &mut Ui,
366    theme: &Theme,
367    id_salt: Id,
368    color: Color32,
369    show_hex_label: bool,
370) -> Response {
371    let p = &theme.palette;
372    let t = &theme.typography;
373
374    let pad_outer = vec2(5.0, 5.0);
375    let swatch_size = 22.0;
376    let inner_gap = 8.0;
377
378    let hex_text = format_hex(color);
379    let galley = if show_hex_label {
380        Some(crate::theme::placeholder_galley(
381            ui,
382            &hex_text,
383            t.small,
384            false,
385            f32::INFINITY,
386        ))
387    } else {
388        None
389    };
390
391    let hex_w = galley.as_ref().map(|g| g.size().x).unwrap_or(0.0);
392    let hex_h = galley.as_ref().map(|g| g.size().y).unwrap_or(0.0);
393    let content_w = swatch_size
394        + galley
395            .as_ref()
396            .map(|_| inner_gap + hex_w + 5.0)
397            .unwrap_or(0.0);
398    let content_h = swatch_size.max(hex_h);
399
400    let desired = vec2(content_w + 2.0 * pad_outer.x, content_h + 2.0 * pad_outer.y);
401
402    let id = id_salt.with("trigger");
403    let (rect, _) = ui.allocate_exact_size(desired, Sense::hover());
404    let response = ui.interact(rect, id, Sense::click());
405
406    if ui.is_rect_visible(rect) {
407        let painter = ui.painter();
408        let radius = CornerRadius::same(theme.control_radius as u8);
409        let stroke_color = if response.has_focus() {
410            with_alpha(p.sky, 200)
411        } else if response.hovered() {
412            p.text_muted
413        } else {
414            p.border
415        };
416        painter.rect(
417            rect,
418            radius,
419            p.input_bg,
420            Stroke::new(1.0, stroke_color),
421            StrokeKind::Inside,
422        );
423
424        let swatch_rect = Rect::from_min_size(
425            pos2(
426                rect.min.x + pad_outer.x,
427                rect.center().y - swatch_size * 0.5,
428            ),
429            Vec2::splat(swatch_size),
430        );
431        paint_swatch(painter, swatch_rect, color, 4, p.is_dark, p.input_bg);
432
433        if let Some(g) = galley {
434            let text_x = swatch_rect.max.x + inner_gap;
435            let text_y = rect.center().y - g.size().y * 0.5;
436            painter.galley(pos2(text_x, text_y), g, p.text);
437        }
438    }
439
440    response
441}
442
443// --- palette grid ----------------------------------------------------------
444
445fn paint_palette_grid(
446    ui: &mut Ui,
447    theme: &Theme,
448    current: Color32,
449    palette: &[Color32],
450    columns: usize,
451) -> Option<Color32> {
452    let p = &theme.palette;
453    let gap = 5.0;
454    let avail = ui.available_width();
455    let cols = columns.max(1);
456    let cell = ((avail - gap * (cols - 1) as f32) / cols as f32).max(8.0);
457    let rows = palette.len().div_ceil(cols);
458    let total_h = rows as f32 * cell + (rows.saturating_sub(1)) as f32 * gap;
459    let (rect, _) = ui.allocate_exact_size(vec2(avail, total_h), Sense::hover());
460
461    let mut picked = None;
462    for (i, color) in palette.iter().copied().enumerate() {
463        let row = i / cols;
464        let col = i % cols;
465        let x = rect.min.x + col as f32 * (cell + gap);
466        let y = rect.min.y + row as f32 * (cell + gap);
467        let cell_rect = Rect::from_min_size(pos2(x, y), Vec2::splat(cell));
468        let id = ui
469            .id()
470            .with(("color_picker_palette", i, color.r(), color.g(), color.b()));
471        let resp = ui.interact(cell_rect, id, Sense::click());
472        let selected = color == current;
473        paint_palette_swatch(ui, cell_rect, color, selected, &resp, p.is_dark, theme);
474        if resp.clicked() {
475            picked = Some(color);
476        }
477    }
478    picked
479}
480
481fn paint_recents_row(
482    ui: &mut Ui,
483    theme: &Theme,
484    current: Color32,
485    recents: &[Color32],
486    columns: usize,
487    max: usize,
488) -> Option<Color32> {
489    let p = &theme.palette;
490    let cols = columns.max(1).max(max);
491    let gap = 5.0;
492    let avail = ui.available_width();
493    let cell = ((avail - gap * (cols - 1) as f32) / cols as f32).max(8.0);
494    let total_h = cell;
495    let (rect, _) = ui.allocate_exact_size(vec2(avail, total_h), Sense::hover());
496
497    let mut picked = None;
498    for col in 0..cols {
499        let x = rect.min.x + col as f32 * (cell + gap);
500        let y = rect.min.y;
501        let cell_rect = Rect::from_min_size(pos2(x, y), Vec2::splat(cell));
502        if let Some(color) = recents.get(col).copied() {
503            let id = ui.id().with(("color_picker_recents", col));
504            let resp = ui.interact(cell_rect, id, Sense::click());
505            let selected = color == current;
506            paint_palette_swatch(ui, cell_rect, color, selected, &resp, p.is_dark, theme);
507            if resp.clicked() {
508                picked = Some(color);
509            }
510        } else {
511            paint_recents_empty(ui, cell_rect, theme);
512        }
513    }
514    picked
515}
516
517fn paint_palette_swatch(
518    ui: &Ui,
519    rect: Rect,
520    color: Color32,
521    selected: bool,
522    resp: &Response,
523    is_dark: bool,
524    theme: &Theme,
525) {
526    let painter = ui.painter();
527    let radius_n: u8 = 4;
528    let radius = CornerRadius::same(radius_n);
529    if color.is_opaque() {
530        painter.rect_filled(rect, radius, color);
531    } else {
532        paint_checkers(painter, rect, radius);
533        painter.rect_filled(rect, radius, color);
534        paint_rounded_corner_mask(painter, rect, radius_n as f32, theme.palette.card);
535    }
536    let inset_color = if is_dark {
537        Color32::from_rgba_unmultiplied(15, 23, 42, 130)
538    } else {
539        Color32::from_rgba_unmultiplied(0, 0, 0, 60)
540    };
541    painter.rect_stroke(
542        rect,
543        radius,
544        Stroke::new(1.0, inset_color),
545        StrokeKind::Inside,
546    );
547
548    if selected {
549        let outer = rect.expand(2.0);
550        let painter = ui.painter();
551        painter.rect_stroke(
552            outer,
553            CornerRadius::same(5),
554            Stroke::new(2.0, theme.palette.sky),
555            StrokeKind::Outside,
556        );
557    } else if resp.hovered() {
558        let outer = rect.expand(1.0);
559        ui.painter().rect_stroke(
560            outer,
561            CornerRadius::same(5),
562            Stroke::new(1.0, with_alpha(theme.palette.text, 110)),
563            StrokeKind::Outside,
564        );
565    }
566}
567
568fn paint_recents_empty(ui: &Ui, rect: Rect, theme: &Theme) {
569    let p = &theme.palette;
570    let painter = ui.painter();
571    let radius = CornerRadius::same(4);
572    painter.rect_filled(rect, radius, p.input_bg);
573    paint_dashed_rect(
574        painter,
575        rect,
576        radius,
577        with_alpha(p.text_faint, 160),
578        1.0,
579        3.0,
580        3.0,
581    );
582}
583
584fn paint_dashed_rect(
585    painter: &egui::Painter,
586    rect: Rect,
587    _radius: CornerRadius,
588    color: Color32,
589    width: f32,
590    dash: f32,
591    gap: f32,
592) {
593    let stroke = Stroke::new(width, color);
594    let segments = |from: Pos2, to: Pos2| -> Vec<[Pos2; 2]> {
595        let dx = to.x - from.x;
596        let dy = to.y - from.y;
597        let len = (dx * dx + dy * dy).sqrt();
598        if len <= 0.0 {
599            return Vec::new();
600        }
601        let step = dash + gap;
602        let n = (len / step).floor() as usize;
603        let mut out = Vec::with_capacity(n + 1);
604        let mut t = 0.0_f32;
605        while t < len {
606            let t_end = (t + dash).min(len);
607            let a = pos2(from.x + dx * (t / len), from.y + dy * (t / len));
608            let b = pos2(from.x + dx * (t_end / len), from.y + dy * (t_end / len));
609            out.push([a, b]);
610            t += step;
611        }
612        out
613    };
614    for seg in segments(rect.left_top(), rect.right_top()) {
615        painter.line_segment(seg, stroke);
616    }
617    for seg in segments(rect.right_top(), rect.right_bottom()) {
618        painter.line_segment(seg, stroke);
619    }
620    for seg in segments(rect.right_bottom(), rect.left_bottom()) {
621        painter.line_segment(seg, stroke);
622    }
623    for seg in segments(rect.left_bottom(), rect.left_top()) {
624        painter.line_segment(seg, stroke);
625    }
626}
627
628// --- continuous picker -----------------------------------------------------
629
630fn paint_sv_plane(ui: &mut Ui, theme: &Theme, hsv: &mut HsvaGamma) -> (bool, bool) {
631    let p = &theme.palette;
632    let avail = ui.available_width();
633    let height = 150.0;
634    let (rect, response) = ui.allocate_exact_size(vec2(avail, height), Sense::click_and_drag());
635    let mut changed = false;
636    let committed = response.drag_stopped() || response.clicked();
637
638    if let Some(pos) = response.interact_pointer_pos() {
639        if response.is_pointer_button_down_on() {
640            let s = ((pos.x - rect.min.x) / rect.width()).clamp(0.0, 1.0);
641            let v = 1.0 - ((pos.y - rect.min.y) / rect.height()).clamp(0.0, 1.0);
642            hsv.s = s;
643            hsv.v = v;
644            changed = true;
645        }
646    }
647
648    if ui.is_rect_visible(rect) {
649        let painter = ui.painter();
650        let radius = CornerRadius::same(6);
651
652        // Build a NxN mesh of hsv-sampled colors.
653        let n: usize = 24;
654        let mut mesh = Mesh::default();
655        for yi in 0..=n {
656            for xi in 0..=n {
657                let s = xi as f32 / n as f32;
658                let v = 1.0 - yi as f32 / n as f32;
659                let c: Color32 = HsvaGamma {
660                    h: hsv.h,
661                    s,
662                    v,
663                    a: 1.0,
664                }
665                .into();
666                let x = lerp(rect.left()..=rect.right(), s);
667                let y = lerp(rect.top()..=rect.bottom(), 1.0 - v);
668                mesh.colored_vertex(pos2(x, y), c);
669            }
670        }
671        let stride = (n + 1) as u32;
672        for yi in 0..n {
673            for xi in 0..n {
674                let i = (yi * (n + 1) + xi) as u32;
675                mesh.add_triangle(i, i + 1, i + stride);
676                mesh.add_triangle(i + 1, i + stride, i + stride + 1);
677            }
678        }
679        painter.add(Shape::mesh(mesh));
680
681        // Cover the rectangular mesh's corner overflow with the popover
682        // surface so the rounded shape reads cleanly.
683        paint_rounded_corner_mask(painter, rect, 6.0, p.card);
684        painter.rect_stroke(rect, radius, Stroke::new(1.0, p.border), StrokeKind::Inside);
685
686        // Reticle.
687        let cx = lerp(rect.left()..=rect.right(), hsv.s);
688        let cy = lerp(rect.top()..=rect.bottom(), 1.0 - hsv.v);
689        let center = pos2(cx, cy);
690        ui.painter().circle(
691            center,
692            6.0,
693            Color32::TRANSPARENT,
694            Stroke::new(2.0, Color32::WHITE),
695        );
696        ui.painter().circle_stroke(
697            center,
698            7.0,
699            Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 180)),
700        );
701    }
702
703    (changed, committed)
704}
705
706fn paint_hue_strip(ui: &mut Ui, theme: &Theme, hsv: &mut HsvaGamma) -> (bool, bool) {
707    let p = &theme.palette;
708    let avail = ui.available_width();
709    let height = 14.0;
710    let (rect, response) = ui.allocate_exact_size(vec2(avail, height), Sense::click_and_drag());
711    let mut changed = false;
712    let committed = response.drag_stopped() || response.clicked();
713
714    if let Some(pos) = response.interact_pointer_pos() {
715        if response.is_pointer_button_down_on() {
716            let h = ((pos.x - rect.min.x) / rect.width()).clamp(0.0, 1.0);
717            hsv.h = h;
718            changed = true;
719        }
720    }
721
722    if ui.is_rect_visible(rect) {
723        let painter = ui.painter();
724        let radius = CornerRadius::same((rect.height() * 0.5) as u8);
725
726        let n: usize = 36;
727        let mut mesh = Mesh::default();
728        for i in 0..=n {
729            let h = i as f32 / n as f32;
730            let c: Color32 = HsvaGamma {
731                h,
732                s: 1.0,
733                v: 1.0,
734                a: 1.0,
735            }
736            .into();
737            let x = lerp(rect.left()..=rect.right(), h);
738            mesh.colored_vertex(pos2(x, rect.top()), c);
739            mesh.colored_vertex(pos2(x, rect.bottom()), c);
740            if i < n {
741                let base = (i * 2) as u32;
742                mesh.add_triangle(base, base + 1, base + 2);
743                mesh.add_triangle(base + 1, base + 2, base + 3);
744            }
745        }
746        painter.add(Shape::mesh(mesh));
747        paint_rounded_corner_mask(painter, rect, rect.height() * 0.5, p.card);
748        painter.rect_stroke(rect, radius, Stroke::new(1.0, p.border), StrokeKind::Inside);
749
750        let thumb_x = lerp(rect.left()..=rect.right(), hsv.h);
751        let thumb_center = pos2(thumb_x, rect.center().y);
752        let thumb_color: Color32 = HsvaGamma {
753            h: hsv.h,
754            s: 1.0,
755            v: 1.0,
756            a: 1.0,
757        }
758        .into();
759        painter.circle(
760            thumb_center,
761            7.0,
762            thumb_color,
763            Stroke::new(2.0, Color32::WHITE),
764        );
765        painter.circle_stroke(
766            thumb_center,
767            8.0,
768            Stroke::new(1.0, Color32::from_rgba_unmultiplied(0, 0, 0, 100)),
769        );
770    }
771
772    (changed, committed)
773}
774
775fn paint_alpha_slider(ui: &mut Ui, theme: &Theme, hsv: &mut HsvaGamma) -> (bool, bool) {
776    let p = &theme.palette;
777    let avail = ui.available_width();
778    let height = 14.0;
779    let (rect, response) = ui.allocate_exact_size(vec2(avail, height), Sense::click_and_drag());
780    let mut changed = false;
781    let committed = response.drag_stopped() || response.clicked();
782
783    if let Some(pos) = response.interact_pointer_pos() {
784        if response.is_pointer_button_down_on() {
785            let a = ((pos.x - rect.min.x) / rect.width()).clamp(0.0, 1.0);
786            hsv.a = a;
787            changed = true;
788        }
789    }
790
791    if ui.is_rect_visible(rect) {
792        let painter = ui.painter();
793        let radius = CornerRadius::same((rect.height() * 0.5) as u8);
794
795        // Checkers under the gradient.
796        paint_checkers(painter, rect, radius);
797
798        // Gradient from transparent → opaque (current hue/sat/value).
799        let opaque: Color32 = HsvaGamma { a: 1.0, ..*hsv }.into();
800        let [r, g, b, _] = opaque.to_srgba_unmultiplied();
801        let transparent = Color32::from_rgba_unmultiplied(r, g, b, 0);
802        let mut mesh = Mesh::default();
803        let n = 12u32;
804        for i in 0..=n {
805            let t = i as f32 / n as f32;
806            let c = lerp_color(transparent, opaque, t);
807            let x = lerp(rect.left()..=rect.right(), t);
808            mesh.colored_vertex(pos2(x, rect.top()), c);
809            mesh.colored_vertex(pos2(x, rect.bottom()), c);
810            if i < n {
811                let base = i * 2;
812                mesh.add_triangle(base, base + 1, base + 2);
813                mesh.add_triangle(base + 1, base + 2, base + 3);
814            }
815        }
816        painter.add(Shape::mesh(mesh));
817        paint_rounded_corner_mask(painter, rect, rect.height() * 0.5, p.card);
818        painter.rect_stroke(rect, radius, Stroke::new(1.0, p.border), StrokeKind::Inside);
819
820        let thumb_x = lerp(rect.left()..=rect.right(), hsv.a);
821        let thumb_center = pos2(thumb_x, rect.center().y);
822        painter.circle(thumb_center, 7.0, p.text, Stroke::new(2.0, p.card));
823    }
824
825    (changed, committed)
826}
827
828// --- hex input -------------------------------------------------------------
829
830fn paint_hex_input(ui: &mut Ui, theme: &Theme, id_salt: Id, current: Color32) -> Option<Color32> {
831    let p = &theme.palette;
832    let t = &theme.typography;
833
834    let buf_id = id_salt.with(HEX_BUF_SUFFIX);
835    let edit_id = id_salt.with("color_picker::hex_edit");
836
837    // Sync the buffer to the current color when the input doesn't have focus.
838    let has_focus = ui.memory(|m| m.has_focus(edit_id));
839    let mut buf: String = ui.ctx().data(|d| d.get_temp(buf_id)).unwrap_or_default();
840    if !has_focus {
841        buf = format_hex(current);
842    }
843
844    let mut picked = None;
845    ui.horizontal(|ui| {
846        // Preview swatch on the left.
847        let preview_size = Vec2::splat(28.0);
848        let (preview_rect, _) = ui.allocate_exact_size(preview_size, Sense::hover());
849        let radius_n: u8 = 5;
850        let radius = CornerRadius::same(radius_n);
851        if current.is_opaque() {
852            ui.painter().rect_filled(preview_rect, radius, current);
853        } else {
854            paint_checkers(ui.painter(), preview_rect, radius);
855            ui.painter().rect_filled(preview_rect, radius, current);
856            paint_rounded_corner_mask(ui.painter(), preview_rect, radius_n as f32, p.card);
857        }
858        ui.painter().rect_stroke(
859            preview_rect,
860            radius,
861            Stroke::new(1.0, p.border),
862            StrokeKind::Inside,
863        );
864
865        ui.add_space(8.0);
866
867        // Hex text edit.
868        let response = crate::theme::with_themed_visuals(ui, |ui| {
869            let v = ui.visuals_mut();
870            crate::theme::themed_input_visuals(v, theme, p.input_bg);
871            v.extreme_bg_color = p.input_bg;
872            v.selection.bg_fill = with_alpha(p.sky, 90);
873            v.selection.stroke = Stroke::new(1.0, p.sky);
874
875            let edit = TextEdit::singleline(&mut buf)
876                .id(edit_id)
877                .font(FontSelection::FontId(egui::FontId::monospace(t.body)))
878                .text_color(p.text)
879                .margin(vec2(8.0, 4.0))
880                .desired_width(ui.available_width());
881            ui.add(edit)
882        });
883
884        // Try to parse on every change. Accept on commit only.
885        if response.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
886            if let Some(c) = parse_hex(&buf) {
887                picked = Some(c);
888                buf = format_hex(c);
889            } else {
890                // Reject: revert buffer.
891                buf = format_hex(current);
892            }
893        } else if !response.has_focus() && response.changed() {
894            // External update path — let the syncing above reset the buffer.
895        } else if response.has_focus() {
896            // Live-parse if the user has typed a complete value, but don't
897            // commit until they press enter. Keep the buffer untouched.
898            let _ = parse_hex(&buf);
899        }
900
901        if !response.has_focus() {
902            // Don't store transient buffer when focus is elsewhere.
903            ui.ctx().data_mut(|d| d.remove::<String>(buf_id));
904        } else {
905            ui.ctx().data_mut(|d| d.insert_temp(buf_id, buf.clone()));
906        }
907    });
908
909    picked
910}
911
912// --- helpers ---------------------------------------------------------------
913
914fn small_label(theme: &Theme, text: &str) -> egui::Label {
915    let rich = egui::RichText::new(text.to_uppercase())
916        .color(theme.palette.text_faint)
917        .size(theme.typography.small - 1.0);
918    egui::Label::new(rich).wrap_mode(egui::TextWrapMode::Extend)
919}
920
921fn paint_swatch(
922    painter: &egui::Painter,
923    rect: Rect,
924    color: Color32,
925    radius: u8,
926    is_dark: bool,
927    surround: Color32,
928) {
929    let r = CornerRadius::same(radius);
930    if color.is_opaque() {
931        painter.rect_filled(rect, r, color);
932    } else {
933        paint_checkers(painter, rect, r);
934        painter.rect_filled(rect, r, color);
935        paint_rounded_corner_mask(painter, rect, radius as f32, surround);
936    }
937    let inset = if is_dark {
938        Color32::from_rgba_unmultiplied(15, 23, 42, 110)
939    } else {
940        Color32::from_rgba_unmultiplied(0, 0, 0, 50)
941    };
942    painter.rect_stroke(rect, r, Stroke::new(1.0, inset), StrokeKind::Inside);
943}
944
945/// Cover the area between the bounding `rect` and its rounded interior
946/// with `mask_color`. Use after painting a rectangular mesh or unrounded
947/// pattern that overflows the intended rounded shape, so the parent
948/// surface fills the corner cells instead of the overflow showing through.
949fn paint_rounded_corner_mask(
950    painter: &egui::Painter,
951    rect: Rect,
952    radius: f32,
953    mask_color: Color32,
954) {
955    if radius <= 0.5 || rect.width() <= 0.0 || rect.height() <= 0.0 {
956        return;
957    }
958    let r = radius.min(rect.width() * 0.5).min(rect.height() * 0.5);
959    let n: usize = 12;
960    let half_pi = std::f32::consts::FRAC_PI_2;
961    let pi = std::f32::consts::PI;
962    let corners: [(Pos2, Pos2, f32); 4] = [
963        // Top-left: arc spans angles π → 3π/2 around (left+r, top+r)
964        (rect.left_top(), pos2(rect.left() + r, rect.top() + r), pi),
965        // Top-right: 3π/2 → 2π around (right-r, top+r)
966        (
967            rect.right_top(),
968            pos2(rect.right() - r, rect.top() + r),
969            1.5 * pi,
970        ),
971        // Bottom-right: 0 → π/2 around (right-r, bottom-r)
972        (
973            rect.right_bottom(),
974            pos2(rect.right() - r, rect.bottom() - r),
975            0.0,
976        ),
977        // Bottom-left: π/2 → π around (left+r, bottom-r)
978        (
979            rect.left_bottom(),
980            pos2(rect.left() + r, rect.bottom() - r),
981            half_pi,
982        ),
983    ];
984    for (corner, center, start_angle) in corners {
985        let mut mesh = Mesh::default();
986        mesh.colored_vertex(corner, mask_color);
987        for i in 0..=n {
988            let t = i as f32 / n as f32;
989            let theta = start_angle + half_pi * t;
990            let p = pos2(center.x + r * theta.cos(), center.y + r * theta.sin());
991            mesh.colored_vertex(p, mask_color);
992        }
993        for i in 0..n {
994            mesh.add_triangle(0, (1 + i) as u32, (2 + i) as u32);
995        }
996        painter.add(Shape::mesh(mesh));
997    }
998}
999
1000fn paint_checkers(painter: &egui::Painter, rect: Rect, radius: CornerRadius) {
1001    let dark = Color32::from_gray(40);
1002    let light = Color32::from_gray(96);
1003    let cell = (rect.height() * 0.5).max(2.0);
1004    painter.rect_filled(rect, radius, dark);
1005    let cols = (rect.width() / cell).ceil() as i32;
1006    let rows = (rect.height() / cell).ceil() as i32;
1007    let mut tiles: Vec<Shape> = Vec::new();
1008    for j in 0..rows {
1009        for i in 0..cols {
1010            if (i + j) % 2 == 0 {
1011                continue;
1012            }
1013            let x0 = rect.min.x + i as f32 * cell;
1014            let y0 = rect.min.y + j as f32 * cell;
1015            let x1 = (x0 + cell).min(rect.max.x);
1016            let y1 = (y0 + cell).min(rect.max.y);
1017            tiles.push(Shape::rect_filled(
1018                Rect::from_min_max(pos2(x0, y0), pos2(x1, y1)),
1019                CornerRadius::ZERO,
1020                light,
1021            ));
1022        }
1023    }
1024    // Clip to the radius by stacking under another rect with no fill but
1025    // matching radius. Simpler: just paint the tiles; the slight over-paint
1026    // at corners is hidden by the swatch's rounded fill drawn on top.
1027    painter.extend(tiles);
1028}
1029
1030fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 {
1031    let t = t.clamp(0.0, 1.0);
1032    let mix = |x: u8, y: u8| -> u8 {
1033        let xf = x as f32;
1034        let yf = y as f32;
1035        (xf + (yf - xf) * t).round().clamp(0.0, 255.0) as u8
1036    };
1037    Color32::from_rgba_unmultiplied(
1038        mix(a.r(), b.r()),
1039        mix(a.g(), b.g()),
1040        mix(a.b(), b.b()),
1041        mix(a.a(), b.a()),
1042    )
1043}
1044
1045fn current_hsv(ctx: &egui::Context, id_salt: Id, color: Color32) -> HsvaGamma {
1046    let cache_id = id_salt.with(HSV_CACHE_SUFFIX);
1047    let cached: Option<HsvaGamma> = ctx.data(|d| d.get_temp(cache_id));
1048    if let Some(c) = cached {
1049        if Color32::from(c) == color {
1050            return c;
1051        }
1052    }
1053    HsvaGamma::from(Hsva::from(color))
1054}
1055
1056fn set_hsv(ctx: &egui::Context, id_salt: Id, hsv: HsvaGamma) {
1057    let cache_id = id_salt.with(HSV_CACHE_SUFFIX);
1058    ctx.data_mut(|d| d.insert_temp(cache_id, hsv));
1059}
1060
1061fn recents_id(id_salt: Id) -> Id {
1062    id_salt.with(RECENTS_SUFFIX)
1063}
1064
1065fn push_recent(ctx: &egui::Context, id_salt: Id, color: Color32, max: usize) {
1066    let id = recents_id(id_salt);
1067    let mut list: Vec<Color32> = ctx.data(|d| d.get_temp(id)).unwrap_or_default();
1068    list.retain(|c| *c != color);
1069    list.insert(0, color);
1070    list.truncate(max);
1071    ctx.data_mut(|d| d.insert_temp(id, list));
1072}
1073
1074fn format_hex(color: Color32) -> String {
1075    let [r, g, b, a] = color.to_srgba_unmultiplied();
1076    if a == 255 {
1077        format!("#{r:02X}{g:02X}{b:02X}")
1078    } else {
1079        format!("#{r:02X}{g:02X}{b:02X}{a:02X}")
1080    }
1081}
1082
1083fn parse_hex(text: &str) -> Option<Color32> {
1084    let s = text.trim().trim_start_matches('#');
1085    let bytes: Vec<u8> = s
1086        .chars()
1087        .filter_map(|c| c.to_digit(16).map(|d| d as u8))
1088        .collect();
1089    match bytes.len() {
1090        3 => Some(Color32::from_rgb(
1091            bytes[0] * 17,
1092            bytes[1] * 17,
1093            bytes[2] * 17,
1094        )),
1095        4 => Some(Color32::from_rgba_unmultiplied(
1096            bytes[0] * 17,
1097            bytes[1] * 17,
1098            bytes[2] * 17,
1099            bytes[3] * 17,
1100        )),
1101        6 => Some(Color32::from_rgb(
1102            (bytes[0] << 4) | bytes[1],
1103            (bytes[2] << 4) | bytes[3],
1104            (bytes[4] << 4) | bytes[5],
1105        )),
1106        8 => Some(Color32::from_rgba_unmultiplied(
1107            (bytes[0] << 4) | bytes[1],
1108            (bytes[2] << 4) | bytes[3],
1109            (bytes[4] << 4) | bytes[5],
1110            (bytes[6] << 4) | bytes[7],
1111        )),
1112        _ => None,
1113    }
1114}
1115
1116#[cfg(test)]
1117mod tests {
1118    use super::*;
1119
1120    #[test]
1121    fn hex_round_trip() {
1122        let c = Color32::from_rgb(0x38, 0xbd, 0xf8);
1123        assert_eq!(format_hex(c), "#38BDF8");
1124        assert_eq!(parse_hex("#38BDF8"), Some(c));
1125        assert_eq!(parse_hex("38bdf8"), Some(c));
1126        assert_eq!(parse_hex("#38B"), Some(Color32::from_rgb(0x33, 0x88, 0xbb)));
1127        assert_eq!(parse_hex(""), None);
1128        assert_eq!(parse_hex("#zzzzzz"), None);
1129    }
1130
1131    #[test]
1132    fn hex_round_trip_alpha() {
1133        let c = Color32::from_rgba_unmultiplied(0x38, 0xbd, 0xf8, 0xc0);
1134        assert_eq!(format_hex(c), "#38BDF8C0");
1135        assert_eq!(parse_hex("#38BDF8C0"), Some(c));
1136    }
1137}