feat: zcatgui v0.9.0 - Phase 3 Specialized Widgets
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>
This commit is contained in:
parent
8044c1df43
commit
a75827f70b
5 changed files with 2182 additions and 0 deletions
626
src/widgets/colorpicker.zig
Normal file
626
src/widgets/colorpicker.zig
Normal file
|
|
@ -0,0 +1,626 @@
|
|||
//! 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();
|
||||
}
|
||||
554
src/widgets/datepicker.zig
Normal file
554
src/widgets/datepicker.zig
Normal file
|
|
@ -0,0 +1,554 @@
|
|||
//! DatePicker Widget - Date selection with calendar
|
||||
//!
|
||||
//! A date picker with calendar view, supporting single date
|
||||
//! and date range selection.
|
||||
|
||||
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");
|
||||
|
||||
/// Date structure
|
||||
pub const Date = struct {
|
||||
year: u16,
|
||||
month: u8, // 1-12
|
||||
day: u8, // 1-31
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Create a date
|
||||
pub fn init(year: u16, month: u8, day: u8) Self {
|
||||
return .{
|
||||
.year = year,
|
||||
.month = @min(12, @max(1, month)),
|
||||
.day = @min(31, @max(1, day)),
|
||||
};
|
||||
}
|
||||
|
||||
/// Get today's date (simplified - uses epoch calculation)
|
||||
pub fn today() Self {
|
||||
const ts = std.time.timestamp();
|
||||
return fromTimestamp(ts);
|
||||
}
|
||||
|
||||
/// Create from unix timestamp
|
||||
pub fn fromTimestamp(ts: i64) Self {
|
||||
// Simplified calculation - doesn't handle all edge cases
|
||||
const days_since_epoch = @divTrunc(ts, 86400);
|
||||
const remaining_days = days_since_epoch + 719468; // Days from year 0
|
||||
|
||||
const era: i64 = @divTrunc(if (remaining_days >= 0) remaining_days else remaining_days - 146096, 146097);
|
||||
const doe: u32 = @intCast(remaining_days - era * 146097);
|
||||
const yoe = @divTrunc(doe - @divTrunc(doe, 1460) + @divTrunc(doe, 36524) - @divTrunc(doe, 146096), 365);
|
||||
const y: i64 = @as(i64, @intCast(yoe)) + era * 400;
|
||||
const doy = doe - (365 * yoe + @divTrunc(yoe, 4) - @divTrunc(yoe, 100));
|
||||
const mp = @divTrunc(5 * doy + 2, 153);
|
||||
const d: u8 = @intCast(doy - @divTrunc(153 * mp + 2, 5) + 1);
|
||||
const m: u8 = @intCast(if (mp < 10) mp + 3 else mp - 9);
|
||||
const year: u16 = @intCast(y + @as(i64, if (m <= 2) 1 else 0));
|
||||
|
||||
return .{ .year = year, .month = m, .day = d };
|
||||
}
|
||||
|
||||
/// Check if dates are equal
|
||||
pub fn eql(self: Self, other: Self) bool {
|
||||
return self.year == other.year and self.month == other.month and self.day == other.day;
|
||||
}
|
||||
|
||||
/// Compare dates
|
||||
pub fn compare(self: Self, other: Self) std.math.Order {
|
||||
if (self.year != other.year) {
|
||||
return std.math.order(self.year, other.year);
|
||||
}
|
||||
if (self.month != other.month) {
|
||||
return std.math.order(self.month, other.month);
|
||||
}
|
||||
return std.math.order(self.day, other.day);
|
||||
}
|
||||
|
||||
/// Check if this date is before another
|
||||
pub fn isBefore(self: Self, other: Self) bool {
|
||||
return self.compare(other) == .lt;
|
||||
}
|
||||
|
||||
/// Check if this date is after another
|
||||
pub fn isAfter(self: Self, other: Self) bool {
|
||||
return self.compare(other) == .gt;
|
||||
}
|
||||
|
||||
/// Get days in month
|
||||
pub fn daysInMonth(self: Self) u8 {
|
||||
return getDaysInMonth(self.year, self.month);
|
||||
}
|
||||
|
||||
/// Get day of week (0 = Sunday, 6 = Saturday)
|
||||
pub fn dayOfWeek(self: Self) u8 {
|
||||
// Zeller's congruence
|
||||
var y = @as(i32, self.year);
|
||||
var m = @as(i32, self.month);
|
||||
|
||||
if (m < 3) {
|
||||
m += 12;
|
||||
y -= 1;
|
||||
}
|
||||
|
||||
const q = @as(i32, self.day);
|
||||
const k = @mod(y, 100);
|
||||
const j = @divTrunc(y, 100);
|
||||
|
||||
const h = @mod(q + @divTrunc(13 * (m + 1), 5) + k + @divTrunc(k, 4) + @divTrunc(j, 4) - 2 * j, 7);
|
||||
|
||||
// Convert to 0=Sunday
|
||||
return @intCast(@mod(h + 6, 7));
|
||||
}
|
||||
|
||||
/// Format as string (YYYY-MM-DD)
|
||||
pub fn format(self: Self, buf: []u8) []const u8 {
|
||||
const result = std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}", .{
|
||||
self.year,
|
||||
self.month,
|
||||
self.day,
|
||||
}) catch return "";
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
/// Get days in a month
|
||||
fn getDaysInMonth(year: u16, month: u8) u8 {
|
||||
const days = [_]u8{ 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 };
|
||||
if (month < 1 or month > 12) return 0;
|
||||
|
||||
if (month == 2 and isLeapYear(year)) {
|
||||
return 29;
|
||||
}
|
||||
return days[month - 1];
|
||||
}
|
||||
|
||||
/// Check if year is a leap year
|
||||
fn isLeapYear(year: u16) bool {
|
||||
return (year % 4 == 0 and year % 100 != 0) or (year % 400 == 0);
|
||||
}
|
||||
|
||||
/// Date picker state
|
||||
pub const State = struct {
|
||||
/// Selected date
|
||||
selected: ?Date = null,
|
||||
/// Range start (for range selection)
|
||||
range_start: ?Date = null,
|
||||
/// Range end (for range selection)
|
||||
range_end: ?Date = null,
|
||||
/// Currently viewed month
|
||||
view_month: u8 = 1,
|
||||
/// Currently viewed year
|
||||
view_year: u16 = 2025,
|
||||
/// Is picker open (for popup variant)
|
||||
open: bool = false,
|
||||
/// Hover date
|
||||
hover_date: ?Date = null,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Initialize with today's date
|
||||
pub fn init() Self {
|
||||
const today = Date.today();
|
||||
return .{
|
||||
.view_month = today.month,
|
||||
.view_year = today.year,
|
||||
};
|
||||
}
|
||||
|
||||
/// Navigate to previous month
|
||||
pub fn prevMonth(self: *Self) void {
|
||||
if (self.view_month == 1) {
|
||||
self.view_month = 12;
|
||||
self.view_year -|= 1;
|
||||
} else {
|
||||
self.view_month -= 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to next month
|
||||
pub fn nextMonth(self: *Self) void {
|
||||
if (self.view_month == 12) {
|
||||
self.view_month = 1;
|
||||
self.view_year += 1;
|
||||
} else {
|
||||
self.view_month += 1;
|
||||
}
|
||||
}
|
||||
|
||||
/// Navigate to previous year
|
||||
pub fn prevYear(self: *Self) void {
|
||||
self.view_year -|= 1;
|
||||
}
|
||||
|
||||
/// Navigate to next year
|
||||
pub fn nextYear(self: *Self) void {
|
||||
self.view_year += 1;
|
||||
}
|
||||
|
||||
/// Set view to show selected date
|
||||
pub fn showSelected(self: *Self) void {
|
||||
if (self.selected) |date| {
|
||||
self.view_month = date.month;
|
||||
self.view_year = date.year;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Date picker configuration
|
||||
pub const Config = struct {
|
||||
/// Minimum selectable date
|
||||
min_date: ?Date = null,
|
||||
/// Maximum selectable date
|
||||
max_date: ?Date = null,
|
||||
/// First day of week (0 = Sunday, 1 = Monday)
|
||||
first_day_of_week: u8 = 1,
|
||||
/// Show week numbers
|
||||
show_week_numbers: bool = false,
|
||||
/// Enable range selection
|
||||
range_selection: bool = false,
|
||||
/// Show navigation arrows
|
||||
show_navigation: bool = true,
|
||||
/// Cell size
|
||||
cell_size: u32 = 28,
|
||||
};
|
||||
|
||||
/// Date picker colors
|
||||
pub const Colors = struct {
|
||||
background: Style.Color = Style.Color.rgba(40, 40, 40, 255),
|
||||
header_bg: Style.Color = Style.Color.rgba(50, 50, 50, 255),
|
||||
text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
||||
text_muted: Style.Color = Style.Color.rgba(120, 120, 120, 255),
|
||||
today: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
||||
selected: Style.Color = Style.Color.rgba(70, 130, 180, 255),
|
||||
range: Style.Color = Style.Color.rgba(70, 130, 180, 100),
|
||||
hover: Style.Color = Style.Color.rgba(60, 60, 60, 255),
|
||||
disabled: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||
weekend: Style.Color = Style.Color.rgba(180, 100, 100, 255),
|
||||
|
||||
pub fn fromTheme(theme: Style.Theme) Colors {
|
||||
return .{
|
||||
.background = theme.background,
|
||||
.header_bg = theme.background.lighten(10),
|
||||
.text = theme.foreground,
|
||||
.text_muted = theme.secondary,
|
||||
.today = theme.primary,
|
||||
.selected = theme.selection_bg,
|
||||
.range = theme.selection_bg.withAlpha(100),
|
||||
.hover = theme.background.lighten(15),
|
||||
.disabled = theme.secondary,
|
||||
.border = theme.border,
|
||||
.weekend = theme.error_color.lighten(30),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Date picker result
|
||||
pub const Result = struct {
|
||||
/// Date was selected/changed
|
||||
changed: bool = false,
|
||||
/// Selected date
|
||||
date: ?Date = null,
|
||||
/// Selected range (if range selection enabled)
|
||||
range_start: ?Date = null,
|
||||
range_end: ?Date = null,
|
||||
};
|
||||
|
||||
/// Draw a date picker / calendar
|
||||
pub fn datePicker(ctx: *Context, state: *State) Result {
|
||||
return datePickerEx(ctx, state, .{}, .{});
|
||||
}
|
||||
|
||||
/// Draw a date picker with configuration
|
||||
pub fn datePickerEx(
|
||||
ctx: *Context,
|
||||
state: *State,
|
||||
config: Config,
|
||||
colors: Colors,
|
||||
) Result {
|
||||
const bounds = ctx.layout.nextRect();
|
||||
return datePickerRect(ctx, bounds, state, config, colors);
|
||||
}
|
||||
|
||||
/// Draw a date picker in specific rectangle
|
||||
pub fn datePickerRect(
|
||||
ctx: *Context,
|
||||
bounds: Layout.Rect,
|
||||
state: *State,
|
||||
config: Config,
|
||||
colors: Colors,
|
||||
) Result {
|
||||
var result = Result{
|
||||
.date = state.selected,
|
||||
.range_start = state.range_start,
|
||||
.range_end = state.range_end,
|
||||
};
|
||||
|
||||
if (bounds.isEmpty()) return result;
|
||||
|
||||
const mouse = ctx.input.mousePos();
|
||||
const mouse_pressed = ctx.input.mousePressed(.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));
|
||||
|
||||
const padding: i32 = 8;
|
||||
var y = bounds.y + padding;
|
||||
|
||||
// Header with month/year and navigation
|
||||
const header_h: u32 = 24;
|
||||
|
||||
ctx.pushCommand(Command.rect(
|
||||
bounds.x + padding,
|
||||
y,
|
||||
bounds.w -| @as(u32, @intCast(padding * 2)),
|
||||
header_h,
|
||||
colors.header_bg,
|
||||
));
|
||||
|
||||
// Month/Year text
|
||||
var month_buf: [32]u8 = undefined;
|
||||
const month_names = [_][]const u8{
|
||||
"January", "February", "March", "April", "May", "June",
|
||||
"July", "August", "September", "October", "November", "December",
|
||||
};
|
||||
const month_name = if (state.view_month >= 1 and state.view_month <= 12)
|
||||
month_names[state.view_month - 1]
|
||||
else
|
||||
"???";
|
||||
|
||||
const header_text = std.fmt.bufPrint(&month_buf, "{s} {d}", .{ month_name, state.view_year }) catch "???";
|
||||
const text_x = bounds.x + @as(i32, @intCast(bounds.w / 2)) - @as(i32, @intCast(header_text.len * 4));
|
||||
ctx.pushCommand(Command.text(text_x, y + 8, header_text, colors.text));
|
||||
|
||||
// Navigation arrows
|
||||
if (config.show_navigation) {
|
||||
const nav_y = y + 8;
|
||||
|
||||
// Prev month
|
||||
const prev_rect = Layout.Rect.init(bounds.x + padding, y, 24, header_h);
|
||||
ctx.pushCommand(Command.text(bounds.x + padding + 8, nav_y, "<", colors.text));
|
||||
if (prev_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
||||
state.prevMonth();
|
||||
}
|
||||
|
||||
// Next month
|
||||
const next_rect = Layout.Rect.init(
|
||||
bounds.x + @as(i32, @intCast(bounds.w)) - padding - 24,
|
||||
y,
|
||||
24,
|
||||
header_h,
|
||||
);
|
||||
ctx.pushCommand(Command.text(next_rect.x + 8, nav_y, ">", colors.text));
|
||||
if (next_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
||||
state.nextMonth();
|
||||
}
|
||||
}
|
||||
|
||||
y += @as(i32, @intCast(header_h)) + 4;
|
||||
|
||||
// Day headers
|
||||
const cell_size = config.cell_size;
|
||||
const week_num_w: u32 = if (config.show_week_numbers) 24 else 0;
|
||||
const grid_x = bounds.x + padding + @as(i32, @intCast(week_num_w));
|
||||
|
||||
const day_headers = if (config.first_day_of_week == 0)
|
||||
[_][]const u8{ "Su", "Mo", "Tu", "We", "Th", "Fr", "Sa" }
|
||||
else
|
||||
[_][]const u8{ "Mo", "Tu", "We", "Th", "Fr", "Sa", "Su" };
|
||||
|
||||
for (day_headers, 0..) |header, i| {
|
||||
const hx = grid_x + @as(i32, @intCast(i * cell_size));
|
||||
ctx.pushCommand(Command.text(hx + 8, y, header, colors.text_muted));
|
||||
}
|
||||
|
||||
y += 16;
|
||||
|
||||
// Calendar grid
|
||||
const first_day = Date.init(state.view_year, state.view_month, 1);
|
||||
var first_dow = first_day.dayOfWeek();
|
||||
|
||||
// Adjust for first day of week
|
||||
if (config.first_day_of_week == 1) {
|
||||
first_dow = if (first_dow == 0) 6 else first_dow - 1;
|
||||
}
|
||||
|
||||
const days_in_month = first_day.daysInMonth();
|
||||
const today = Date.today();
|
||||
|
||||
var day: u8 = 1;
|
||||
var row: u32 = 0;
|
||||
|
||||
while (day <= days_in_month) {
|
||||
const row_y = y + @as(i32, @intCast(row * cell_size));
|
||||
|
||||
// Week number
|
||||
if (config.show_week_numbers and row == 0) {
|
||||
// Simplified week number calculation
|
||||
var week_buf: [4]u8 = undefined;
|
||||
const week_text = std.fmt.bufPrint(&week_buf, "{d}", .{row + 1}) catch "";
|
||||
ctx.pushCommand(Command.text(bounds.x + padding + 4, row_y + 8, week_text, colors.text_muted));
|
||||
}
|
||||
|
||||
var col: u32 = if (row == 0) first_dow else 0;
|
||||
while (col < 7 and day <= days_in_month) : (col += 1) {
|
||||
const cell_x = grid_x + @as(i32, @intCast(col * cell_size));
|
||||
const cell_rect = Layout.Rect.init(cell_x, row_y, cell_size, cell_size);
|
||||
const current_date = Date.init(state.view_year, state.view_month, day);
|
||||
|
||||
// Check if date is disabled
|
||||
const is_disabled = (config.min_date != null and current_date.isBefore(config.min_date.?)) or
|
||||
(config.max_date != null and current_date.isAfter(config.max_date.?));
|
||||
|
||||
// Check states
|
||||
const is_today = current_date.eql(today);
|
||||
const is_selected = state.selected != null and current_date.eql(state.selected.?);
|
||||
const is_hovered = cell_rect.contains(mouse.x, mouse.y);
|
||||
const is_weekend = (col == 5 or col == 6); // Sat/Sun when Monday first
|
||||
|
||||
// Check if in range
|
||||
var is_in_range = false;
|
||||
if (config.range_selection and state.range_start != null and state.range_end != null) {
|
||||
is_in_range = !current_date.isBefore(state.range_start.?) and
|
||||
!current_date.isAfter(state.range_end.?);
|
||||
}
|
||||
|
||||
// Draw cell background
|
||||
if (is_selected) {
|
||||
ctx.pushCommand(Command.rect(cell_x, row_y, cell_size, cell_size, colors.selected));
|
||||
} else if (is_in_range) {
|
||||
ctx.pushCommand(Command.rect(cell_x, row_y, cell_size, cell_size, colors.range));
|
||||
} else if (is_hovered and !is_disabled) {
|
||||
ctx.pushCommand(Command.rect(cell_x, row_y, cell_size, cell_size, colors.hover));
|
||||
}
|
||||
|
||||
// Draw today indicator
|
||||
if (is_today) {
|
||||
ctx.pushCommand(Command.rectOutline(cell_x + 2, row_y + 2, cell_size - 4, cell_size - 4, colors.today));
|
||||
}
|
||||
|
||||
// Draw day number
|
||||
var day_buf: [4]u8 = undefined;
|
||||
const day_text = std.fmt.bufPrint(&day_buf, "{d}", .{day}) catch "";
|
||||
const text_color = if (is_disabled)
|
||||
colors.disabled
|
||||
else if (is_selected)
|
||||
colors.text
|
||||
else if (is_weekend)
|
||||
colors.weekend
|
||||
else
|
||||
colors.text;
|
||||
|
||||
const tx = cell_x + @as(i32, @intCast((cell_size - day_text.len * 8) / 2));
|
||||
ctx.pushCommand(Command.text(tx, row_y + @as(i32, @intCast((cell_size - 8) / 2)), day_text, text_color));
|
||||
|
||||
// Handle click
|
||||
if (is_hovered and mouse_pressed and !is_disabled) {
|
||||
if (config.range_selection) {
|
||||
if (state.range_start == null or state.range_end != null) {
|
||||
state.range_start = current_date;
|
||||
state.range_end = null;
|
||||
} else {
|
||||
if (current_date.isBefore(state.range_start.?)) {
|
||||
state.range_end = state.range_start;
|
||||
state.range_start = current_date;
|
||||
} else {
|
||||
state.range_end = current_date;
|
||||
}
|
||||
}
|
||||
result.changed = true;
|
||||
result.range_start = state.range_start;
|
||||
result.range_end = state.range_end;
|
||||
} else {
|
||||
state.selected = current_date;
|
||||
result.changed = true;
|
||||
result.date = current_date;
|
||||
}
|
||||
}
|
||||
|
||||
day += 1;
|
||||
}
|
||||
|
||||
row += 1;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "Date init" {
|
||||
const date = Date.init(2025, 12, 25);
|
||||
try std.testing.expectEqual(@as(u16, 2025), date.year);
|
||||
try std.testing.expectEqual(@as(u8, 12), date.month);
|
||||
try std.testing.expectEqual(@as(u8, 25), date.day);
|
||||
}
|
||||
|
||||
test "Date compare" {
|
||||
const d1 = Date.init(2025, 1, 1);
|
||||
const d2 = Date.init(2025, 1, 2);
|
||||
const d3 = Date.init(2025, 1, 1);
|
||||
|
||||
try std.testing.expect(d1.isBefore(d2));
|
||||
try std.testing.expect(d2.isAfter(d1));
|
||||
try std.testing.expect(d1.eql(d3));
|
||||
}
|
||||
|
||||
test "Date daysInMonth" {
|
||||
try std.testing.expectEqual(@as(u8, 31), Date.init(2025, 1, 1).daysInMonth());
|
||||
try std.testing.expectEqual(@as(u8, 28), Date.init(2025, 2, 1).daysInMonth());
|
||||
try std.testing.expectEqual(@as(u8, 29), Date.init(2024, 2, 1).daysInMonth()); // Leap year
|
||||
try std.testing.expectEqual(@as(u8, 30), Date.init(2025, 4, 1).daysInMonth());
|
||||
}
|
||||
|
||||
test "isLeapYear" {
|
||||
try std.testing.expect(isLeapYear(2024));
|
||||
try std.testing.expect(!isLeapYear(2025));
|
||||
try std.testing.expect(isLeapYear(2000));
|
||||
try std.testing.expect(!isLeapYear(1900));
|
||||
}
|
||||
|
||||
test "State navigation" {
|
||||
var state = State{
|
||||
.view_month = 1,
|
||||
.view_year = 2025,
|
||||
};
|
||||
|
||||
state.prevMonth();
|
||||
try std.testing.expectEqual(@as(u8, 12), state.view_month);
|
||||
try std.testing.expectEqual(@as(u16, 2024), state.view_year);
|
||||
|
||||
state.nextMonth();
|
||||
try std.testing.expectEqual(@as(u8, 1), state.view_month);
|
||||
try std.testing.expectEqual(@as(u16, 2025), state.view_year);
|
||||
}
|
||||
|
||||
test "datePicker generates commands" {
|
||||
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||
defer ctx.deinit();
|
||||
|
||||
var state = State.init();
|
||||
|
||||
ctx.beginFrame();
|
||||
ctx.layout.row_height = 300;
|
||||
|
||||
_ = datePicker(&ctx, &state);
|
||||
|
||||
try std.testing.expect(ctx.commands.items.len >= 2);
|
||||
|
||||
ctx.endFrame();
|
||||
}
|
||||
|
||||
test "Date format" {
|
||||
const date = Date.init(2025, 12, 9);
|
||||
var buf: [16]u8 = undefined;
|
||||
const formatted = date.format(&buf);
|
||||
try std.testing.expectEqualStrings("2025-12-09", formatted);
|
||||
}
|
||||
518
src/widgets/image.zig
Normal file
518
src/widgets/image.zig
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
//! Image Widget - Display images with caching
|
||||
//!
|
||||
//! Supports RGBA, RGB, and grayscale images with various fit modes.
|
||||
//! Includes an LRU cache for efficient image management.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
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");
|
||||
|
||||
/// Image pixel format
|
||||
pub const Format = enum {
|
||||
rgba,
|
||||
rgb,
|
||||
grayscale,
|
||||
};
|
||||
|
||||
/// Image fit mode
|
||||
pub const Fit = enum {
|
||||
/// No scaling, original size
|
||||
none,
|
||||
/// Scale to fit within bounds, preserving aspect ratio
|
||||
contain,
|
||||
/// Scale to cover bounds, preserving aspect ratio (may crop)
|
||||
cover,
|
||||
/// Stretch to fill bounds exactly
|
||||
fill,
|
||||
/// Scale down only if larger than bounds
|
||||
scale_down,
|
||||
};
|
||||
|
||||
/// Image alignment
|
||||
pub const Alignment = enum {
|
||||
top_left,
|
||||
top_center,
|
||||
top_right,
|
||||
center_left,
|
||||
center,
|
||||
center_right,
|
||||
bottom_left,
|
||||
bottom_center,
|
||||
bottom_right,
|
||||
};
|
||||
|
||||
/// Raw image data
|
||||
pub const ImageData = struct {
|
||||
/// Pixel data
|
||||
pixels: []const u8,
|
||||
/// Image width
|
||||
width: u32,
|
||||
/// Image height
|
||||
height: u32,
|
||||
/// Pixel format
|
||||
format: Format,
|
||||
/// Row stride in bytes (null = width * bytes_per_pixel)
|
||||
stride: ?u32 = null,
|
||||
|
||||
/// Get bytes per pixel for format
|
||||
pub fn bytesPerPixel(self: ImageData) u32 {
|
||||
return switch (self.format) {
|
||||
.rgba => 4,
|
||||
.rgb => 3,
|
||||
.grayscale => 1,
|
||||
};
|
||||
}
|
||||
|
||||
/// Get actual stride
|
||||
pub fn getStride(self: ImageData) u32 {
|
||||
return self.stride orelse self.width * self.bytesPerPixel();
|
||||
}
|
||||
|
||||
/// Get pixel at coordinates
|
||||
pub fn getPixel(self: ImageData, x: u32, y: u32) Style.Color {
|
||||
if (x >= self.width or y >= self.height) {
|
||||
return Style.Color.rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
const stride = self.getStride();
|
||||
const bpp = self.bytesPerPixel();
|
||||
const offset = y * stride + x * bpp;
|
||||
|
||||
if (offset + bpp > self.pixels.len) {
|
||||
return Style.Color.rgba(0, 0, 0, 0);
|
||||
}
|
||||
|
||||
return switch (self.format) {
|
||||
.rgba => Style.Color.rgba(
|
||||
self.pixels[offset],
|
||||
self.pixels[offset + 1],
|
||||
self.pixels[offset + 2],
|
||||
self.pixels[offset + 3],
|
||||
),
|
||||
.rgb => Style.Color.rgba(
|
||||
self.pixels[offset],
|
||||
self.pixels[offset + 1],
|
||||
self.pixels[offset + 2],
|
||||
255,
|
||||
),
|
||||
.grayscale => Style.Color.rgba(
|
||||
self.pixels[offset],
|
||||
self.pixels[offset],
|
||||
self.pixels[offset],
|
||||
255,
|
||||
),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Image configuration
|
||||
pub const Config = struct {
|
||||
/// How to fit image in bounds
|
||||
fit: Fit = .contain,
|
||||
/// Alignment within bounds
|
||||
alignment: Alignment = .center,
|
||||
/// Placeholder color while loading
|
||||
placeholder: ?Style.Color = null,
|
||||
/// Tint color (multiplied with image)
|
||||
tint: ?Style.Color = null,
|
||||
/// Opacity (0.0 - 1.0)
|
||||
opacity: f32 = 1.0,
|
||||
};
|
||||
|
||||
/// Image result
|
||||
pub const Result = struct {
|
||||
/// Actual bounds used for image
|
||||
bounds: Layout.Rect = Layout.Rect.zero(),
|
||||
/// Image was clicked
|
||||
clicked: bool = false,
|
||||
/// Image is hovered
|
||||
hovered: bool = false,
|
||||
};
|
||||
|
||||
/// Cached image entry
|
||||
pub const CachedImage = struct {
|
||||
data: ImageData,
|
||||
last_used: i64,
|
||||
size_bytes: usize,
|
||||
};
|
||||
|
||||
/// Image cache with LRU eviction
|
||||
pub const ImageCache = struct {
|
||||
allocator: Allocator,
|
||||
entries: std.AutoHashMap(u64, CachedImage),
|
||||
max_size: usize,
|
||||
current_size: usize,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Initialize cache with max size in bytes
|
||||
pub fn init(allocator: Allocator, max_size: usize) Self {
|
||||
return .{
|
||||
.allocator = allocator,
|
||||
.entries = std.AutoHashMap(u64, CachedImage).init(allocator),
|
||||
.max_size = max_size,
|
||||
.current_size = 0,
|
||||
};
|
||||
}
|
||||
|
||||
/// Deinitialize cache
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.entries.deinit();
|
||||
}
|
||||
|
||||
/// Get image from cache
|
||||
pub fn get(self: *Self, id: u64) ?*CachedImage {
|
||||
if (self.entries.getPtr(id)) |entry| {
|
||||
entry.last_used = std.time.milliTimestamp();
|
||||
return entry;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Put image in cache
|
||||
pub fn put(self: *Self, id: u64, data: ImageData) void {
|
||||
const size = data.pixels.len;
|
||||
|
||||
// Evict if needed
|
||||
while (self.current_size + size > self.max_size and self.entries.count() > 0) {
|
||||
self.evictOldest();
|
||||
}
|
||||
|
||||
// Add entry
|
||||
self.entries.put(id, .{
|
||||
.data = data,
|
||||
.last_used = std.time.milliTimestamp(),
|
||||
.size_bytes = size,
|
||||
}) catch return;
|
||||
|
||||
self.current_size += size;
|
||||
}
|
||||
|
||||
/// Remove image from cache
|
||||
pub fn remove(self: *Self, id: u64) void {
|
||||
if (self.entries.fetchRemove(id)) |kv| {
|
||||
self.current_size -|= kv.value.size_bytes;
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear entire cache
|
||||
pub fn clear(self: *Self) void {
|
||||
self.entries.clearRetainingCapacity();
|
||||
self.current_size = 0;
|
||||
}
|
||||
|
||||
/// Evict oldest entry
|
||||
fn evictOldest(self: *Self) void {
|
||||
var oldest_id: ?u64 = null;
|
||||
var oldest_time: i64 = std.math.maxInt(i64);
|
||||
|
||||
var it = self.entries.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.value_ptr.last_used < oldest_time) {
|
||||
oldest_time = entry.value_ptr.last_used;
|
||||
oldest_id = entry.key_ptr.*;
|
||||
}
|
||||
}
|
||||
|
||||
if (oldest_id) |id| {
|
||||
self.remove(id);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Draw an image
|
||||
pub fn image(ctx: *Context, data: ImageData) Result {
|
||||
return imageEx(ctx, data, .{});
|
||||
}
|
||||
|
||||
/// Draw an image with configuration
|
||||
pub fn imageEx(ctx: *Context, data: ImageData, config: Config) Result {
|
||||
const bounds = ctx.layout.nextRect();
|
||||
return imageRect(ctx, bounds, data, config);
|
||||
}
|
||||
|
||||
/// Draw an image in a specific rectangle
|
||||
pub fn imageRect(
|
||||
ctx: *Context,
|
||||
bounds: Layout.Rect,
|
||||
data: ImageData,
|
||||
config: Config,
|
||||
) Result {
|
||||
var result = Result{};
|
||||
|
||||
if (bounds.isEmpty()) return result;
|
||||
|
||||
// Check mouse interaction
|
||||
const mouse = ctx.input.mousePos();
|
||||
result.hovered = bounds.contains(mouse.x, mouse.y);
|
||||
result.clicked = result.hovered and ctx.input.mousePressed(.left);
|
||||
|
||||
// Calculate destination rectangle based on fit mode
|
||||
const dest = calculateDestRect(bounds, data.width, data.height, config.fit, config.alignment);
|
||||
result.bounds = dest;
|
||||
|
||||
// Draw placeholder if specified
|
||||
if (config.placeholder) |placeholder| {
|
||||
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, placeholder));
|
||||
}
|
||||
|
||||
// Draw image using rect commands (simple pixel rendering)
|
||||
// For a real implementation, we'd use a dedicated image command
|
||||
// Here we'll draw a colored rectangle representing the image
|
||||
const img_color = if (config.tint) |tint|
|
||||
tint
|
||||
else
|
||||
Style.Color.rgba(128, 128, 128, @intFromFloat(config.opacity * 255));
|
||||
|
||||
ctx.pushCommand(Command.rect(dest.x, dest.y, dest.w, dest.h, img_color));
|
||||
|
||||
// Draw border to indicate image bounds
|
||||
ctx.pushCommand(Command.rectOutline(
|
||||
dest.x,
|
||||
dest.y,
|
||||
dest.w,
|
||||
dest.h,
|
||||
Style.Color.rgba(100, 100, 100, 128),
|
||||
));
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Draw image from cache
|
||||
pub fn imageFromCache(
|
||||
ctx: *Context,
|
||||
cache: *ImageCache,
|
||||
id: u64,
|
||||
config: Config,
|
||||
) Result {
|
||||
const bounds = ctx.layout.nextRect();
|
||||
return imageFromCacheRect(ctx, bounds, cache, id, config);
|
||||
}
|
||||
|
||||
/// Draw image from cache in specific rectangle
|
||||
pub fn imageFromCacheRect(
|
||||
ctx: *Context,
|
||||
bounds: Layout.Rect,
|
||||
cache: *ImageCache,
|
||||
id: u64,
|
||||
config: Config,
|
||||
) Result {
|
||||
if (cache.get(id)) |cached| {
|
||||
return imageRect(ctx, bounds, cached.data, config);
|
||||
}
|
||||
|
||||
// Image not in cache - draw placeholder
|
||||
const result = Result{
|
||||
.bounds = bounds,
|
||||
};
|
||||
|
||||
if (config.placeholder) |placeholder| {
|
||||
ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, placeholder));
|
||||
} else {
|
||||
// Default placeholder
|
||||
ctx.pushCommand(Command.rect(
|
||||
bounds.x,
|
||||
bounds.y,
|
||||
bounds.w,
|
||||
bounds.h,
|
||||
Style.Color.rgba(50, 50, 50, 255),
|
||||
));
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Calculate destination rectangle for image
|
||||
fn calculateDestRect(
|
||||
bounds: Layout.Rect,
|
||||
img_width: u32,
|
||||
img_height: u32,
|
||||
fit: Fit,
|
||||
alignment: Alignment,
|
||||
) Layout.Rect {
|
||||
if (img_width == 0 or img_height == 0) return bounds;
|
||||
|
||||
var dest_w: u32 = img_width;
|
||||
var dest_h: u32 = img_height;
|
||||
|
||||
switch (fit) {
|
||||
.none => {
|
||||
// Keep original size
|
||||
},
|
||||
.contain => {
|
||||
// Scale to fit within bounds
|
||||
const scale_x = @as(f32, @floatFromInt(bounds.w)) / @as(f32, @floatFromInt(img_width));
|
||||
const scale_y = @as(f32, @floatFromInt(bounds.h)) / @as(f32, @floatFromInt(img_height));
|
||||
const scale = @min(scale_x, scale_y);
|
||||
dest_w = @intFromFloat(@as(f32, @floatFromInt(img_width)) * scale);
|
||||
dest_h = @intFromFloat(@as(f32, @floatFromInt(img_height)) * scale);
|
||||
},
|
||||
.cover => {
|
||||
// Scale to cover bounds
|
||||
const scale_x = @as(f32, @floatFromInt(bounds.w)) / @as(f32, @floatFromInt(img_width));
|
||||
const scale_y = @as(f32, @floatFromInt(bounds.h)) / @as(f32, @floatFromInt(img_height));
|
||||
const scale = @max(scale_x, scale_y);
|
||||
dest_w = @intFromFloat(@as(f32, @floatFromInt(img_width)) * scale);
|
||||
dest_h = @intFromFloat(@as(f32, @floatFromInt(img_height)) * scale);
|
||||
},
|
||||
.fill => {
|
||||
// Stretch to fill
|
||||
dest_w = bounds.w;
|
||||
dest_h = bounds.h;
|
||||
},
|
||||
.scale_down => {
|
||||
// Only scale if larger
|
||||
if (img_width > bounds.w or img_height > bounds.h) {
|
||||
const scale_x = @as(f32, @floatFromInt(bounds.w)) / @as(f32, @floatFromInt(img_width));
|
||||
const scale_y = @as(f32, @floatFromInt(bounds.h)) / @as(f32, @floatFromInt(img_height));
|
||||
const scale = @min(scale_x, scale_y);
|
||||
dest_w = @intFromFloat(@as(f32, @floatFromInt(img_width)) * scale);
|
||||
dest_h = @intFromFloat(@as(f32, @floatFromInt(img_height)) * scale);
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Calculate position based on alignment
|
||||
var x = bounds.x;
|
||||
var y = bounds.y;
|
||||
|
||||
switch (alignment) {
|
||||
.top_left, .center_left, .bottom_left => {},
|
||||
.top_center, .center, .bottom_center => {
|
||||
x += @as(i32, @intCast((bounds.w -| dest_w) / 2));
|
||||
},
|
||||
.top_right, .center_right, .bottom_right => {
|
||||
x += @as(i32, @intCast(bounds.w -| dest_w));
|
||||
},
|
||||
}
|
||||
|
||||
switch (alignment) {
|
||||
.top_left, .top_center, .top_right => {},
|
||||
.center_left, .center, .center_right => {
|
||||
y += @as(i32, @intCast((bounds.h -| dest_h) / 2));
|
||||
},
|
||||
.bottom_left, .bottom_center, .bottom_right => {
|
||||
y += @as(i32, @intCast(bounds.h -| dest_h));
|
||||
},
|
||||
}
|
||||
|
||||
return Layout.Rect.init(x, y, dest_w, dest_h);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "ImageData getPixel" {
|
||||
const pixels = [_]u8{ 255, 0, 0, 255, 0, 255, 0, 255 };
|
||||
const data = ImageData{
|
||||
.pixels = &pixels,
|
||||
.width = 2,
|
||||
.height = 1,
|
||||
.format = .rgba,
|
||||
};
|
||||
|
||||
const p0 = data.getPixel(0, 0);
|
||||
try std.testing.expectEqual(@as(u8, 255), p0.r);
|
||||
try std.testing.expectEqual(@as(u8, 0), p0.g);
|
||||
|
||||
const p1 = data.getPixel(1, 0);
|
||||
try std.testing.expectEqual(@as(u8, 0), p1.r);
|
||||
try std.testing.expectEqual(@as(u8, 255), p1.g);
|
||||
}
|
||||
|
||||
test "ImageData grayscale" {
|
||||
const pixels = [_]u8{ 128, 255 };
|
||||
const data = ImageData{
|
||||
.pixels = &pixels,
|
||||
.width = 2,
|
||||
.height = 1,
|
||||
.format = .grayscale,
|
||||
};
|
||||
|
||||
const p0 = data.getPixel(0, 0);
|
||||
try std.testing.expectEqual(@as(u8, 128), p0.r);
|
||||
try std.testing.expectEqual(@as(u8, 128), p0.g);
|
||||
try std.testing.expectEqual(@as(u8, 128), p0.b);
|
||||
}
|
||||
|
||||
test "ImageCache basic" {
|
||||
var cache = ImageCache.init(std.testing.allocator, 1024);
|
||||
defer cache.deinit();
|
||||
|
||||
const pixels = [_]u8{ 255, 255, 255, 255 };
|
||||
const data = ImageData{
|
||||
.pixels = &pixels,
|
||||
.width = 1,
|
||||
.height = 1,
|
||||
.format = .rgba,
|
||||
};
|
||||
|
||||
cache.put(42, data);
|
||||
try std.testing.expect(cache.get(42) != null);
|
||||
try std.testing.expect(cache.get(99) == null);
|
||||
|
||||
cache.remove(42);
|
||||
try std.testing.expect(cache.get(42) == null);
|
||||
}
|
||||
|
||||
test "ImageCache eviction" {
|
||||
var cache = ImageCache.init(std.testing.allocator, 8);
|
||||
defer cache.deinit();
|
||||
|
||||
const pixels1 = [_]u8{ 1, 2, 3, 4 };
|
||||
const pixels2 = [_]u8{ 5, 6, 7, 8 };
|
||||
const pixels3 = [_]u8{ 9, 10, 11, 12 };
|
||||
|
||||
cache.put(1, .{ .pixels = &pixels1, .width = 1, .height = 1, .format = .rgba });
|
||||
cache.put(2, .{ .pixels = &pixels2, .width = 1, .height = 1, .format = .rgba });
|
||||
|
||||
// This should evict entry 1
|
||||
cache.put(3, .{ .pixels = &pixels3, .width = 1, .height = 1, .format = .rgba });
|
||||
|
||||
// Entry 1 should be evicted
|
||||
try std.testing.expect(cache.entries.count() <= 2);
|
||||
}
|
||||
|
||||
test "calculateDestRect contain" {
|
||||
const bounds = Layout.Rect.init(0, 0, 200, 100);
|
||||
const dest = calculateDestRect(bounds, 100, 100, .contain, .center);
|
||||
|
||||
// 100x100 image in 200x100 bounds with contain should be 100x100 centered
|
||||
try std.testing.expectEqual(@as(u32, 100), dest.w);
|
||||
try std.testing.expectEqual(@as(u32, 100), dest.h);
|
||||
try std.testing.expectEqual(@as(i32, 50), dest.x);
|
||||
}
|
||||
|
||||
test "calculateDestRect fill" {
|
||||
const bounds = Layout.Rect.init(0, 0, 200, 100);
|
||||
const dest = calculateDestRect(bounds, 100, 100, .fill, .center);
|
||||
|
||||
try std.testing.expectEqual(@as(u32, 200), dest.w);
|
||||
try std.testing.expectEqual(@as(u32, 100), dest.h);
|
||||
}
|
||||
|
||||
test "image generates commands" {
|
||||
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||
defer ctx.deinit();
|
||||
|
||||
const pixels = [_]u8{ 255, 255, 255, 255 };
|
||||
const data = ImageData{
|
||||
.pixels = &pixels,
|
||||
.width = 1,
|
||||
.height = 1,
|
||||
.format = .rgba,
|
||||
};
|
||||
|
||||
ctx.beginFrame();
|
||||
ctx.layout.row_height = 100;
|
||||
|
||||
_ = image(&ctx, data);
|
||||
|
||||
try std.testing.expect(ctx.commands.items.len >= 1);
|
||||
|
||||
ctx.endFrame();
|
||||
}
|
||||
446
src/widgets/reorderable.zig
Normal file
446
src/widgets/reorderable.zig
Normal file
|
|
@ -0,0 +1,446 @@
|
|||
//! ReorderableList Widget - Drag and drop list reordering
|
||||
//!
|
||||
//! A list that supports drag and drop to reorder items.
|
||||
//! Also supports adding and removing items.
|
||||
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
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");
|
||||
|
||||
/// Reorderable list state
|
||||
pub const State = struct {
|
||||
/// Index of item being dragged (null if not dragging)
|
||||
dragging_index: ?usize = null,
|
||||
/// Vertical offset while dragging
|
||||
drag_offset: i32 = 0,
|
||||
/// Target drop position
|
||||
target_index: ?usize = null,
|
||||
/// Current order of items (indices into original array)
|
||||
order: []usize,
|
||||
/// Allocator for order array
|
||||
allocator: Allocator,
|
||||
/// Scroll offset
|
||||
scroll_y: i32 = 0,
|
||||
|
||||
const Self = @This();
|
||||
|
||||
/// Initialize state with item count
|
||||
pub fn init(allocator: Allocator, item_count: usize) !Self {
|
||||
const order = try allocator.alloc(usize, item_count);
|
||||
for (order, 0..) |*o, i| {
|
||||
o.* = i;
|
||||
}
|
||||
return .{
|
||||
.order = order,
|
||||
.allocator = allocator,
|
||||
};
|
||||
}
|
||||
|
||||
/// Deinitialize state
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.allocator.free(self.order);
|
||||
}
|
||||
|
||||
/// Reset order to sequential
|
||||
pub fn reset(self: *Self) void {
|
||||
for (self.order, 0..) |*o, i| {
|
||||
o.* = i;
|
||||
}
|
||||
}
|
||||
|
||||
/// Get item at visual position
|
||||
pub fn getItemAt(self: Self, visual_index: usize) ?usize {
|
||||
if (visual_index >= self.order.len) return null;
|
||||
return self.order[visual_index];
|
||||
}
|
||||
|
||||
/// Move item from one position to another
|
||||
pub fn moveItem(self: *Self, from: usize, to: usize) void {
|
||||
if (from >= self.order.len or to >= self.order.len) return;
|
||||
if (from == to) return;
|
||||
|
||||
const item = self.order[from];
|
||||
|
||||
if (from < to) {
|
||||
// Move items up
|
||||
var i = from;
|
||||
while (i < to) : (i += 1) {
|
||||
self.order[i] = self.order[i + 1];
|
||||
}
|
||||
} else {
|
||||
// Move items down
|
||||
var i = from;
|
||||
while (i > to) : (i -= 1) {
|
||||
self.order[i] = self.order[i - 1];
|
||||
}
|
||||
}
|
||||
|
||||
self.order[to] = item;
|
||||
}
|
||||
|
||||
/// Remove item at visual position
|
||||
pub fn removeItem(self: *Self, index: usize) void {
|
||||
if (index >= self.order.len) return;
|
||||
|
||||
// Shift items down
|
||||
var i = index;
|
||||
while (i < self.order.len - 1) : (i += 1) {
|
||||
self.order[i] = self.order[i + 1];
|
||||
}
|
||||
|
||||
// Note: We don't resize the array, just track fewer items
|
||||
// In a real impl, you'd need to handle this properly
|
||||
}
|
||||
};
|
||||
|
||||
/// Reorderable list configuration
|
||||
pub const Config = struct {
|
||||
/// Height of each item
|
||||
item_height: u32 = 32,
|
||||
/// Show drag handle
|
||||
drag_handle: bool = true,
|
||||
/// Allow removing items
|
||||
allow_remove: bool = true,
|
||||
/// Allow adding items
|
||||
allow_add: bool = false,
|
||||
/// Spacing between items
|
||||
spacing: u32 = 2,
|
||||
/// Padding inside list
|
||||
padding: u32 = 4,
|
||||
};
|
||||
|
||||
/// Reorderable list colors
|
||||
pub const Colors = struct {
|
||||
background: Style.Color = Style.Color.rgba(30, 30, 30, 255),
|
||||
item_bg: Style.Color = Style.Color.rgba(45, 45, 45, 255),
|
||||
item_bg_hover: Style.Color = Style.Color.rgba(55, 55, 55, 255),
|
||||
item_bg_dragging: Style.Color = Style.Color.rgba(70, 70, 70, 255),
|
||||
item_text: Style.Color = Style.Color.rgba(220, 220, 220, 255),
|
||||
handle: Style.Color = Style.Color.rgba(100, 100, 100, 255),
|
||||
remove_btn: Style.Color = Style.Color.rgba(200, 80, 80, 255),
|
||||
drop_indicator: Style.Color = Style.Color.rgba(100, 149, 237, 255),
|
||||
border: Style.Color = Style.Color.rgba(80, 80, 80, 255),
|
||||
add_btn: Style.Color = Style.Color.rgba(80, 180, 80, 255),
|
||||
|
||||
pub fn fromTheme(theme: Style.Theme) Colors {
|
||||
return .{
|
||||
.background = theme.background,
|
||||
.item_bg = theme.background.lighten(10),
|
||||
.item_bg_hover = theme.background.lighten(15),
|
||||
.item_bg_dragging = theme.background.lighten(25),
|
||||
.item_text = theme.foreground,
|
||||
.handle = theme.secondary,
|
||||
.remove_btn = theme.error_color,
|
||||
.drop_indicator = theme.primary,
|
||||
.border = theme.border,
|
||||
.add_btn = theme.success,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Result of reorderable list widget
|
||||
pub const Result = struct {
|
||||
/// Item was reordered (check state.order for new order)
|
||||
reordered: bool = false,
|
||||
/// Index of item that was removed
|
||||
removed_index: ?usize = null,
|
||||
/// Add button was clicked
|
||||
add_requested: bool = false,
|
||||
/// Item was clicked (not drag)
|
||||
clicked_index: ?usize = null,
|
||||
};
|
||||
|
||||
/// Draw a reorderable list
|
||||
pub fn reorderableList(
|
||||
ctx: *Context,
|
||||
state: *State,
|
||||
items: []const []const u8,
|
||||
) Result {
|
||||
return reorderableListEx(ctx, state, items, .{}, .{});
|
||||
}
|
||||
|
||||
/// Draw a reorderable list with configuration
|
||||
pub fn reorderableListEx(
|
||||
ctx: *Context,
|
||||
state: *State,
|
||||
items: []const []const u8,
|
||||
config: Config,
|
||||
colors: Colors,
|
||||
) Result {
|
||||
const bounds = ctx.layout.nextRect();
|
||||
return reorderableListRect(ctx, bounds, state, items, config, colors);
|
||||
}
|
||||
|
||||
/// Draw a reorderable list in specific rectangle
|
||||
pub fn reorderableListRect(
|
||||
ctx: *Context,
|
||||
bounds: Layout.Rect,
|
||||
state: *State,
|
||||
items: []const []const u8,
|
||||
config: Config,
|
||||
colors: Colors,
|
||||
) Result {
|
||||
var result = Result{};
|
||||
|
||||
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));
|
||||
|
||||
const inner = bounds.shrink(config.padding);
|
||||
if (inner.isEmpty()) return result;
|
||||
|
||||
const item_total_height = config.item_height + config.spacing;
|
||||
const handle_width: u32 = if (config.drag_handle) 24 else 0;
|
||||
const remove_width: u32 = if (config.allow_remove) 24 else 0;
|
||||
|
||||
// Calculate visible items
|
||||
const visible_count = @min(inner.h / item_total_height, items.len);
|
||||
|
||||
// Process each item
|
||||
var i: usize = 0;
|
||||
while (i < @min(state.order.len, items.len) and i < visible_count) : (i += 1) {
|
||||
const item_idx = state.order[i];
|
||||
if (item_idx >= items.len) continue;
|
||||
|
||||
var item_y = inner.y + @as(i32, @intCast(i * item_total_height));
|
||||
|
||||
// Adjust position if dragging
|
||||
const is_dragging = state.dragging_index != null and state.dragging_index.? == i;
|
||||
if (is_dragging) {
|
||||
item_y += state.drag_offset;
|
||||
}
|
||||
|
||||
const item_rect = Layout.Rect.init(
|
||||
inner.x,
|
||||
item_y,
|
||||
inner.w,
|
||||
config.item_height,
|
||||
);
|
||||
|
||||
const item_hovered = item_rect.contains(mouse.x, mouse.y);
|
||||
|
||||
// Draw item background
|
||||
const bg_color = if (is_dragging)
|
||||
colors.item_bg_dragging
|
||||
else if (item_hovered)
|
||||
colors.item_bg_hover
|
||||
else
|
||||
colors.item_bg;
|
||||
|
||||
ctx.pushCommand(Command.rect(item_rect.x, item_rect.y, item_rect.w, item_rect.h, bg_color));
|
||||
|
||||
var text_x = item_rect.x + 4;
|
||||
|
||||
// Draw drag handle
|
||||
if (config.drag_handle) {
|
||||
const handle_rect = Layout.Rect.init(
|
||||
item_rect.x,
|
||||
item_rect.y,
|
||||
handle_width,
|
||||
config.item_height,
|
||||
);
|
||||
|
||||
// Draw handle lines
|
||||
const line_y = item_rect.y + @as(i32, @intCast(config.item_height / 2));
|
||||
ctx.pushCommand(Command.text(
|
||||
handle_rect.x + 6,
|
||||
line_y - 4,
|
||||
":::",
|
||||
colors.handle,
|
||||
));
|
||||
|
||||
// Handle drag start
|
||||
if (handle_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
||||
state.dragging_index = i;
|
||||
state.drag_offset = 0;
|
||||
}
|
||||
|
||||
text_x += @as(i32, @intCast(handle_width));
|
||||
}
|
||||
|
||||
// Draw item text
|
||||
const text_y = item_rect.y + @as(i32, @intCast((config.item_height - 8) / 2));
|
||||
ctx.pushCommand(Command.text(text_x, text_y, items[item_idx], colors.item_text));
|
||||
|
||||
// Draw remove button
|
||||
if (config.allow_remove) {
|
||||
const remove_rect = Layout.Rect.init(
|
||||
item_rect.x + @as(i32, @intCast(item_rect.w - remove_width)),
|
||||
item_rect.y,
|
||||
remove_width,
|
||||
config.item_height,
|
||||
);
|
||||
|
||||
const remove_y = item_rect.y + @as(i32, @intCast((config.item_height - 8) / 2));
|
||||
ctx.pushCommand(Command.text(
|
||||
remove_rect.x + 8,
|
||||
remove_y,
|
||||
"x",
|
||||
colors.remove_btn,
|
||||
));
|
||||
|
||||
if (remove_rect.contains(mouse.x, mouse.y) and mouse_pressed and !is_dragging) {
|
||||
result.removed_index = i;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle click (not drag)
|
||||
if (item_hovered and mouse_pressed and state.dragging_index == null) {
|
||||
// Check if click is not on handle or remove button
|
||||
const handle_end = item_rect.x + @as(i32, @intCast(handle_width));
|
||||
const remove_start = item_rect.x + @as(i32, @intCast(item_rect.w - remove_width));
|
||||
|
||||
if (mouse.x > handle_end and mouse.x < remove_start) {
|
||||
result.clicked_index = i;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update drag offset if dragging
|
||||
if (state.dragging_index != null and mouse_down) {
|
||||
// Calculate offset based on mouse movement
|
||||
// This is simplified - a real impl would track initial mouse position
|
||||
const drag_idx = state.dragging_index.?;
|
||||
const orig_y = inner.y + @as(i32, @intCast(drag_idx * item_total_height));
|
||||
state.drag_offset = mouse.y - orig_y - @as(i32, @intCast(config.item_height / 2));
|
||||
|
||||
// Calculate target drop position
|
||||
const rel_y = mouse.y - inner.y;
|
||||
if (rel_y >= 0) {
|
||||
const target = @as(usize, @intCast(rel_y)) / item_total_height;
|
||||
state.target_index = @min(target, items.len - 1);
|
||||
|
||||
// Draw drop indicator
|
||||
if (state.target_index) |target_idx| {
|
||||
const ind_y = inner.y + @as(i32, @intCast(target_idx * item_total_height));
|
||||
ctx.pushCommand(Command.rect(
|
||||
inner.x,
|
||||
ind_y - 1,
|
||||
inner.w,
|
||||
2,
|
||||
colors.drop_indicator,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle drop
|
||||
if (state.dragging_index != null and mouse_released) {
|
||||
if (state.target_index) |target| {
|
||||
const from = state.dragging_index.?;
|
||||
if (from != target) {
|
||||
state.moveItem(from, target);
|
||||
result.reordered = true;
|
||||
}
|
||||
}
|
||||
state.dragging_index = null;
|
||||
state.target_index = null;
|
||||
state.drag_offset = 0;
|
||||
}
|
||||
|
||||
// Draw add button
|
||||
if (config.allow_add) {
|
||||
const add_y = inner.y + @as(i32, @intCast(@min(items.len, visible_count) * item_total_height));
|
||||
const add_rect = Layout.Rect.init(inner.x, add_y, inner.w, config.item_height);
|
||||
|
||||
if (add_rect.bottom() <= inner.bottom()) {
|
||||
ctx.pushCommand(Command.rect(add_rect.x, add_rect.y, add_rect.w, add_rect.h, colors.item_bg));
|
||||
|
||||
const add_text_y = add_y + @as(i32, @intCast((config.item_height - 8) / 2));
|
||||
ctx.pushCommand(Command.text(
|
||||
add_rect.x + @as(i32, @intCast(add_rect.w / 2)) - 4,
|
||||
add_text_y,
|
||||
"+",
|
||||
colors.add_btn,
|
||||
));
|
||||
|
||||
if (add_rect.contains(mouse.x, mouse.y) and mouse_pressed) {
|
||||
result.add_requested = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
||||
test "State init/deinit" {
|
||||
var state = try State.init(std.testing.allocator, 5);
|
||||
defer state.deinit();
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 5), state.order.len);
|
||||
try std.testing.expectEqual(@as(usize, 0), state.order[0]);
|
||||
try std.testing.expectEqual(@as(usize, 4), state.order[4]);
|
||||
}
|
||||
|
||||
test "State moveItem" {
|
||||
var state = try State.init(std.testing.allocator, 5);
|
||||
defer state.deinit();
|
||||
|
||||
// Move item 0 to position 2
|
||||
state.moveItem(0, 2);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 1), state.order[0]);
|
||||
try std.testing.expectEqual(@as(usize, 2), state.order[1]);
|
||||
try std.testing.expectEqual(@as(usize, 0), state.order[2]);
|
||||
try std.testing.expectEqual(@as(usize, 3), state.order[3]);
|
||||
}
|
||||
|
||||
test "State moveItem reverse" {
|
||||
var state = try State.init(std.testing.allocator, 5);
|
||||
defer state.deinit();
|
||||
|
||||
// Move item 3 to position 1
|
||||
state.moveItem(3, 1);
|
||||
|
||||
try std.testing.expectEqual(@as(usize, 0), state.order[0]);
|
||||
try std.testing.expectEqual(@as(usize, 3), state.order[1]);
|
||||
try std.testing.expectEqual(@as(usize, 1), state.order[2]);
|
||||
try std.testing.expectEqual(@as(usize, 2), state.order[3]);
|
||||
}
|
||||
|
||||
test "State getItemAt" {
|
||||
var state = try State.init(std.testing.allocator, 3);
|
||||
defer state.deinit();
|
||||
|
||||
state.moveItem(0, 2);
|
||||
|
||||
try std.testing.expectEqual(@as(?usize, 1), state.getItemAt(0));
|
||||
try std.testing.expectEqual(@as(?usize, 2), state.getItemAt(1));
|
||||
try std.testing.expectEqual(@as(?usize, 0), state.getItemAt(2));
|
||||
try std.testing.expectEqual(@as(?usize, null), state.getItemAt(5));
|
||||
}
|
||||
|
||||
test "reorderableList generates commands" {
|
||||
var ctx = try Context.init(std.testing.allocator, 800, 600);
|
||||
defer ctx.deinit();
|
||||
|
||||
var state = try State.init(std.testing.allocator, 3);
|
||||
defer state.deinit();
|
||||
|
||||
const items = [_][]const u8{ "Item 1", "Item 2", "Item 3" };
|
||||
|
||||
ctx.beginFrame();
|
||||
ctx.layout.row_height = 200;
|
||||
|
||||
_ = reorderableList(&ctx, &state, &items);
|
||||
|
||||
// Should have background + border + items
|
||||
try std.testing.expect(ctx.commands.items.len >= 2);
|
||||
|
||||
ctx.endFrame();
|
||||
}
|
||||
|
|
@ -31,6 +31,10 @@ pub const toast = @import("toast.zig");
|
|||
pub const textarea = @import("textarea.zig");
|
||||
pub const tree = @import("tree.zig");
|
||||
pub const badge = @import("badge.zig");
|
||||
pub const img = @import("image.zig");
|
||||
pub const reorderable = @import("reorderable.zig");
|
||||
pub const colorpicker = @import("colorpicker.zig");
|
||||
pub const datepicker = @import("datepicker.zig");
|
||||
|
||||
// =============================================================================
|
||||
// Re-exports for convenience
|
||||
|
|
@ -223,6 +227,40 @@ pub const Tag = badge.Tag;
|
|||
pub const TagGroupConfig = badge.TagGroupConfig;
|
||||
pub const TagGroupResult = badge.TagGroupResult;
|
||||
|
||||
// Image
|
||||
pub const Image = img;
|
||||
pub const ImageData = img.ImageData;
|
||||
pub const ImageFormat = img.Format;
|
||||
pub const ImageFit = img.Fit;
|
||||
pub const ImageAlignment = img.Alignment;
|
||||
pub const ImageConfig = img.Config;
|
||||
pub const ImageResult = img.Result;
|
||||
pub const ImageCache = img.ImageCache;
|
||||
pub const CachedImage = img.CachedImage;
|
||||
|
||||
// Reorderable
|
||||
pub const Reorderable = reorderable;
|
||||
pub const ReorderableState = reorderable.State;
|
||||
pub const ReorderableConfig = reorderable.Config;
|
||||
pub const ReorderableColors = reorderable.Colors;
|
||||
pub const ReorderableResult = reorderable.Result;
|
||||
|
||||
// ColorPicker
|
||||
pub const ColorPicker = colorpicker;
|
||||
pub const ColorPickerState = colorpicker.State;
|
||||
pub const ColorPickerMode = colorpicker.Mode;
|
||||
pub const ColorPickerConfig = colorpicker.Config;
|
||||
pub const ColorPickerColors = colorpicker.Colors;
|
||||
pub const ColorPickerResult = colorpicker.Result;
|
||||
|
||||
// DatePicker
|
||||
pub const DatePicker = datepicker;
|
||||
pub const Date = datepicker.Date;
|
||||
pub const DatePickerState = datepicker.State;
|
||||
pub const DatePickerConfig = datepicker.Config;
|
||||
pub const DatePickerColors = datepicker.Colors;
|
||||
pub const DatePickerResult = datepicker.Result;
|
||||
|
||||
// =============================================================================
|
||||
// Tests
|
||||
// =============================================================================
|
||||
|
|
|
|||
Loading…
Reference in a new issue