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

commita802e731a56692eeec6075e98b0c8879f4f4c365
authorLucas Galante <[email protected]>
date2026-05-19 23:12
Initial commit: basic RGB color chooser with clear-ui

R, G, B sliders (click/drag), color preview swatch, and hex display.
Uses the clear-ui framework for wgpu rendering and color palette.

 .gitignore  |   2 +
 Cargo.toml  |  12 ++
 src/main.rs | 513 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 527 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..2c96eb1
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,2 @@
+target/
+Cargo.lock
diff --git a/Cargo.toml b/Cargo.toml
new file mode 100644
index 0000000..f707e65
--- /dev/null
+++ b/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "clear-colors"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+clear-ui = { path = "../clear-ui" }
+winit = "0.30"
+wgpu = "24"
+bytemuck = { version = "1", features = ["derive"] }
+pollster = "0.4"
+glyphon = "0.8"
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..ba4acd5
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,513 @@
+use std::sync::Arc;
+
+use winit::application::ApplicationHandler;
+use winit::event::{ElementState, MouseButton, WindowEvent};
+use winit::event_loop::{ActiveEventLoop, ControlFlow, EventLoop};
+use winit::window::{Window, WindowAttributes};
+
+use clear_ui::color;
+use glyphon::{
+    Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, SwashCache, TextArea, TextAtlas,
+    TextBounds, TextRenderer, Viewport,
+};
+
+#[repr(C)]
+#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
+struct Vertex {
+    position: [f32; 2],
+    color: [f32; 4],
+}
+
+impl Vertex {
+    const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![
+        0 => Float32x2,
+        1 => Float32x4,
+    ];
+
+    fn desc() -> wgpu::VertexBufferLayout<'static> {
+        wgpu::VertexBufferLayout {
+            array_stride: std::mem::size_of::<Vertex>() as wgpu::BufferAddress,
+            step_mode: wgpu::VertexStepMode::Vertex,
+            attributes: &Self::ATTRIBS,
+        }
+    }
+}
+
+fn quad_vertices(x: f32, y: f32, w: f32, h: f32, sw: f32, sh: f32, c: [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: c },
+        Vertex { position: [x1, y0], color: c },
+        Vertex { position: [x0, y1], color: c },
+        Vertex { position: [x1, y0], color: c },
+        Vertex { position: [x1, y1], color: c },
+        Vertex { position: [x0, y1], color: c },
+    ]
+}
+
+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);
+    buf.set_text(fs, text, Attrs::new(), glyphon::Shaping::Advanced);
+    buf.shape_until_scroll(fs, true);
+    buf
+}
+
+const HEADER_H: f32 = 36.0;
+const SLIDER_ROW_H: f32 = 36.0;
+const SLIDER_START_Y: f32 = 48.0;
+const SLIDER_LABEL_X: f32 = 12.0;
+const SLIDER_TRACK_X: f32 = 32.0;
+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_W: f32 = 160.0;
+const PREVIEW_H: f32 = 72.0;
+const WIN_W: f32 = 380.0;
+const WIN_H: f32 = 280.0;
+
+struct RectWidget {
+    x: f32, y: f32, w: f32, h: f32,
+    color: [f32; 4],
+}
+
+struct TextItem {
+    buffer: Buffer,
+    x: f32, y: f32,
+    color: glyphon::Color,
+}
+
+#[derive(Clone, Copy, PartialEq)]
+enum DragTarget { Red, Green, Blue }
+
+struct ColorApp {
+    window: Arc<Window>,
+    surface: wgpu::Surface<'static>,
+    device: wgpu::Device,
+    queue: wgpu::Queue,
+    config: wgpu::SurfaceConfiguration,
+    render_pipeline: wgpu::RenderPipeline,
+    vertex_buffer: wgpu::Buffer,
+    vertex_count: u32,
+
+    red: f32,
+    green: f32,
+    blue: f32,
+
+    font_system: FontSystem,
+    swash_cache: SwashCache,
+    text_atlas: TextAtlas,
+    text_renderer: TextRenderer,
+    text_viewport: Viewport,
+
+    rects: Vec<RectWidget>,
+    text_items: Vec<TextItem>,
+
+    cursor_x: f32,
+    cursor_y: f32,
+    dragging: Option<DragTarget>,
+
+    scale_factor: f64,
+    width: u32,
+    height: u32,
+    needs_rebuild: bool,
+}
+
+impl ColorApp {
+    async fn new(window: Arc<Window>) -> Self {
+        let size = window.inner_size();
+        let sw = size.width as f32;
+        let sh = size.height as f32;
+
+        let instance = wgpu::Instance::new(&wgpu::InstanceDescriptor {
+            backends: wgpu::Backends::VULKAN,
+            ..Default::default()
+        });
+        let surface = instance.create_surface(window.clone()).expect("surface");
+        let adapter = instance.request_adapter(&wgpu::RequestAdapterOptions {
+            power_preference: wgpu::PowerPreference::HighPerformance,
+            compatible_surface: Some(&surface),
+            force_fallback_adapter: false,
+        }).await.expect("adapter");
+        let (device, queue) = adapter.request_device(&wgpu::DeviceDescriptor {
+            label: Some("GPU Device"),
+            required_features: wgpu::Features::empty(),
+            required_limits: wgpu::Limits::downlevel_webgl2_defaults().using_resolution(adapter.limits()),
+            memory_hints: wgpu::MemoryHints::MemoryUsage,
+        }, None).await.expect("device");
+        let config = surface.get_default_config(&adapter, size.width.max(1), size.height.max(1)).expect("config");
+        surface.configure(&device, &config);
+
+        let shader_code = clear_ui::SHADER;
+        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
+            label: Some("Shader"),
+            source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(shader_code)),
+        });
+        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
+            label: Some("Pipeline Layout"),
+            bind_group_layouts: &[],
+            push_constant_ranges: &[],
+        });
+        let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
+            label: Some("Render Pipeline"),
+            layout: Some(&pipeline_layout),
+            vertex: wgpu::VertexState {
+                module: &shader,
+                entry_point: Some("vs_main"),
+                buffers: &[Vertex::desc()],
+                compilation_options: Default::default(),
+            },
+            fragment: Some(wgpu::FragmentState {
+                module: &shader,
+                entry_point: Some("fs_main"),
+                targets: &[Some(wgpu::ColorTargetState {
+                    format: config.format,
+                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
+                    write_mask: wgpu::ColorWrites::ALL,
+                })],
+                compilation_options: Default::default(),
+            }),
+            primitive: wgpu::PrimitiveState {
+                topology: wgpu::PrimitiveTopology::TriangleList,
+                front_face: wgpu::FrontFace::Ccw,
+                cull_mode: None,
+                polygon_mode: wgpu::PolygonMode::Fill,
+                unclipped_depth: false,
+                conservative: false,
+                strip_index_format: None,
+            },
+            depth_stencil: None,
+            multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
+            multiview: None,
+            cache: None,
+        });
+
+        let font_system = FontSystem::new();
+        let swash_cache = SwashCache::new();
+        let cache = Cache::new(&device);
+        let mut text_atlas = TextAtlas::new(&device, &queue, &cache, config.format);
+        let text_renderer = TextRenderer::new(&mut text_atlas, &device, wgpu::MultisampleState::default(), None);
+        let mut text_viewport = Viewport::new(&device, &cache);
+        text_viewport.update(&queue, Resolution { width: size.width, height: size.height });
+
+        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
+            label: Some("Vertex Buffer"),
+            size: 1,
+            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
+            mapped_at_creation: false,
+        });
+
+        let scale_factor = (window.scale_factor() as f32).max(2.0) as f64;
+
+        let mut app = Self {
+            window, surface, device, queue, config, render_pipeline,
+            vertex_buffer, vertex_count: 0,
+            red: 0.5, green: 0.5, blue: 0.5,
+            font_system, swash_cache, text_atlas, text_renderer, text_viewport,
+            rects: Vec::new(), text_items: Vec::new(),
+            cursor_x: 0.0, cursor_y: 0.0,
+            dragging: None,
+            scale_factor,
+            width: size.width, height: size.height,
+            needs_rebuild: true,
+        };
+        app.rebuild_layout(sw, sh);
+        app
+    }
+
+    fn rebuild_layout(&mut self, sw: f32, sh: f32) {
+        let s = self.scale_factor as f32;
+        let mut rects = Vec::new();
+        let mut text_items = Vec::new();
+
+        rects.push(RectWidget {
+            x: 0.0, y: 0.0, w: sw, h: HEADER_H * s,
+            color: color::HEADER_BG,
+        });
+        text_items.push(TextItem {
+            buffer: make_text_buffer(&mut self.font_system, "Clear Colors", 14.0 * s),
+            x: 12.0 * s, y: 10.0 * s,
+            color: glyphon::Color::rgb(0xcc, 0xcc, 0xd4),
+        });
+
+        rects.push(RectWidget {
+            x: 0.0, y: HEADER_H * s, w: sw, h: sh - HEADER_H * s,
+            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],
+        ];
+
+        for i in 0..3 {
+            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;
+
+            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],
+            });
+
+            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],
+                });
+            }
+
+            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);
+            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,
+                color: glyphon::Color::rgb(0xcc, 0xcc, 0xdd),
+            });
+        }
+
+        rects.push(RectWidget {
+            x: PREVIEW_X * s, y: PREVIEW_Y * s,
+            w: PREVIEW_W * s, h: PREVIEW_H * s,
+            color: [self.red, self.green, self.blue, 1.0],
+        });
+
+        let hex = format!("#{:02X}{:02X}{:02X}",
+            (self.red * 255.0) as u8,
+            (self.green * 255.0) as u8,
+            (self.blue * 255.0) as u8);
+        text_items.push(TextItem {
+            buffer: make_text_buffer(&mut self.font_system, &hex, 16.0 * s),
+            x: (PREVIEW_X + PREVIEW_W + 16.0) * s,
+            y: (PREVIEW_Y + 26.0) * s,
+            color: glyphon::Color::rgb(0xe0, 0xe0, 0xe8),
+        });
+
+        self.rects = rects;
+        self.text_items = text_items;
+        self.needs_rebuild = false;
+    }
+
+    fn slider_physical_rect(i: usize, s: f32) -> (f32, f32, f32, f32) {
+        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;
+        (SLIDER_TRACK_X * s, track_y, SLIDER_TRACK_W * s, SLIDER_TRACK_H * s)
+    }
+
+    fn collect_vertices(&self) -> Vec<Vertex> {
+        let sw = self.width as f32;
+        let sh = self.height as f32;
+        let mut verts = Vec::new();
+        for r in &self.rects {
+            verts.extend(quad_vertices(r.x, r.y, r.w, r.h, sw, sh, r.color));
+        }
+        verts
+    }
+
+    fn upload_vertices(&mut self) {
+        let verts = self.collect_vertices();
+        self.vertex_count = verts.len() as u32;
+        let data = bytemuck::cast_slice(&verts);
+        let needed = data.len() as wgpu::BufferAddress;
+        if needed > self.vertex_buffer.size() {
+            self.vertex_buffer = self.device.create_buffer(&wgpu::BufferDescriptor {
+                label: Some("Vertex Buffer"),
+                size: needed,
+                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
+                mapped_at_creation: false,
+            });
+        }
+        self.queue.write_buffer(&self.vertex_buffer, 0, data);
+    }
+
+    fn prepare_text(&mut self) {
+        let Self {
+            ref mut text_renderer, ref device, ref queue,
+            ref mut font_system, ref mut text_atlas,
+            ref mut text_viewport, ref mut swash_cache,
+            ref text_items, width, height, ..
+        } = self;
+
+        let w = *width as f32;
+        let h = *height as f32;
+        let viewport = Resolution { width: w as u32, height: h as u32 };
+        text_viewport.update(queue, viewport);
+        let bounds = TextBounds { left: 0, top: 0, right: w as i32, bottom: h as i32 };
+        let areas: Vec<TextArea> = text_items.iter().map(|ti| TextArea {
+            buffer: &ti.buffer,
+            left: ti.x, top: ti.y, scale: 1.0, bounds,
+            default_color: ti.color,
+            custom_glyphs: &[],
+        }).collect();
+        text_renderer.prepare(device, queue, font_system, text_atlas, text_viewport, areas, swash_cache).unwrap();
+    }
+
+    fn resize(&mut self, size: winit::dpi::PhysicalSize<u32>) {
+        if size.width > 0 && size.height > 0 {
+            self.width = size.width; self.height = size.height;
+            self.config.width = size.width; self.config.height = size.height;
+            self.surface.configure(&self.device, &self.config);
+            self.needs_rebuild = true;
+        }
+    }
+
+    fn handle_event(&mut self, event: &WindowEvent) -> bool {
+        match event {
+            WindowEvent::CursorMoved { position, .. } => {
+                self.cursor_x = position.x as f32;
+                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,
+                    };
+                    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 };
+                    if (new_val - old).abs() > 0.002 {
+                        match i { 0 => self.red = new_val, 1 => self.green = new_val, _ => self.blue = new_val };
+                        self.needs_rebuild = 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 {
+                            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,
+                                });
+                                self.needs_rebuild = true;
+                                return true;
+                            }
+                        }
+                    }
+                    ElementState::Released => {
+                        if self.dragging.is_some() {
+                            self.dragging = None;
+                            return true;
+                        }
+                    }
+                }
+                false
+            }
+            _ => false,
+        }
+    }
+
+    fn render(&mut self) {
+        let sw = self.width as f32;
+        let sh = self.height as f32;
+
+        if self.needs_rebuild {
+            self.rebuild_layout(sw, sh);
+            self.upload_vertices();
+        }
+
+        self.prepare_text();
+
+        let output = match self.surface.get_current_texture() {
+            Ok(t) => t,
+            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
+                self.surface.configure(&self.device, &self.config);
+                return;
+            }
+            Err(wgpu::SurfaceError::Timeout) => return,
+            Err(e) => { eprintln!("Surface error: {e:?}"); return; }
+        };
+
+        let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
+        let mut encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
+            label: Some("Encoder"),
+        });
+
+        {
+            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
+                label: Some("Render Pass"),
+                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
+                    view: &view,
+                    resolve_target: None,
+                    ops: wgpu::Operations {
+                        load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.06, g: 0.06, b: 0.08, a: 1.0 }),
+                        store: wgpu::StoreOp::Store,
+                    },
+                })],
+                depth_stencil_attachment: None,
+                timestamp_writes: None,
+                occlusion_query_set: None,
+            });
+
+            pass.set_pipeline(&self.render_pipeline);
+            pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
+            pass.draw(0..self.vertex_count, 0..1);
+
+            self.text_renderer.render(&self.text_atlas, &self.text_viewport, &mut pass).unwrap();
+        }
+
+        self.queue.submit(std::iter::once(encoder.finish()));
+        self.window.pre_present_notify();
+        output.present();
+    }
+}
+
+struct AppWrapper { state: Option<ColorApp> }
+
+impl AppWrapper {
+    fn new() -> Self { Self { state: None } }
+}
+
+impl ApplicationHandler for AppWrapper {
+    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
+        if self.state.is_some() { return; }
+        let window = Arc::new(event_loop.create_window(
+            WindowAttributes::default()
+                .with_title("Clear Colors")
+                .with_inner_size(winit::dpi::LogicalSize::new(WIN_W, WIN_H)),
+        ).unwrap());
+        let state = pollster::block_on(ColorApp::new(window));
+        self.state = Some(state);
+        self.state.as_ref().unwrap().window.request_redraw();
+    }
+
+    fn window_event(&mut self, event_loop: &ActiveEventLoop, _id: winit::window::WindowId, event: WindowEvent) {
+        let redraw = match &event {
+            WindowEvent::CloseRequested => { event_loop.exit(); true }
+            WindowEvent::Resized(s) => { if let Some(st) = &mut self.state { st.resize(*s); } true }
+            WindowEvent::RedrawRequested => {
+                if let Some(st) = &mut self.state { st.render(); st.window.request_redraw(); }
+                true
+            }
+            _ => self.state.as_mut().map(|st| st.handle_event(&event)).unwrap_or(false)
+        };
+        if redraw { if let Some(st) = &mut self.state { st.window.request_redraw(); } }
+    }
+}
+
+fn main() {
+    let event_loop = EventLoop::new().unwrap();
+    event_loop.set_control_flow(ControlFlow::Poll);
+    event_loop.run_app(&mut AppWrapper::new()).unwrap();
+}