drafftink_widgets/
colors.rs

1//! Color palette and color picker components.
2//!
3//! Includes the full Tailwind CSS color palette and components for color selection.
4
5use egui::{
6    vec2, Color32, CornerRadius, CursorIcon, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2,
7};
8
9use crate::{sizing, theme};
10
11/// A Tailwind color with all shade variants (50-950).
12#[derive(Clone, Copy)]
13pub struct TailwindColor {
14    /// Color name (e.g., "Red", "Blue")
15    pub name: &'static str,
16    /// Shades from 50 to 950 (11 total)
17    pub shades: [Color32; 11],
18}
19
20impl TailwindColor {
21    /// Create a new TailwindColor from RGB tuples.
22    pub const fn new(name: &'static str, shades: [(u8, u8, u8); 11]) -> Self {
23        Self {
24            name,
25            shades: [
26                Color32::from_rgb(shades[0].0, shades[0].1, shades[0].2),
27                Color32::from_rgb(shades[1].0, shades[1].1, shades[1].2),
28                Color32::from_rgb(shades[2].0, shades[2].1, shades[2].2),
29                Color32::from_rgb(shades[3].0, shades[3].1, shades[3].2),
30                Color32::from_rgb(shades[4].0, shades[4].1, shades[4].2),
31                Color32::from_rgb(shades[5].0, shades[5].1, shades[5].2),
32                Color32::from_rgb(shades[6].0, shades[6].1, shades[6].2),
33                Color32::from_rgb(shades[7].0, shades[7].1, shades[7].2),
34                Color32::from_rgb(shades[8].0, shades[8].1, shades[8].2),
35                Color32::from_rgb(shades[9].0, shades[9].1, shades[9].2),
36                Color32::from_rgb(shades[10].0, shades[10].1, shades[10].2),
37            ],
38        }
39    }
40
41    /// Get the 500-level color (primary shade, index 5)
42    pub const fn primary(&self) -> Color32 {
43        self.shades[5]
44    }
45
46    /// Get shade by index (0=50, 1=100, ..., 5=500, ..., 10=950)
47    pub const fn shade(&self, index: usize) -> Color32 {
48        self.shades[index]
49    }
50}
51
52/// Shade labels for display
53pub const SHADE_LABELS: [&str; 11] = [
54    "50", "100", "200", "300", "400", "500", "600", "700", "800", "900", "950",
55];
56
57/// The complete Tailwind CSS color palette.
58pub struct TailwindPalette;
59
60impl TailwindPalette {
61    /// Get all colors in the palette
62    pub fn all() -> &'static [TailwindColor] {
63        TAILWIND_COLORS
64    }
65
66    /// Get color by name
67    pub fn by_name(name: &str) -> Option<&'static TailwindColor> {
68        TAILWIND_COLORS.iter().find(|c| c.name == name)
69    }
70
71    /// Get quick selection colors (commonly used subset)
72    /// Returns indices into the TAILWIND_COLORS array
73    pub fn quick_colors() -> &'static [usize] {
74        // Blue, Amber, Lime, Indigo, Purple, Rose, Slate
75        &[10, 2, 4, 11, 13, 16, 17]
76    }
77}
78
79// Tailwind CSS colors - https://tailwindcss.com/docs/colors
80pub const TAILWIND_COLORS: &[TailwindColor] = &[
81    TailwindColor::new("Red", [
82        (254, 242, 242), (254, 226, 226), (254, 202, 202), (252, 165, 165),
83        (248, 113, 113), (239, 68, 68), (220, 38, 38), (185, 28, 28),
84        (153, 27, 27), (127, 29, 29), (69, 10, 10),
85    ]),
86    TailwindColor::new("Orange", [
87        (255, 247, 237), (255, 237, 213), (254, 215, 170), (253, 186, 116),
88        (251, 146, 60), (249, 115, 22), (234, 88, 12), (194, 65, 12),
89        (154, 52, 18), (124, 45, 18), (67, 20, 7),
90    ]),
91    TailwindColor::new("Amber", [
92        (255, 251, 235), (254, 243, 199), (253, 230, 138), (252, 211, 77),
93        (251, 191, 36), (245, 158, 11), (217, 119, 6), (180, 83, 9),
94        (146, 64, 14), (120, 53, 15), (69, 26, 3),
95    ]),
96    TailwindColor::new("Yellow", [
97        (254, 252, 232), (254, 249, 195), (254, 240, 138), (253, 224, 71),
98        (250, 204, 21), (234, 179, 8), (202, 138, 4), (161, 98, 7),
99        (133, 77, 14), (113, 63, 18), (66, 32, 6),
100    ]),
101    TailwindColor::new("Lime", [
102        (247, 254, 231), (236, 252, 203), (217, 249, 157), (190, 242, 100),
103        (163, 230, 53), (132, 204, 22), (101, 163, 13), (77, 124, 15),
104        (63, 98, 18), (54, 83, 20), (26, 46, 5),
105    ]),
106    TailwindColor::new("Green", [
107        (240, 253, 244), (220, 252, 231), (187, 247, 208), (134, 239, 172),
108        (74, 222, 128), (34, 197, 94), (22, 163, 74), (21, 128, 61),
109        (22, 101, 52), (20, 83, 45), (5, 46, 22),
110    ]),
111    TailwindColor::new("Emerald", [
112        (236, 253, 245), (209, 250, 229), (167, 243, 208), (110, 231, 183),
113        (52, 211, 153), (16, 185, 129), (5, 150, 105), (4, 120, 87),
114        (6, 95, 70), (6, 78, 59), (2, 44, 34),
115    ]),
116    TailwindColor::new("Teal", [
117        (240, 253, 250), (204, 251, 241), (153, 246, 228), (94, 234, 212),
118        (45, 212, 191), (20, 184, 166), (13, 148, 136), (15, 118, 110),
119        (17, 94, 89), (19, 78, 74), (4, 47, 46),
120    ]),
121    TailwindColor::new("Cyan", [
122        (236, 254, 255), (207, 250, 254), (165, 243, 252), (103, 232, 249),
123        (34, 211, 238), (6, 182, 212), (8, 145, 178), (14, 116, 144),
124        (21, 94, 117), (22, 78, 99), (8, 51, 68),
125    ]),
126    TailwindColor::new("Sky", [
127        (240, 249, 255), (224, 242, 254), (186, 230, 253), (125, 211, 252),
128        (56, 189, 248), (14, 165, 233), (2, 132, 199), (3, 105, 161),
129        (7, 89, 133), (12, 74, 110), (8, 47, 73),
130    ]),
131    TailwindColor::new("Blue", [
132        (239, 246, 255), (219, 234, 254), (191, 219, 254), (147, 197, 253),
133        (96, 165, 250), (59, 130, 246), (37, 99, 235), (29, 78, 216),
134        (30, 64, 175), (30, 58, 138), (23, 37, 84),
135    ]),
136    TailwindColor::new("Indigo", [
137        (238, 242, 255), (224, 231, 255), (199, 210, 254), (165, 180, 252),
138        (129, 140, 248), (99, 102, 241), (79, 70, 229), (67, 56, 202),
139        (55, 48, 163), (49, 46, 129), (30, 27, 75),
140    ]),
141    TailwindColor::new("Violet", [
142        (245, 243, 255), (237, 233, 254), (221, 214, 254), (196, 181, 253),
143        (167, 139, 250), (139, 92, 246), (124, 58, 237), (109, 40, 217),
144        (91, 33, 182), (76, 29, 149), (46, 16, 101),
145    ]),
146    TailwindColor::new("Purple", [
147        (250, 245, 255), (243, 232, 255), (233, 213, 255), (216, 180, 254),
148        (192, 132, 252), (168, 85, 247), (147, 51, 234), (126, 34, 206),
149        (107, 33, 168), (88, 28, 135), (59, 7, 100),
150    ]),
151    TailwindColor::new("Fuchsia", [
152        (253, 244, 255), (250, 232, 255), (245, 208, 254), (240, 171, 252),
153        (232, 121, 249), (217, 70, 239), (192, 38, 211), (162, 28, 175),
154        (134, 25, 143), (112, 26, 117), (74, 4, 78),
155    ]),
156    TailwindColor::new("Pink", [
157        (253, 242, 248), (252, 231, 243), (251, 207, 232), (249, 168, 212),
158        (244, 114, 182), (236, 72, 153), (219, 39, 119), (190, 24, 93),
159        (157, 23, 77), (131, 24, 67), (80, 7, 36),
160    ]),
161    TailwindColor::new("Rose", [
162        (255, 241, 242), (255, 228, 230), (254, 205, 211), (253, 164, 175),
163        (251, 113, 133), (244, 63, 94), (225, 29, 72), (190, 18, 60),
164        (159, 18, 57), (136, 19, 55), (76, 5, 25),
165    ]),
166    TailwindColor::new("Slate", [
167        (248, 250, 252), (241, 245, 249), (226, 232, 240), (203, 213, 225),
168        (148, 163, 184), (100, 116, 139), (71, 85, 105), (51, 65, 85),
169        (30, 41, 59), (15, 23, 42), (2, 6, 23),
170    ]),
171    TailwindColor::new("Gray", [
172        (249, 250, 251), (243, 244, 246), (229, 231, 235), (209, 213, 219),
173        (156, 163, 175), (107, 114, 128), (75, 85, 99), (55, 65, 81),
174        (31, 41, 55), (17, 24, 39), (3, 7, 18),
175    ]),
176    TailwindColor::new("Zinc", [
177        (250, 250, 250), (244, 244, 245), (228, 228, 231), (212, 212, 216),
178        (161, 161, 170), (113, 113, 122), (82, 82, 91), (63, 63, 70),
179        (39, 39, 42), (24, 24, 27), (9, 9, 11),
180    ]),
181    TailwindColor::new("Neutral", [
182        (250, 250, 250), (245, 245, 245), (229, 229, 229), (212, 212, 212),
183        (163, 163, 163), (115, 115, 115), (82, 82, 82), (64, 64, 64),
184        (38, 38, 38), (23, 23, 23), (10, 10, 10),
185    ]),
186    TailwindColor::new("Stone", [
187        (250, 250, 249), (245, 245, 244), (231, 229, 228), (214, 211, 209),
188        (168, 162, 158), (120, 113, 108), (87, 83, 78), (68, 64, 60),
189        (41, 37, 36), (28, 25, 23), (12, 10, 9),
190    ]),
191];
192
193/// Style for color swatches.
194#[derive(Clone)]
195pub struct ColorSwatchStyle {
196    /// Size of the swatch
197    pub size: Vec2,
198    /// Whether to show as circle (true) or rounded rect (false)
199    pub circular: bool,
200    /// Selection indicator style
201    pub selection_style: SelectionStyle,
202}
203
204/// How to indicate selection on a color swatch.
205#[derive(Clone, Copy, PartialEq)]
206pub enum SelectionStyle {
207    /// No selection indicator
208    None,
209    /// Inner offset ring (like Excalidraw)
210    InnerRing,
211    /// Outer border
212    OuterBorder,
213}
214
215impl Default for ColorSwatchStyle {
216    fn default() -> Self {
217        Self {
218            size: vec2(sizing::SMALL, sizing::SMALL),
219            circular: true,
220            selection_style: SelectionStyle::InnerRing,
221        }
222    }
223}
224
225impl ColorSwatchStyle {
226    /// Small circular swatch (default)
227    pub fn small() -> Self {
228        Self::default()
229    }
230
231    /// Grid swatch (smaller, for color grids)
232    pub fn grid() -> Self {
233        Self {
234            size: vec2(16.0, 16.0),
235            circular: true,
236            selection_style: SelectionStyle::InnerRing,
237        }
238    }
239
240    /// Large swatch
241    pub fn large() -> Self {
242        Self {
243            size: vec2(28.0, 28.0),
244            circular: true,
245            selection_style: SelectionStyle::InnerRing,
246        }
247    }
248}
249
250/// A clickable color swatch.
251pub struct ColorSwatch<'a> {
252    color: Color32,
253    tooltip: &'a str,
254    selected: bool,
255    style: ColorSwatchStyle,
256}
257
258impl<'a> ColorSwatch<'a> {
259    /// Create a new color swatch.
260    pub fn new(color: Color32, tooltip: &'a str) -> Self {
261        Self {
262            color,
263            tooltip,
264            selected: false,
265            style: ColorSwatchStyle::default(),
266        }
267    }
268
269    /// Set whether this swatch is selected.
270    pub fn selected(mut self, selected: bool) -> Self {
271        self.selected = selected;
272        self
273    }
274
275    /// Set the style.
276    pub fn style(mut self, style: ColorSwatchStyle) -> Self {
277        self.style = style;
278        self
279    }
280
281    /// Use grid style.
282    pub fn grid(mut self) -> Self {
283        self.style = ColorSwatchStyle::grid();
284        self
285    }
286
287    /// Show the swatch and return (clicked, rect).
288    pub fn show(self, ui: &mut Ui) -> (bool, Rect) {
289        let (rect, response) = ui.allocate_exact_size(self.style.size, Sense::click());
290
291        if ui.is_rect_visible(rect) {
292            let center = rect.center();
293            let radius = rect.width().min(rect.height()) / 2.0;
294
295            if self.style.circular {
296                // Circular swatch
297                ui.painter().circle_filled(center, radius, self.color);
298
299                if self.selected && self.style.selection_style == SelectionStyle::InnerRing {
300                    // Inner offset ring
301                    ui.painter().circle_stroke(
302                        center,
303                        radius - 3.0,
304                        Stroke::new(2.0, Color32::from_gray(30)),
305                    );
306                } else if self.selected && self.style.selection_style == SelectionStyle::OuterBorder
307                {
308                    ui.painter()
309                        .circle_stroke(center, radius, Stroke::new(2.0, theme::ACCENT));
310                }
311            } else {
312                // Rounded rect swatch
313                ui.painter().rect_filled(
314                    rect,
315                    CornerRadius::same(sizing::CORNER_RADIUS),
316                    self.color,
317                );
318
319                if self.selected {
320                    ui.painter().rect_stroke(
321                        rect,
322                        CornerRadius::same(sizing::CORNER_RADIUS),
323                        Stroke::new(2.0, Color32::from_gray(30)),
324                        StrokeKind::Inside,
325                    );
326                }
327            }
328        }
329
330        let clicked = response.clicked();
331        response.on_hover_text(self.tooltip).on_hover_cursor(CursorIcon::PointingHand);
332        (clicked, rect)
333    }
334}
335
336/// A color swatch with hue wheel indicator (for "current color" buttons).
337pub struct ColorSwatchWithWheel<'a> {
338    color: Color32,
339    tooltip: &'a str,
340    size: Vec2,
341}
342
343impl<'a> ColorSwatchWithWheel<'a> {
344    /// Create a new color swatch with hue wheel.
345    pub fn new(color: Color32, tooltip: &'a str) -> Self {
346        Self {
347            color,
348            tooltip,
349            size: vec2(sizing::SMALL, sizing::SMALL),
350        }
351    }
352
353    /// Set the size.
354    pub fn size(mut self, size: Vec2) -> Self {
355        self.size = size;
356        self
357    }
358
359    /// Show the swatch and return (clicked, rect).
360    pub fn show(self, ui: &mut Ui) -> (bool, Rect) {
361        let (rect, response) = ui.allocate_exact_size(self.size, Sense::click());
362
363        if ui.is_rect_visible(rect) {
364            let center = rect.center();
365            let outer_radius = rect.width().min(rect.height()) / 2.0;
366            let ring_width = 3.0;
367            let inner_radius = outer_radius - ring_width;
368
369            // Draw hue wheel as segments
370            let num_segments = 32;
371            for i in 0..num_segments {
372                let angle1 = (i as f32 / num_segments as f32) * std::f32::consts::TAU;
373                let angle2 = ((i + 1) as f32 / num_segments as f32) * std::f32::consts::TAU;
374
375                let hue = i as f32 / num_segments as f32;
376                let hue_color = hue_to_rgb(hue);
377
378                let p1 = Pos2::new(
379                    center.x + outer_radius * angle1.cos(),
380                    center.y + outer_radius * angle1.sin(),
381                );
382                let p2 = Pos2::new(
383                    center.x + outer_radius * angle2.cos(),
384                    center.y + outer_radius * angle2.sin(),
385                );
386                let p3 = Pos2::new(
387                    center.x + inner_radius * angle2.cos(),
388                    center.y + inner_radius * angle2.sin(),
389                );
390                let p4 = Pos2::new(
391                    center.x + inner_radius * angle1.cos(),
392                    center.y + inner_radius * angle1.sin(),
393                );
394
395                ui.painter().add(egui::Shape::convex_polygon(
396                    vec![p1, p2, p3, p4],
397                    hue_color,
398                    Stroke::NONE,
399                ));
400            }
401
402            // Black inner circle (offset)
403            ui.painter()
404                .circle_filled(center, inner_radius, Color32::from_gray(30));
405
406            // Inner color fill
407            ui.painter()
408                .circle_filled(center, inner_radius - 2.0, self.color);
409        }
410
411        let clicked = response.clicked();
412        response.on_hover_text(self.tooltip).on_hover_cursor(CursorIcon::PointingHand);
413        (clicked, rect)
414    }
415}
416
417/// A "no color" swatch (white with red diagonal).
418pub struct NoColorSwatch<'a> {
419    tooltip: &'a str,
420    selected: bool,
421    size: Vec2,
422}
423
424impl<'a> NoColorSwatch<'a> {
425    /// Create a new "no color" swatch.
426    pub fn new(tooltip: &'a str) -> Self {
427        Self {
428            tooltip,
429            selected: false,
430            size: vec2(sizing::SMALL, sizing::SMALL),
431        }
432    }
433
434    /// Set whether this swatch is selected.
435    pub fn selected(mut self, selected: bool) -> Self {
436        self.selected = selected;
437        self
438    }
439
440    /// Use grid size (smaller, for color grids).
441    pub fn grid(mut self) -> Self {
442        self.size = vec2(16.0, 16.0);
443        self
444    }
445
446    /// Show the swatch and return true if clicked.
447    pub fn show(self, ui: &mut Ui) -> bool {
448        let (rect, response) = ui.allocate_exact_size(self.size, Sense::click());
449
450        if ui.is_rect_visible(rect) {
451            let center = rect.center();
452            let radius = rect.width().min(rect.height()) / 2.0;
453
454            // White circle
455            ui.painter().circle_filled(center, radius, Color32::WHITE);
456            ui.painter()
457                .circle_stroke(center, radius, Stroke::new(1.0, Color32::from_gray(200)));
458
459            // Red diagonal line
460            let offset = radius * 0.6;
461            ui.painter().line_segment(
462                [
463                    Pos2::new(center.x - offset, center.y + offset),
464                    Pos2::new(center.x + offset, center.y - offset),
465                ],
466                Stroke::new(2.0, Color32::from_rgb(239, 68, 68)),
467            );
468
469            if self.selected {
470                ui.painter().circle_stroke(
471                    center,
472                    radius - 3.0,
473                    Stroke::new(2.0, Color32::from_gray(30)),
474                );
475            }
476        }
477
478        let clicked = response.clicked();
479        response.on_hover_text(self.tooltip).on_hover_cursor(CursorIcon::PointingHand);
480        clicked
481    }
482}
483
484/// A full color grid picker using Tailwind colors.
485pub struct ColorGrid<'a> {
486    current_color: Color32,
487    title: &'a str,
488    /// Which shades to show (indices into SHADE_LABELS)
489    shade_indices: &'a [usize],
490    position: ColorGridPosition,
491}
492
493/// Position of the color grid relative to anchor.
494#[derive(Clone, Copy, Default)]
495pub enum ColorGridPosition {
496    /// Below the anchor (for top toolbars)
497    #[default]
498    Below,
499    /// Above the anchor (for bottom toolbars)
500    Above,
501}
502
503impl<'a> ColorGrid<'a> {
504    /// Create a new color grid.
505    pub fn new(current_color: Color32, title: &'a str) -> Self {
506        Self {
507            current_color,
508            title,
509            shade_indices: &[1, 2, 3, 4, 5, 6, 7, 8, 9], // 100-900 by default
510            position: ColorGridPosition::Below,
511        }
512    }
513
514    /// Set which shade indices to show.
515    pub fn shades(mut self, indices: &'a [usize]) -> Self {
516        self.shade_indices = indices;
517        self
518    }
519
520    /// Position the grid above the anchor.
521    pub fn above(mut self) -> Self {
522        self.position = ColorGridPosition::Above;
523        self
524    }
525
526    /// Position the grid below the anchor.
527    pub fn below(mut self) -> Self {
528        self.position = ColorGridPosition::Below;
529        self
530    }
531
532    /// Show the color grid at the given anchor rect.
533    /// Returns the selected color if one was clicked.
534    pub fn show(self, ctx: &egui::Context, anchor_rect: Rect) -> Option<Color32> {
535        let mut selected = None;
536
537        // Calculate position
538        let grid_height = 220.0;
539        let pos = match self.position {
540            ColorGridPosition::Below => {
541                Pos2::new(anchor_rect.left() - 100.0, anchor_rect.bottom() + 8.0)
542            }
543            ColorGridPosition::Above => {
544                Pos2::new(anchor_rect.left() - 50.0, anchor_rect.top() - grid_height - 8.0)
545            }
546        };
547
548        egui::Area::new(egui::Id::new("color_grid"))
549            .fixed_pos(pos)
550            .order(egui::Order::Foreground)
551            .show(ctx, |ui| {
552                crate::menu::panel_frame().show(ui, |ui| {
553                    ui.vertical(|ui| {
554                        ui.spacing_mut().item_spacing = vec2(0.0, 4.0);
555
556                        // Header
557                        ui.label(
558                            egui::RichText::new(self.title)
559                                .size(12.0)
560                                .color(theme::TEXT_MUTED),
561                        );
562
563                        ui.add_space(4.0);
564
565                        // Color grid with quick access column on right
566                        // Quick access colors: None, Black, White, Blue, Red, Emerald, Amber, Purple, Slate
567                        // Using Tailwind 500-level colors for the named colors
568                        #[derive(Clone, Copy)]
569                        enum QuickColor {
570                            None,
571                            Black,
572                            White,
573                            Tailwind(usize), // Index into TAILWIND_COLORS, uses shade 5 (500-level)
574                        }
575                        let quick_colors: [QuickColor; 9] = [
576                            QuickColor::None,
577                            QuickColor::Black,
578                            QuickColor::White,
579                            QuickColor::Tailwind(10), // Blue
580                            QuickColor::Tailwind(0),  // Red
581                            QuickColor::Tailwind(6),  // Emerald
582                            QuickColor::Tailwind(2),  // Amber
583                            QuickColor::Tailwind(13), // Purple
584                            QuickColor::Tailwind(17), // Slate
585                        ];
586                        let quick_tooltips = ["None", "Black", "White", "Blue", "Red", "Emerald", "Amber", "Purple", "Slate"];
587
588                        for (row_idx, &shade_idx) in self.shade_indices.iter().enumerate() {
589                            ui.horizontal(|ui| {
590                                ui.spacing_mut().item_spacing = vec2(2.0, 0.0);
591                                
592                                // Main Tailwind color grid
593                                for tw_color in TAILWIND_COLORS.iter() {
594                                    let color = tw_color.shades[shade_idx];
595                                    let is_selected = colors_match(self.current_color, color);
596                                    let tooltip =
597                                        format!("{} {}", tw_color.name, SHADE_LABELS[shade_idx]);
598                                    let (clicked, _) = ColorSwatch::new(color, &tooltip)
599                                        .selected(is_selected)
600                                        .grid()
601                                        .show(ui);
602                                    if clicked {
603                                        selected = Some(color);
604                                    }
605                                }
606                                
607                                // Quick access column (only for first 9 rows)
608                                if row_idx < quick_colors.len() {
609                                    ui.add_space(4.0);
610                                    
611                                    // Quick access color
612                                    match quick_colors[row_idx] {
613                                        QuickColor::None => {
614                                            // "No color" swatch
615                                            let is_selected = self.current_color.a() == 0;
616                                            if NoColorSwatch::new(quick_tooltips[row_idx])
617                                                .selected(is_selected)
618                                                .grid()
619                                                .show(ui)
620                                            {
621                                                selected = Some(Color32::TRANSPARENT);
622                                            }
623                                        }
624                                        QuickColor::Black => {
625                                            let is_selected = colors_match(self.current_color, Color32::BLACK);
626                                            let (clicked, _) = ColorSwatch::new(Color32::BLACK, quick_tooltips[row_idx])
627                                                .selected(is_selected)
628                                                .grid()
629                                                .show(ui);
630                                            if clicked {
631                                                selected = Some(Color32::BLACK);
632                                            }
633                                        }
634                                        QuickColor::White => {
635                                            // White circle with gray border
636                                            let size = vec2(16.0, 16.0);
637                                            let (rect, response) = ui.allocate_exact_size(size, Sense::click());
638                                            if ui.is_rect_visible(rect) {
639                                                let center = rect.center();
640                                                let radius = 8.0;
641                                                ui.painter().circle_filled(center, radius, Color32::WHITE);
642                                                ui.painter().circle_stroke(center, radius, Stroke::new(1.0, Color32::from_gray(180)));
643                                                let is_selected = colors_match(self.current_color, Color32::WHITE);
644                                                if is_selected {
645                                                    ui.painter().circle_stroke(center, radius - 3.0, Stroke::new(2.0, Color32::from_gray(30)));
646                                                }
647                                            }
648                                            let clicked = response.clicked();
649                                            response.on_hover_text(quick_tooltips[row_idx]).on_hover_cursor(CursorIcon::PointingHand);
650                                            if clicked {
651                                                selected = Some(Color32::WHITE);
652                                            }
653                                        }
654                                        QuickColor::Tailwind(idx) => {
655                                            let color = TAILWIND_COLORS[idx].shades[5]; // 500-level
656                                            let is_selected = colors_match(self.current_color, color);
657                                            let (clicked, _) = ColorSwatch::new(color, quick_tooltips[row_idx])
658                                                .selected(is_selected)
659                                                .grid()
660                                                .show(ui);
661                                            if clicked {
662                                                selected = Some(color);
663                                            }
664                                        }
665                                    }
666                                }
667                            });
668                        }
669                    });
670                });
671            });
672
673        selected
674    }
675}
676
677/// Check if two colors match (for selection highlighting).
678pub fn colors_match(a: Color32, b: Color32) -> bool {
679    a.r() == b.r() && a.g() == b.g() && a.b() == b.b()
680}
681
682/// Convert hue (0.0-1.0) to RGB color.
683pub fn hue_to_rgb(hue: f32) -> Color32 {
684    let h = hue * 6.0;
685    let c = 1.0_f32;
686    let x = c * (1.0 - (h % 2.0 - 1.0).abs());
687
688    let (r, g, b) = match h as i32 {
689        0 => (c, x, 0.0),
690        1 => (x, c, 0.0),
691        2 => (0.0, c, x),
692        3 => (0.0, x, c),
693        4 => (x, 0.0, c),
694        _ => (c, 0.0, x),
695    };
696
697    Color32::from_rgb((r * 255.0) as u8, (g * 255.0) as u8, (b * 255.0) as u8)
698}
699
700/// Parse a CSS color string (e.g., "#6366f1") to Color32.
701pub fn parse_css_color(color: &str) -> Color32 {
702    if color.starts_with('#') && color.len() == 7 {
703        let r = u8::from_str_radix(&color[1..3], 16).unwrap_or(128);
704        let g = u8::from_str_radix(&color[3..5], 16).unwrap_or(128);
705        let b = u8::from_str_radix(&color[5..7], 16).unwrap_or(128);
706        Color32::from_rgb(r, g, b)
707    } else {
708        Color32::from_rgb(128, 128, 128)
709    }
710}