New Widgets (4): - Image: Display images with RGBA/RGB/grayscale support, fit modes (contain, cover, fill, scale_down), LRU cache - ReorderableList: Drag and drop list reordering with drag handle, remove button, add button support - ColorPicker: RGB/HSL/Palette modes, alpha slider, preview comparison, recent colors - DatePicker: Calendar view with month navigation, range selection, min/max dates, week numbers Widget count: 27 widgets Test count: 186 tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
626 lines
23 KiB
Zig
626 lines
23 KiB
Zig
//! 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();
|
|
}
|