//! ColorPicker Widget - Color selection //! //! A color picker with RGB/HSL modes, palette, and recent colors. const std = @import("std"); const Context = @import("../core/context.zig").Context; const Command = @import("../core/command.zig"); const Layout = @import("../core/layout.zig"); const Style = @import("../core/style.zig"); const Input = @import("../core/input.zig"); /// Color picker mode pub const Mode = enum { rgb, hsl, palette, }; /// Color picker state pub const State = struct { /// Current selected color current: Style.Color = Style.Color.rgba(255, 255, 255, 255), /// Original color (for comparison) original: Style.Color = Style.Color.rgba(255, 255, 255, 255), /// Current mode mode: Mode = .rgb, /// HSL values (for HSL mode) hue: f32 = 0, saturation: f32 = 1, lightness: f32 = 0.5, /// Alpha value alpha: f32 = 1.0, /// Recent colors recent: [8]Style.Color = [_]Style.Color{Style.Color.rgba(0, 0, 0, 255)} ** 8, recent_count: usize = 0, /// Is picker open (for popup variant) open: bool = false, /// Which component is being dragged dragging: ?DragTarget = null, const DragTarget = enum { hue_bar, sl_area, alpha_bar, r_slider, g_slider, b_slider, }; const Self = @This(); /// Set color and update HSL pub fn setColor(self: *Self, color: Style.Color) void { self.current = color; self.alpha = @as(f32, @floatFromInt(color.a)) / 255.0; const hsl = rgbToHsl(color.r, color.g, color.b); self.hue = hsl.h; self.saturation = hsl.s; self.lightness = hsl.l; } /// Update color from HSL pub fn updateFromHsl(self: *Self) void { const rgb = hslToRgb(self.hue, self.saturation, self.lightness); self.current = Style.Color.rgba(rgb.r, rgb.g, rgb.b, @intFromFloat(self.alpha * 255)); } /// Add current color to recent colors pub fn addToRecent(self: *Self) void { // Shift existing colors var i: usize = 7; while (i > 0) : (i -= 1) { self.recent[i] = self.recent[i - 1]; } self.recent[0] = self.current; self.recent_count = @min(self.recent_count + 1, 8); } }; /// Color picker configuration pub const Config = struct { /// Show alpha slider show_alpha: bool = true, /// Show preview comparison show_preview: bool = true, /// Show mode tabs show_modes: bool = true, /// Show recent colors show_recent: bool = true, /// Predefined palette palette: ?[]const Style.Color = null, /// Width of the picker width: u32 = 200, /// Height of the color area color_area_height: u32 = 150, }; /// Color picker colors (meta!) pub const Colors = struct { background: Style.Color = Style.Color.rgba(40, 40, 40, 255), border: Style.Color = Style.Color.rgba(80, 80, 80, 255), text: Style.Color = Style.Color.rgba(220, 220, 220, 255), tab_active: Style.Color = Style.Color.rgba(60, 60, 60, 255), tab_inactive: Style.Color = Style.Color.rgba(50, 50, 50, 255), pub fn fromTheme(theme: Style.Theme) Colors { return .{ .background = theme.background, .border = theme.border, .text = theme.foreground, .tab_active = theme.background.lighten(20), .tab_inactive = theme.background.lighten(10), }; } }; /// Color picker result pub const Result = struct { /// Color was changed changed: bool = false, /// Picker was closed (for popup) closed: bool = false, /// Current color color: Style.Color = Style.Color.rgba(255, 255, 255, 255), }; /// Draw a color picker pub fn colorPicker(ctx: *Context, state: *State) Result { return colorPickerEx(ctx, state, .{}, .{}); } /// Draw a color picker with configuration pub fn colorPickerEx( ctx: *Context, state: *State, config: Config, colors: Colors, ) Result { const bounds = ctx.layout.nextRect(); return colorPickerRect(ctx, bounds, state, config, colors); } /// Draw a color picker in specific rectangle pub fn colorPickerRect( ctx: *Context, bounds: Layout.Rect, state: *State, config: Config, colors: Colors, ) Result { var result = Result{ .color = state.current, }; if (bounds.isEmpty()) return result; const mouse = ctx.input.mousePos(); const mouse_pressed = ctx.input.mousePressed(.left); const mouse_released = ctx.input.mouseReleased(.left); const mouse_down = ctx.input.mouseDown(.left); // Draw background ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, colors.background)); ctx.pushCommand(Command.rectOutline(bounds.x, bounds.y, bounds.w, bounds.h, colors.border)); var y = bounds.y + 4; const padding: i32 = 8; const content_w = bounds.w -| 16; // Mode tabs if (config.show_modes) { const tab_w = content_w / 3; const tab_h: u32 = 20; const modes = [_]Mode{ .rgb, .hsl, .palette }; const labels = [_][]const u8{ "RGB", "HSL", "Palette" }; for (modes, 0..) |mode, i| { const tab_x = bounds.x + padding + @as(i32, @intCast(i * tab_w)); const tab_rect = Layout.Rect.init(tab_x, y, tab_w, tab_h); const is_active = state.mode == mode; const tab_bg = if (is_active) colors.tab_active else colors.tab_inactive; ctx.pushCommand(Command.rect(tab_rect.x, tab_rect.y, tab_rect.w, tab_rect.h, tab_bg)); ctx.pushCommand(Command.text( tab_x + @as(i32, @intCast(tab_w / 2)) - 10, y + 6, labels[i], colors.text, )); if (tab_rect.contains(mouse.x, mouse.y) and mouse_pressed) { state.mode = mode; } } y += @as(i32, @intCast(tab_h)) + 8; } // Draw based on mode switch (state.mode) { .rgb => { // RGB sliders const slider_h: u32 = 20; const slider_spacing: u32 = 4; // R slider if (drawSlider(ctx, bounds.x + padding, y, content_w, slider_h, state.current.r, 255, Style.Color.rgba(255, 0, 0, 255), mouse, mouse_pressed, mouse_down, state.dragging == .r_slider)) |new_val| { state.current.r = new_val; state.setColor(state.current); result.changed = true; } if (mouse_pressed and Layout.Rect.init(bounds.x + padding, y, content_w, slider_h).contains(mouse.x, mouse.y)) { state.dragging = .r_slider; } ctx.pushCommand(Command.text(bounds.x + padding, y + 6, "R", colors.text)); y += @as(i32, @intCast(slider_h + slider_spacing)); // G slider if (drawSlider(ctx, bounds.x + padding, y, content_w, slider_h, state.current.g, 255, Style.Color.rgba(0, 255, 0, 255), mouse, mouse_pressed, mouse_down, state.dragging == .g_slider)) |new_val| { state.current.g = new_val; state.setColor(state.current); result.changed = true; } if (mouse_pressed and Layout.Rect.init(bounds.x + padding, y, content_w, slider_h).contains(mouse.x, mouse.y)) { state.dragging = .g_slider; } ctx.pushCommand(Command.text(bounds.x + padding, y + 6, "G", colors.text)); y += @as(i32, @intCast(slider_h + slider_spacing)); // B slider if (drawSlider(ctx, bounds.x + padding, y, content_w, slider_h, state.current.b, 255, Style.Color.rgba(0, 0, 255, 255), mouse, mouse_pressed, mouse_down, state.dragging == .b_slider)) |new_val| { state.current.b = new_val; state.setColor(state.current); result.changed = true; } if (mouse_pressed and Layout.Rect.init(bounds.x + padding, y, content_w, slider_h).contains(mouse.x, mouse.y)) { state.dragging = .b_slider; } ctx.pushCommand(Command.text(bounds.x + padding, y + 6, "B", colors.text)); y += @as(i32, @intCast(slider_h + slider_spacing)); }, .hsl => { // Hue bar const hue_h: u32 = 16; drawHueBar(ctx, bounds.x + padding, y, content_w, hue_h); // Draw hue indicator const hue_x = bounds.x + padding + @as(i32, @intFromFloat(state.hue / 360.0 * @as(f32, @floatFromInt(content_w)))); ctx.pushCommand(Command.rect(hue_x - 1, y - 2, 3, hue_h + 4, Style.Color.rgba(255, 255, 255, 255))); const hue_rect = Layout.Rect.init(bounds.x + padding, y, content_w, hue_h); if ((mouse_pressed and hue_rect.contains(mouse.x, mouse.y)) or (mouse_down and state.dragging == .hue_bar)) { if (mouse_pressed) state.dragging = .hue_bar; const rel_x = @max(0, mouse.x - (bounds.x + padding)); state.hue = @as(f32, @floatFromInt(rel_x)) / @as(f32, @floatFromInt(content_w)) * 360.0; state.hue = @min(360.0, @max(0.0, state.hue)); state.updateFromHsl(); result.changed = true; } y += @as(i32, @intCast(hue_h)) + 8; // SL area const sl_size = @min(content_w, config.color_area_height); drawSLArea(ctx, bounds.x + padding, y, sl_size, sl_size, state.hue); // Draw SL indicator const sl_x = bounds.x + padding + @as(i32, @intFromFloat(state.saturation * @as(f32, @floatFromInt(sl_size)))); const sl_y = y + @as(i32, @intFromFloat((1.0 - state.lightness) * @as(f32, @floatFromInt(sl_size)))); ctx.pushCommand(Command.rectOutline(sl_x - 3, sl_y - 3, 7, 7, Style.Color.rgba(255, 255, 255, 255))); ctx.pushCommand(Command.rectOutline(sl_x - 2, sl_y - 2, 5, 5, Style.Color.rgba(0, 0, 0, 255))); const sl_rect = Layout.Rect.init(bounds.x + padding, y, sl_size, sl_size); if ((mouse_pressed and sl_rect.contains(mouse.x, mouse.y)) or (mouse_down and state.dragging == .sl_area)) { if (mouse_pressed) state.dragging = .sl_area; const rel_x = @max(0, mouse.x - (bounds.x + padding)); const rel_y = @max(0, mouse.y - y); state.saturation = @as(f32, @floatFromInt(rel_x)) / @as(f32, @floatFromInt(sl_size)); state.lightness = 1.0 - @as(f32, @floatFromInt(rel_y)) / @as(f32, @floatFromInt(sl_size)); state.saturation = @min(1.0, @max(0.0, state.saturation)); state.lightness = @min(1.0, @max(0.0, state.lightness)); state.updateFromHsl(); result.changed = true; } y += @as(i32, @intCast(sl_size)) + 8; }, .palette => { // Draw palette grid if (config.palette) |palette| { const swatch_size: u32 = 24; const swatch_spacing: u32 = 4; const swatches_per_row = (content_w + swatch_spacing) / (swatch_size + swatch_spacing); for (palette, 0..) |color, i| { const row = i / swatches_per_row; const col = i % swatches_per_row; const swatch_x = bounds.x + padding + @as(i32, @intCast(col * (swatch_size + swatch_spacing))); const swatch_y = y + @as(i32, @intCast(row * (swatch_size + swatch_spacing))); ctx.pushCommand(Command.rect(swatch_x, swatch_y, swatch_size, swatch_size, color)); ctx.pushCommand(Command.rectOutline(swatch_x, swatch_y, swatch_size, swatch_size, colors.border)); const swatch_rect = Layout.Rect.init(swatch_x, swatch_y, swatch_size, swatch_size); if (swatch_rect.contains(mouse.x, mouse.y) and mouse_pressed) { state.setColor(color); result.changed = true; } } const rows = (palette.len + swatches_per_row - 1) / swatches_per_row; y += @as(i32, @intCast(rows * (swatch_size + swatch_spacing))) + 8; } else { // Default palette const default_palette = [_]Style.Color{ Style.Color.rgba(255, 0, 0, 255), Style.Color.rgba(255, 128, 0, 255), Style.Color.rgba(255, 255, 0, 255), Style.Color.rgba(0, 255, 0, 255), Style.Color.rgba(0, 255, 255, 255), Style.Color.rgba(0, 0, 255, 255), Style.Color.rgba(128, 0, 255, 255), Style.Color.rgba(255, 0, 255, 255), Style.Color.rgba(0, 0, 0, 255), Style.Color.rgba(128, 128, 128, 255), Style.Color.rgba(255, 255, 255, 255), }; const swatch_size: u32 = 24; const swatch_spacing: u32 = 4; for (default_palette, 0..) |color, i| { const col = i % 6; const row = i / 6; const swatch_x = bounds.x + padding + @as(i32, @intCast(col * (swatch_size + swatch_spacing))); const swatch_y = y + @as(i32, @intCast(row * (swatch_size + swatch_spacing))); ctx.pushCommand(Command.rect(swatch_x, swatch_y, swatch_size, swatch_size, color)); const swatch_rect = Layout.Rect.init(swatch_x, swatch_y, swatch_size, swatch_size); if (swatch_rect.contains(mouse.x, mouse.y) and mouse_pressed) { state.setColor(color); result.changed = true; } } y += 60; } }, } // Alpha slider if (config.show_alpha) { const alpha_h: u32 = 16; ctx.pushCommand(Command.text(bounds.x + padding, y + 4, "A", colors.text)); const alpha_x = bounds.x + padding + 16; const alpha_w = content_w - 16; drawCheckerboard(ctx, alpha_x, y, alpha_w, alpha_h); // Draw alpha gradient const alpha_val: u8 = @intFromFloat(state.alpha * 255); ctx.pushCommand(Command.rect( alpha_x, y, @intFromFloat(@as(f32, @floatFromInt(alpha_w)) * state.alpha), alpha_h, Style.Color.rgba(state.current.r, state.current.g, state.current.b, alpha_val), )); ctx.pushCommand(Command.rectOutline(alpha_x, y, alpha_w, alpha_h, colors.border)); const alpha_rect = Layout.Rect.init(alpha_x, y, alpha_w, alpha_h); if ((mouse_pressed and alpha_rect.contains(mouse.x, mouse.y)) or (mouse_down and state.dragging == .alpha_bar)) { if (mouse_pressed) state.dragging = .alpha_bar; const rel_x = @max(0, mouse.x - alpha_x); state.alpha = @as(f32, @floatFromInt(rel_x)) / @as(f32, @floatFromInt(alpha_w)); state.alpha = @min(1.0, @max(0.0, state.alpha)); state.current.a = @intFromFloat(state.alpha * 255); result.changed = true; } y += @as(i32, @intCast(alpha_h)) + 8; } // Preview if (config.show_preview) { const preview_h: u32 = 32; const preview_w = content_w / 2; // Original color ctx.pushCommand(Command.rect(bounds.x + padding, y, preview_w, preview_h, state.original)); ctx.pushCommand(Command.text(bounds.x + padding + 2, y + 12, "Old", colors.text)); // New color ctx.pushCommand(Command.rect(bounds.x + padding + @as(i32, @intCast(preview_w)), y, preview_w, preview_h, state.current)); ctx.pushCommand(Command.text(bounds.x + padding + @as(i32, @intCast(preview_w)) + 2, y + 12, "New", colors.text)); ctx.pushCommand(Command.rectOutline(bounds.x + padding, y, content_w, preview_h, colors.border)); _ = y + @as(i32, @intCast(preview_h)) + 8; } // Handle mouse release if (mouse_released) { state.dragging = null; } return result; } /// Draw a color slider fn drawSlider( ctx: *Context, x: i32, y: i32, w: u32, h: u32, value: u8, max: u8, color: Style.Color, mouse: anytype, mouse_pressed: bool, mouse_down: bool, is_dragging: bool, ) ?u8 { const slider_x = x + 16; const slider_w = w - 16; // Draw track ctx.pushCommand(Command.rect(slider_x, y + 2, slider_w, h - 4, Style.Color.rgba(50, 50, 50, 255))); // Draw filled portion const fill_w = @as(u32, @intCast(value)) * slider_w / @as(u32, @intCast(max)); ctx.pushCommand(Command.rect(slider_x, y + 2, fill_w, h - 4, color)); // Draw handle const handle_x = slider_x + @as(i32, @intCast(fill_w)) - 2; ctx.pushCommand(Command.rect(handle_x, y, 4, h, Style.Color.rgba(255, 255, 255, 255))); // Check interaction const slider_rect = Layout.Rect.init(slider_x, y, slider_w, h); if ((mouse_pressed and slider_rect.contains(mouse.x, mouse.y)) or (mouse_down and is_dragging)) { const rel_x = @max(0, mouse.x - slider_x); const new_val = @as(u8, @intCast(@min(@as(u32, @intCast(max)), @as(u32, @intCast(rel_x)) * @as(u32, @intCast(max)) / slider_w))); return new_val; } return null; } /// Draw hue bar (rainbow gradient) fn drawHueBar(ctx: *Context, x: i32, y: i32, w: u32, h: u32) void { // Draw simplified hue gradient (6 segments) const colors_array = [_]Style.Color{ Style.Color.rgba(255, 0, 0, 255), // Red Style.Color.rgba(255, 255, 0, 255), // Yellow Style.Color.rgba(0, 255, 0, 255), // Green Style.Color.rgba(0, 255, 255, 255), // Cyan Style.Color.rgba(0, 0, 255, 255), // Blue Style.Color.rgba(255, 0, 255, 255), // Magenta Style.Color.rgba(255, 0, 0, 255), // Red (wrap) }; const segment_w = w / 6; for (0..6) |i| { const seg_x = x + @as(i32, @intCast(i * segment_w)); // Blend between colors (simplified - just use solid color per segment) ctx.pushCommand(Command.rect(seg_x, y, segment_w, h, colors_array[i])); } } /// Draw saturation/lightness area fn drawSLArea(ctx: *Context, x: i32, y: i32, w: u32, h: u32, hue: f32) void { // Draw base color at full saturation const rgb = hslToRgb(hue, 1.0, 0.5); ctx.pushCommand(Command.rect(x, y, w, h, Style.Color.rgba(rgb.r, rgb.g, rgb.b, 255))); // Overlay white gradient (left to right) ctx.pushCommand(Command.rect(x, y, w / 3, h, Style.Color.rgba(255, 255, 255, 128))); // Overlay black gradient (top to bottom) - simplified ctx.pushCommand(Command.rect(x, y + @as(i32, @intCast(h * 2 / 3)), w, h / 3, Style.Color.rgba(0, 0, 0, 128))); } /// Draw checkerboard pattern (for alpha preview) fn drawCheckerboard(ctx: *Context, x: i32, y: i32, w: u32, h: u32) void { const check_size: u32 = 8; var cy: u32 = 0; while (cy < h) : (cy += check_size) { var cx: u32 = 0; while (cx < w) : (cx += check_size) { const is_light = ((cx / check_size) + (cy / check_size)) % 2 == 0; const color = if (is_light) Style.Color.rgba(200, 200, 200, 255) else Style.Color.rgba(150, 150, 150, 255); ctx.pushCommand(Command.rect( x + @as(i32, @intCast(cx)), y + @as(i32, @intCast(cy)), @min(check_size, w - cx), @min(check_size, h - cy), color, )); } } } /// Convert RGB to HSL fn rgbToHsl(r: u8, g: u8, b: u8) struct { h: f32, s: f32, l: f32 } { const rf = @as(f32, @floatFromInt(r)) / 255.0; const gf = @as(f32, @floatFromInt(g)) / 255.0; const bf = @as(f32, @floatFromInt(b)) / 255.0; const max_val = @max(rf, @max(gf, bf)); const min_val = @min(rf, @min(gf, bf)); const diff = max_val - min_val; var h: f32 = 0; var s: f32 = 0; const l = (max_val + min_val) / 2; if (diff > 0) { s = if (l > 0.5) diff / (2 - max_val - min_val) else diff / (max_val + min_val); if (max_val == rf) { h = (gf - bf) / diff + (if (gf < bf) @as(f32, 6) else 0); } else if (max_val == gf) { h = (bf - rf) / diff + 2; } else { h = (rf - gf) / diff + 4; } h *= 60; } return .{ .h = h, .s = s, .l = l }; } /// Convert HSL to RGB fn hslToRgb(h: f32, s: f32, l: f32) struct { r: u8, g: u8, b: u8 } { if (s == 0) { const gray = @as(u8, @intFromFloat(l * 255)); return .{ .r = gray, .g = gray, .b = gray }; } const q = if (l < 0.5) l * (1 + s) else l + s - l * s; const p = 2 * l - q; const hue_to_rgb = struct { fn f(p_val: f32, q_val: f32, t_val: f32) f32 { var t = t_val; if (t < 0) t += 1; if (t > 1) t -= 1; if (t < 1.0 / 6.0) return p_val + (q_val - p_val) * 6 * t; if (t < 1.0 / 2.0) return q_val; if (t < 2.0 / 3.0) return p_val + (q_val - p_val) * (2.0 / 3.0 - t) * 6; return p_val; } }.f; const h_norm = h / 360.0; const r = hue_to_rgb(p, q, h_norm + 1.0 / 3.0); const g_val = hue_to_rgb(p, q, h_norm); const b = hue_to_rgb(p, q, h_norm - 1.0 / 3.0); return .{ .r = @intFromFloat(r * 255), .g = @intFromFloat(g_val * 255), .b = @intFromFloat(b * 255), }; } // ============================================================================= // Tests // ============================================================================= test "State setColor" { var state = State{}; state.setColor(Style.Color.rgba(255, 0, 0, 255)); try std.testing.expectEqual(@as(u8, 255), state.current.r); try std.testing.expectEqual(@as(u8, 0), state.current.g); try std.testing.expect(state.hue > 350 or state.hue < 10); // Red is ~0 degrees } test "State updateFromHsl" { var state = State{}; state.hue = 120; // Green state.saturation = 1.0; state.lightness = 0.5; state.alpha = 1.0; state.updateFromHsl(); try std.testing.expectEqual(@as(u8, 0), state.current.r); try std.testing.expectEqual(@as(u8, 255), state.current.g); try std.testing.expectEqual(@as(u8, 0), state.current.b); } test "rgbToHsl and hslToRgb" { // Test red const hsl = rgbToHsl(255, 0, 0); try std.testing.expect(hsl.h < 1 or hsl.h > 359); // Test round-trip const rgb = hslToRgb(120, 1.0, 0.5); // Green try std.testing.expectEqual(@as(u8, 0), rgb.r); try std.testing.expectEqual(@as(u8, 255), rgb.g); try std.testing.expectEqual(@as(u8, 0), rgb.b); } test "colorPicker generates commands" { var ctx = try Context.init(std.testing.allocator, 800, 600); defer ctx.deinit(); var state = State{}; ctx.beginFrame(); ctx.layout.row_height = 300; _ = colorPicker(&ctx, &state); try std.testing.expect(ctx.commands.items.len >= 2); ctx.endFrame(); }