From a75827f70b9bb4f941aa3d263288c60eef0c618c Mon Sep 17 00:00:00 2001 From: reugenio Date: Tue, 9 Dec 2025 13:21:47 +0100 Subject: [PATCH] feat: zcatgui v0.9.0 - Phase 3 Specialized Widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/widgets/colorpicker.zig | 626 ++++++++++++++++++++++++++++++++++++ src/widgets/datepicker.zig | 554 +++++++++++++++++++++++++++++++ src/widgets/image.zig | 518 +++++++++++++++++++++++++++++ src/widgets/reorderable.zig | 446 +++++++++++++++++++++++++ src/widgets/widgets.zig | 38 +++ 5 files changed, 2182 insertions(+) create mode 100644 src/widgets/colorpicker.zig create mode 100644 src/widgets/datepicker.zig create mode 100644 src/widgets/image.zig create mode 100644 src/widgets/reorderable.zig diff --git a/src/widgets/colorpicker.zig b/src/widgets/colorpicker.zig new file mode 100644 index 0000000..2897b21 --- /dev/null +++ b/src/widgets/colorpicker.zig @@ -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(); +} diff --git a/src/widgets/datepicker.zig b/src/widgets/datepicker.zig new file mode 100644 index 0000000..a85d01b --- /dev/null +++ b/src/widgets/datepicker.zig @@ -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); +} diff --git a/src/widgets/image.zig b/src/widgets/image.zig new file mode 100644 index 0000000..f4a4401 --- /dev/null +++ b/src/widgets/image.zig @@ -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(); +} diff --git a/src/widgets/reorderable.zig b/src/widgets/reorderable.zig new file mode 100644 index 0000000..4fb99fd --- /dev/null +++ b/src/widgets/reorderable.zig @@ -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(); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 569818e..338fac9 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -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 // =============================================================================