git.lucas.co / cce-color-editor
color picker and palette editor
git clone https://git.lucas.co/cce-color-editor.git

commitaea8f1b3c13f5b10e41ca9cb9ca311822b2644ba
parent32dc7a04df
authorLucas Galante <[email protected]>
date2026-05-24 17:03
Update components and system settings

 src/main.rs | 404 +++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
 1 file changed, 372 insertions(+), 32 deletions(-)

diff --git a/src/main.rs b/src/main.rs
index abf3259..0f46b0c 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -49,6 +49,21 @@ fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [f32; 4])
     ]
 }
 
+fn gradient_quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c0: [f32; 4], c1: [f32; 4]) -> [Vertex; 6] {
+    let x0 = (x / sw) * 2.0 - 1.0;
+    let y0 = 1.0 - (y / sh) * 2.0;
+    let x1 = ((x + w) / sw) * 2.0 - 1.0;
+    let y1 = 1.0 - ((y + h) / sh) * 2.0;
+    [
+        Vertex { position: [x0, y0], color: c0 },
+        Vertex { position: [x1, y0], color: c1 },
+        Vertex { position: [x0, y1], color: c0 },
+        Vertex { position: [x1, y0], color: c1 },
+        Vertex { position: [x1, y1], color: c1 },
+        Vertex { position: [x0, y1], color: c0 },
+    ]
+}
+
 fn make_text_buffer(fs: &mut FontSystem, text: &str, size: f32) -> Buffer {
     let metrics = Metrics::new(size, size * 1.4);
     let mut buf = Buffer::new(fs, metrics);
@@ -71,6 +86,54 @@ fn parse_hex(hex: &str) -> Option<(f32, f32, f32)> {
     }
 }
 
+fn rgb_to_hsl(r: f32, g: f32, b: f32) -> (f32, f32, f32) {
+    let max = r.max(g.max(b));
+    let min = r.min(g.min(b));
+    let mut h = 0.0;
+    let mut s = 0.0;
+    let l = (max + min) / 2.0;
+
+    if max != min {
+        let d = max - min;
+        s = if l > 0.5 { d / (2.0 - max - min) } else { d / (max + min) };
+        if max == r {
+            h = (g - b) / d + (if g < b { 6.0 } else { 0.0 });
+        } else if max == g {
+            h = (b - r) / d + 2.0;
+        } else if max == b {
+            h = (r - g) / d + 4.0;
+        }
+        h /= 6.0;
+    }
+
+    (h, s, l)
+}
+
+fn hsl_to_rgb(h: f32, s: f32, l: f32) -> (f32, f32, f32) {
+    if s == 0.0 {
+        return (l, l, l);
+    }
+
+    let q = if l < 0.5 { l * (1.0 + s) } else { l + s - l * s };
+    let p = 2.0 * l - q;
+
+    let r = hue_to_rgb(p, q, h + 1.0 / 3.0);
+    let g = hue_to_rgb(p, q, h);
+    let b = hue_to_rgb(p, q, h - 1.0 / 3.0);
+
+    (r, g, b)
+}
+
+fn hue_to_rgb(p: f32, q: f32, mut t: f32) -> f32 {
+    if t < 0.0 { t += 1.0; }
+    if t > 1.0 { t -= 1.0; }
+    if t < 1.0 / 6.0 { return p + (q - p) * 6.0 * t; }
+    if t < 1.0 / 2.0 { return q; }
+    if t < 2.0 / 3.0 { return p + (q - p) * (2.0 / 3.0 - t) * 6.0; }
+    p
+}
+
+
 const HEADER_H: f32 = 36.0;
 const SLIDER_ROW_H: f32 = 36.0;
 const SLIDER_START_Y: f32 = 48.0;
@@ -80,21 +143,27 @@ const SLIDER_TRACK_W: f32 = 280.0;
 const SLIDER_TRACK_H: f32 = 20.0;
 const SLIDER_VALUE_X: f32 = 320.0;
 const PREVIEW_X: f32 = 12.0;
-const PREVIEW_Y: f32 = 168.0;
+const PREVIEW_Y: f32 = 276.0;
 const PREVIEW_W: f32 = 160.0;
 const PREVIEW_H: f32 = 72.0;
-const BUTTON_Y: f32 = 252.0;
+const BUTTON_Y: f32 = 360.0;
 const BUTTON_H: f32 = 32.0;
 const BUTTON_W: f32 = 100.0;
 const BUTTON_GAP: f32 = 12.0;
 const WIN_W: f32 = 380.0;
-const WIN_H: f32 = 320.0;
+const WIN_H: f32 = 428.0;
 
 struct RectWidget {
     x: f32, y: f32, w: f32, h: f32,
     color: [f32; 4],
 }
 
+struct GradientRectWidget {
+    x: f32, y: f32, w: f32, h: f32,
+    c0: [f32; 4],
+    c1: [f32; 4],
+}
+
 struct TextItem {
     buffer: Buffer,
     x: f32, y: f32,
@@ -102,7 +171,7 @@ struct TextItem {
 }
 
 #[derive(Clone, Copy, PartialEq)]
-enum DragTarget { Red, Green, Blue }
+enum DragTarget { Red, Green, Blue, Hue, Saturation, Lightness }
 
 #[derive(Clone, Copy, PartialEq)]
 enum Action { Apply, Cancel }
@@ -125,6 +194,9 @@ struct ColorApp {
     red: f32,
     green: f32,
     blue: f32,
+    hue: f32,
+    saturation: f32,
+    lightness: f32,
 
     font_system: FontSystem,
     swash_cache: SwashCache,
@@ -133,6 +205,7 @@ struct ColorApp {
     text_viewport: Viewport,
 
     rects: Vec<RectWidget>,
+    gradient_rects: Vec<GradientRectWidget>,
     text_items: Vec<TextItem>,
     action_buttons: Vec<HitButton>,
 
@@ -233,12 +306,16 @@ impl ColorApp {
 
         let scale_factor = (window.scale_factor() as f32).max(2.0) as f64;
 
+        let (hue, saturation, lightness) = rgb_to_hsl(red, green, blue);
         let mut app = Self {
             window, surface, device, queue, config, render_pipeline,
             vertex_buffer, vertex_count: 0,
             red, green, blue,
+            hue, saturation, lightness,
             font_system, swash_cache, text_atlas, text_renderer, text_viewport,
-            rects: Vec::new(), text_items: Vec::new(),
+            rects: Vec::new(),
+            gradient_rects: Vec::new(),
+            text_items: Vec::new(),
             action_buttons: Vec::new(),
             cursor_x: 0.0, cursor_y: 0.0,
             dragging: None,
@@ -261,6 +338,7 @@ impl ColorApp {
     fn rebuild_layout(&mut self, sw: f32, sh: f32) {
         let s = self.scale_factor as f32;
         let mut rects = Vec::new();
+        let mut gradient_rects = Vec::new();
         let mut text_items = Vec::new();
         let mut action_buttons = Vec::new();
 
@@ -279,40 +357,103 @@ impl ColorApp {
             color: color::CONTENT_BG,
         });
 
-        let channels = [self.red, self.green, self.blue];
-        let labels = ['R', 'G', 'B'];
-        let fill_colors = [
-            [1.0, 0.0, 0.0, 1.0],
-            [0.0, 1.0, 0.0, 1.0],
-            [0.0, 0.0, 1.0, 1.0],
-        ];
+        let channels = [self.red, self.green, self.blue, self.hue, self.saturation, self.lightness];
+        let labels = ['R', 'G', 'B', 'H', 'S', 'L'];
+
+        let red = self.red;
+        let green = self.green;
+        let blue = self.blue;
+        let hue = self.hue;
+        let saturation = self.saturation;
+        let lightness = self.lightness;
+
+        let get_color_at = |i: usize, t: f32| -> [f32; 4] {
+            match i {
+                0 => [t, green, blue, 1.0],
+                1 => [red, t, blue, 1.0],
+                2 => [red, green, t, 1.0],
+                3 => {
+                    let (r, g, b) = hsl_to_rgb(t, saturation, lightness);
+                    [r, g, b, 1.0]
+                }
+                4 => {
+                    let (r, g, b) = hsl_to_rgb(hue, t, lightness);
+                    [r, g, b, 1.0]
+                }
+                _ => {
+                    let (r, g, b) = hsl_to_rgb(hue, saturation, t);
+                    [r, g, b, 1.0]
+                }
+            }
+        };
 
-        for i in 0..3 {
+        for i in 0..6 {
             let row_y = (SLIDER_START_Y + i as f32 * SLIDER_ROW_H) * s;
             let track_y = row_y + ((SLIDER_ROW_H - SLIDER_TRACK_H) / 2.0) * s;
 
+            // Draw track border
             rects.push(RectWidget {
-                x: SLIDER_TRACK_X * s, y: track_y,
-                w: SLIDER_TRACK_W * s, h: SLIDER_TRACK_H * s,
-                color: [0.18, 0.18, 0.22, 1.0],
+                x: (SLIDER_TRACK_X - 1.0) * s,
+                y: track_y - 1.0 * s,
+                w: (SLIDER_TRACK_W + 2.0) * s,
+                h: (SLIDER_TRACK_H + 2.0) * s,
+                color: [0.08, 0.08, 0.10, 1.0],
             });
 
-            let fill_w = SLIDER_TRACK_W * channels[i] * s;
-            if fill_w > 0.0 {
-                rects.push(RectWidget {
-                    x: SLIDER_TRACK_X * s, y: track_y,
-                    w: fill_w, h: SLIDER_TRACK_H * s,
-                    color: fill_colors[i],
+            // Draw gradient track
+            let n_segments = if i == 3 { 30 } else { 10 };
+            for j in 0..n_segments {
+                let t0 = j as f32 / n_segments as f32;
+                let t1 = (j + 1) as f32 / n_segments as f32;
+                let c0 = get_color_at(i, t0);
+                let c1 = get_color_at(i, t1);
+                gradient_rects.push(GradientRectWidget {
+                    x: (SLIDER_TRACK_X + t0 * SLIDER_TRACK_W) * s,
+                    y: track_y,
+                    w: ((t1 - t0) * SLIDER_TRACK_W) * s,
+                    h: SLIDER_TRACK_H * s,
+                    c0,
+                    c1,
                 });
             }
 
+            // Draw vertical bar indicator at current position
+            let indicator_w = 4.0;
+            let indicator_h = SLIDER_TRACK_H + 4.0;
+            let indicator_x = SLIDER_TRACK_X + channels[i] * SLIDER_TRACK_W - indicator_w / 2.0;
+            let indicator_y = (SLIDER_START_Y + i as f32 * SLIDER_ROW_H) + ((SLIDER_ROW_H - indicator_h) / 2.0);
+
+            // Indicator border/shadow
+            rects.push(RectWidget {
+                x: (indicator_x - 1.0) * s,
+                y: (indicator_y - 1.0) * s,
+                w: (indicator_w + 2.0) * s,
+                h: (indicator_h + 2.0) * s,
+                color: [0.05, 0.05, 0.05, 0.95],
+            });
+
+            // Indicator body
+            rects.push(RectWidget {
+                x: indicator_x * s,
+                y: indicator_y * s,
+                w: indicator_w * s,
+                h: indicator_h * s,
+                color: [1.0, 1.0, 1.0, 1.0],
+            });
+
             text_items.push(TextItem {
                 buffer: make_text_buffer(&mut self.font_system, &labels[i].to_string(), 12.0 * s),
                 x: SLIDER_LABEL_X * s, y: row_y + 4.0 * s,
                 color: glyphon::Color::rgb(0xaa, 0xaa, 0xbb),
             });
 
-            let val = format!("{}", (channels[i] * 255.0) as u8);
+            let val = if i < 3 {
+                format!("{}", (channels[i] * 255.0) as u8)
+            } else if i == 3 {
+                format!("{}°", (channels[i] * 360.0).round() as u16)
+            } else {
+                format!("{}%", (channels[i] * 100.0).round() as u8)
+            };
             text_items.push(TextItem {
                 buffer: make_text_buffer(&mut self.font_system, &val, 11.0 * s),
                 x: SLIDER_VALUE_X * s, y: row_y + 4.0 * s,
@@ -374,6 +515,7 @@ impl ColorApp {
         });
 
         self.rects = rects;
+        self.gradient_rects = gradient_rects;
         self.text_items = text_items;
         self.action_buttons = action_buttons;
         self.needs_rebuild = false;
@@ -392,6 +534,9 @@ impl ColorApp {
         for r in &self.rects {
             verts.extend(quad_vertices(r.x, r.y, r.w, r.h, sw, sh, r.color));
         }
+        for g in &self.gradient_rects {
+            verts.extend(gradient_quad_vertices(g.x, g.y, g.w, g.h, sw, sh, g.c0, g.c1));
+        }
         verts
     }
 
@@ -449,33 +594,226 @@ impl ColorApp {
                 self.cursor_y = position.y as f32;
                 if let Some(drag) = self.dragging {
                     let i = match drag {
-                        DragTarget::Red => 0, DragTarget::Green => 1, DragTarget::Blue => 2,
+                        DragTarget::Red => 0,
+                        DragTarget::Green => 1,
+                        DragTarget::Blue => 2,
+                        DragTarget::Hue => 3,
+                        DragTarget::Saturation => 4,
+                        DragTarget::Lightness => 5,
                     };
                     let s = self.scale_factor as f32;
                     let (tx, _, tw, _) = Self::slider_physical_rect(i, s);
                     let new_val = ((self.cursor_x - tx) / tw).clamp(0.0, 1.0);
-                    let old = match i { 0 => self.red, 1 => self.green, _ => self.blue };
+                    let old = match i {
+                        0 => self.red,
+                        1 => self.green,
+                        2 => self.blue,
+                        3 => self.hue,
+                        4 => self.saturation,
+                        _ => self.lightness,
+                    };
                     if (new_val - old).abs() > 0.002 {
-                        match i { 0 => self.red = new_val, 1 => self.green = new_val, _ => self.blue = new_val };
+                        match i {
+                            0 => {
+                                self.red = new_val;
+                                let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                self.saturation = sat;
+                                self.lightness = l;
+                                if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                    self.hue = h;
+                                }
+                            }
+                            1 => {
+                                self.green = new_val;
+                                let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                self.saturation = sat;
+                                self.lightness = l;
+                                if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                    self.hue = h;
+                                }
+                            }
+                            2 => {
+                                self.blue = new_val;
+                                let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                self.saturation = sat;
+                                self.lightness = l;
+                                if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                    self.hue = h;
+                                }
+                            }
+                            3 => {
+                                self.hue = new_val;
+                                let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                self.red = r;
+                                self.green = g;
+                                self.blue = b;
+                            }
+                            4 => {
+                                self.saturation = new_val;
+                                let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                self.red = r;
+                                self.green = g;
+                                self.blue = b;
+                            }
+                            _ => {
+                                self.lightness = new_val;
+                                let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                self.red = r;
+                                self.green = g;
+                                self.blue = b;
+                            }
+                        }
                         self.needs_rebuild = true;
                     }
                 }
                 false
             }
+            WindowEvent::MouseWheel { delta, .. } => {
+                let s = self.scale_factor as f32;
+                let (px, py) = (self.cursor_x, self.cursor_y);
+                let scroll_amount = match delta {
+                    winit::event::MouseScrollDelta::LineDelta(_x, y) => *y,
+                    winit::event::MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 60.0,
+                };
+                if scroll_amount.abs() > 0.0001 {
+                    for i in 0..6 {
+                        let (tx, ty, tw, th) = Self::slider_physical_rect(i, s);
+                        if px >= tx && px <= tx + tw && py >= ty - 4.0 * s && py <= ty + th + 4.0 * s {
+                            let step = 0.02;
+                            let old_val = match i {
+                                0 => self.red,
+                                1 => self.green,
+                                2 => self.blue,
+                                3 => self.hue,
+                                4 => self.saturation,
+                                _ => self.lightness,
+                            };
+                            let new_val = (old_val + scroll_amount * step).clamp(0.0, 1.0);
+                            if (new_val - old_val).abs() > 0.0001 {
+                                match i {
+                                    0 => {
+                                        self.red = new_val;
+                                        let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                        self.saturation = sat;
+                                        self.lightness = l;
+                                        if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                            self.hue = h;
+                                        }
+                                    }
+                                    1 => {
+                                        self.green = new_val;
+                                        let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                        self.saturation = sat;
+                                        self.lightness = l;
+                                        if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                            self.hue = h;
+                                        }
+                                    }
+                                    2 => {
+                                        self.blue = new_val;
+                                        let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                        self.saturation = sat;
+                                        self.lightness = l;
+                                        if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                            self.hue = h;
+                                        }
+                                    }
+                                    3 => {
+                                        self.hue = new_val;
+                                        let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                        self.red = r;
+                                        self.green = g;
+                                        self.blue = b;
+                                    }
+                                    4 => {
+                                        self.saturation = new_val;
+                                        let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                        self.red = r;
+                                        self.green = g;
+                                        self.blue = b;
+                                    }
+                                    _ => {
+                                        self.lightness = new_val;
+                                        let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                        self.red = r;
+                                        self.green = g;
+                                        self.blue = b;
+                                    }
+                                }
+                                self.needs_rebuild = true;
+                                return true;
+                            }
+                        }
+                    }
+                }
+                false
+            }
             WindowEvent::MouseInput { state, button, .. } => {
                 if *button != MouseButton::Left { return false; }
                 match state {
                     ElementState::Pressed => {
                         let s = self.scale_factor as f32;
                         let (px, py) = (self.cursor_x, self.cursor_y);
-                        for i in 0..3 {
+                        for i in 0..6 {
                             let (tx, ty, tw, th) = Self::slider_physical_rect(i, s);
                             if px >= tx && px <= tx + tw && py >= ty && py <= ty + th {
                                 let val = ((px - tx) / tw).clamp(0.0, 1.0);
-                                match i { 0 => self.red = val, 1 => self.green = val, _ => self.blue = val };
-                                self.dragging = Some(match i {
-                                    0 => DragTarget::Red, 1 => DragTarget::Green, _ => DragTarget::Blue,
-                                });
+                                match i {
+                                    0 => {
+                                        self.red = val;
+                                        let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                        self.saturation = sat;
+                                        self.lightness = l;
+                                        if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                            self.hue = h;
+                                        }
+                                        self.dragging = Some(DragTarget::Red);
+                                    }
+                                    1 => {
+                                        self.green = val;
+                                        let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                        self.saturation = sat;
+                                        self.lightness = l;
+                                        if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                            self.hue = h;
+                                        }
+                                        self.dragging = Some(DragTarget::Green);
+                                    }
+                                    2 => {
+                                        self.blue = val;
+                                        let (h, sat, l) = rgb_to_hsl(self.red, self.green, self.blue);
+                                        self.saturation = sat;
+                                        self.lightness = l;
+                                        if sat > 0.001 && l > 0.001 && l < 0.999 {
+                                            self.hue = h;
+                                        }
+                                        self.dragging = Some(DragTarget::Blue);
+                                    }
+                                    3 => {
+                                        self.hue = val;
+                                        let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                        self.red = r;
+                                        self.green = g;
+                                        self.blue = b;
+                                        self.dragging = Some(DragTarget::Hue);
+                                    }
+                                    4 => {
+                                        self.saturation = val;
+                                        let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                        self.red = r;
+                                        self.green = g;
+                                        self.blue = b;
+                                        self.dragging = Some(DragTarget::Saturation);
+                                    }
+                                    _ => {
+                                        self.lightness = val;
+                                        let (r, g, b) = hsl_to_rgb(self.hue, self.saturation, self.lightness);
+                                        self.red = r;
+                                        self.green = g;
+                                        self.blue = b;
+                                        self.dragging = Some(DragTarget::Lightness);
+                                    }
+                                }
                                 self.needs_rebuild = true;
                                 return true;
                             }
@@ -572,11 +910,13 @@ impl AppWrapper {
 impl ApplicationHandler for AppWrapper {
     fn resumed(&mut self, event_loop: &ActiveEventLoop) {
         if self.state.is_some() { return; }
+        let size = winit::dpi::LogicalSize::new(WIN_W, WIN_H);
         let window = Arc::new(event_loop.create_window(
             WindowAttributes::default()
                 .with_name("clear-color-interface", "clear-color-interface")
                 .with_title("Clear Color Interface")
-                .with_inner_size(winit::dpi::LogicalSize::new(WIN_W, WIN_H)),
+                .with_inner_size(size)
+                .with_min_inner_size(size),
         ).unwrap());
         let state = pollster::block_on(ColorApp::new(
             window, self.initial_red, self.initial_green, self.initial_blue,