From bb5b201203a031de135d2d60e2b02a00917002e4 Mon Sep 17 00:00:00 2001 From: reugenio Date: Tue, 9 Dec 2025 13:37:27 +0100 Subject: [PATCH] feat: zcatgui v0.11.0 - Phase 5 Data Visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New Widgets (3): Canvas - Drawing primitives widget - Point, fillRect, strokeRect, line, text - fillCircle, strokeCircle (Bresenham algorithm) - fillArc, fillTriangle (scanline fill) - strokePolygon, fillRoundedRect - horizontalGradient, verticalGradient - Color interpolation (lerpColor) Chart - Data visualization widgets - LineChart: Points, grid, axis labels, fill under line - BarChart: Vertical bars, value display, labels - PieChart: Slices with colors, donut mode - DataPoint and DataSeries for multi-series - 8-color default palette - Scanline fill for triangles and quads Icon - Vector icon system (60+ icons) - Size presets: small(12), medium(16), large(24), xlarge(32) - Categories: Navigation, Actions, Files, Status, UI, Media - Stroke-based drawing with configurable thickness - All icons resolution-independent Widget count: 34 widget files All tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/widgets/canvas.zig | 494 ++++++++++++++++++++++++ src/widgets/chart.zig | 666 +++++++++++++++++++++++++++++++++ src/widgets/icon.zig | 805 ++++++++++++++++++++++++++++++++++++++++ src/widgets/widgets.zig | 25 ++ 4 files changed, 1990 insertions(+) create mode 100644 src/widgets/canvas.zig create mode 100644 src/widgets/chart.zig create mode 100644 src/widgets/icon.zig diff --git a/src/widgets/canvas.zig b/src/widgets/canvas.zig new file mode 100644 index 0000000..e4f40db --- /dev/null +++ b/src/widgets/canvas.zig @@ -0,0 +1,494 @@ +//! Canvas Widget - Drawing primitives +//! +//! A canvas for drawing shapes, lines, and custom graphics. +//! Provides an immediate-mode drawing API. + +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"); + +/// Point in 2D space +pub const Point = struct { + x: i32, + y: i32, + + pub fn init(x: i32, y: i32) Point { + return .{ .x = x, .y = y }; + } + + pub fn add(self: Point, other: Point) Point { + return .{ .x = self.x + other.x, .y = self.y + other.y }; + } + + pub fn sub(self: Point, other: Point) Point { + return .{ .x = self.x - other.x, .y = self.y - other.y }; + } +}; + +/// Canvas state for drawing operations +pub const Canvas = struct { + ctx: *Context, + bounds: Layout.Rect, + offset: Point, + + const Self = @This(); + + /// Begin canvas operations in a region + pub fn begin(ctx: *Context) Self { + const bounds = ctx.layout.nextRect(); + return .{ + .ctx = ctx, + .bounds = bounds, + .offset = Point.init(bounds.x, bounds.y), + }; + } + + /// Begin canvas in specific rectangle + pub fn beginRect(ctx: *Context, bounds: Layout.Rect) Self { + return .{ + .ctx = ctx, + .bounds = bounds, + .offset = Point.init(bounds.x, bounds.y), + }; + } + + // ========================================================================= + // Basic shapes + // ========================================================================= + + /// Draw a filled rectangle + pub fn fillRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Style.Color) void { + self.ctx.pushCommand(Command.rect( + self.offset.x + x, + self.offset.y + y, + w, + h, + color, + )); + } + + /// Draw a rectangle outline + pub fn strokeRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Style.Color) void { + self.ctx.pushCommand(Command.rectOutline( + self.offset.x + x, + self.offset.y + y, + w, + h, + color, + )); + } + + /// Draw a line + pub fn line(self: *Self, x1: i32, y1: i32, x2: i32, y2: i32, color: Style.Color) void { + self.ctx.pushCommand(Command.line( + self.offset.x + x1, + self.offset.y + y1, + self.offset.x + x2, + self.offset.y + y2, + color, + )); + } + + /// Draw text + pub fn text(self: *Self, x: i32, y: i32, str: []const u8, color: Style.Color) void { + self.ctx.pushCommand(Command.text( + self.offset.x + x, + self.offset.y + y, + str, + color, + )); + } + + // ========================================================================= + // Circle drawing (using rectangles approximation) + // ========================================================================= + + /// Draw a filled circle (approximated with octagon/rects) + pub fn fillCircle(self: *Self, cx: i32, cy: i32, radius: u32, color: Style.Color) void { + if (radius == 0) return; + + const r = @as(i32, @intCast(radius)); + const x = self.offset.x + cx; + const y = self.offset.y + cy; + + // Draw circle using horizontal lines at different heights + var dy: i32 = -r; + while (dy <= r) : (dy += 1) { + // Calculate width at this height using circle equation + const dy_f = @as(f32, @floatFromInt(dy)); + const r_f = @as(f32, @floatFromInt(r)); + const dx_f = @sqrt(r_f * r_f - dy_f * dy_f); + const dx = @as(i32, @intFromFloat(dx_f)); + + self.ctx.pushCommand(Command.rect( + x - dx, + y + dy, + @intCast(dx * 2 + 1), + 1, + color, + )); + } + } + + /// Draw a circle outline + pub fn strokeCircle(self: *Self, cx: i32, cy: i32, radius: u32, color: Style.Color) void { + if (radius == 0) return; + + const r = @as(i32, @intCast(radius)); + const x = self.offset.x + cx; + const y = self.offset.y + cy; + + // Draw circle outline using Bresenham's algorithm + var px: i32 = 0; + var py: i32 = r; + var d: i32 = 3 - 2 * r; + + while (px <= py) { + // Draw 8 symmetric points + self.setPixel(x + px, y + py, color); + self.setPixel(x - px, y + py, color); + self.setPixel(x + px, y - py, color); + self.setPixel(x - px, y - py, color); + self.setPixel(x + py, y + px, color); + self.setPixel(x - py, y + px, color); + self.setPixel(x + py, y - px, color); + self.setPixel(x - py, y - px, color); + + if (d < 0) { + d = d + 4 * px + 6; + } else { + d = d + 4 * (px - py) + 10; + py -= 1; + } + px += 1; + } + } + + /// Set a single pixel + fn setPixel(self: *Self, x: i32, y: i32, color: Style.Color) void { + self.ctx.pushCommand(Command.rect(x, y, 1, 1, color)); + } + + // ========================================================================= + // Arc and pie + // ========================================================================= + + /// Draw a filled arc/pie segment + pub fn fillArc( + self: *Self, + cx: i32, + cy: i32, + radius: u32, + start_angle: f32, + end_angle: f32, + color: Style.Color, + ) void { + if (radius == 0) return; + + const r = @as(f32, @floatFromInt(radius)); + const x = self.offset.x + cx; + const y = self.offset.y + cy; + + // Draw arc using line segments + const segments: u32 = @max(8, radius / 2); + const angle_step = (end_angle - start_angle) / @as(f32, @floatFromInt(segments)); + + var angle = start_angle; + var i: u32 = 0; + while (i < segments) : (i += 1) { + const next_angle = angle + angle_step; + + // Calculate points + const x1 = x + @as(i32, @intFromFloat(@cos(angle) * r)); + const y1 = y + @as(i32, @intFromFloat(@sin(angle) * r)); + const x2 = x + @as(i32, @intFromFloat(@cos(next_angle) * r)); + const y2 = y + @as(i32, @intFromFloat(@sin(next_angle) * r)); + + // Draw triangle from center to arc segment + self.fillTriangle(x, y, x1, y1, x2, y2, color); + + angle = next_angle; + } + } + + /// Draw a triangle (filled) + pub fn fillTriangle( + self: *Self, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + x3: i32, + y3: i32, + color: Style.Color, + ) void { + // Sort vertices by y + var v1 = Point.init(x1, y1); + var v2 = Point.init(x2, y2); + var v3 = Point.init(x3, y3); + + if (v1.y > v2.y) std.mem.swap(Point, &v1, &v2); + if (v1.y > v3.y) std.mem.swap(Point, &v1, &v3); + if (v2.y > v3.y) std.mem.swap(Point, &v2, &v3); + + // Fill using horizontal scanlines + const total_height = v3.y - v1.y; + if (total_height == 0) return; + + var py = v1.y; + while (py <= v3.y) : (py += 1) { + const second_half = py > v2.y or v2.y == v1.y; + const segment_height = if (second_half) v3.y - v2.y else v2.y - v1.y; + + if (segment_height == 0) continue; + + const alpha = @as(f32, @floatFromInt(py - v1.y)) / @as(f32, @floatFromInt(total_height)); + const beta = @as(f32, @floatFromInt(py - (if (second_half) v2.y else v1.y))) / + @as(f32, @floatFromInt(segment_height)); + + var ax = v1.x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(v3.x - v1.x)) * alpha)); + var bx: i32 = undefined; + + if (second_half) { + bx = v2.x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(v3.x - v2.x)) * beta)); + } else { + bx = v1.x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(v2.x - v1.x)) * beta)); + } + + if (ax > bx) std.mem.swap(i32, &ax, &bx); + + self.ctx.pushCommand(Command.rect(ax, py, @intCast(bx - ax + 1), 1, color)); + } + } + + // ========================================================================= + // Polygon + // ========================================================================= + + /// Draw a polygon outline + pub fn strokePolygon(self: *Self, points: []const Point, color: Style.Color) void { + if (points.len < 2) return; + + var i: usize = 0; + while (i < points.len) : (i += 1) { + const p1 = points[i]; + const p2 = points[(i + 1) % points.len]; + self.line(p1.x, p1.y, p2.x, p2.y, color); + } + } + + // ========================================================================= + // Rounded rectangle + // ========================================================================= + + /// Draw a filled rounded rectangle + pub fn fillRoundedRect( + self: *Self, + x: i32, + y: i32, + w: u32, + h: u32, + radius: u32, + color: Style.Color, + ) void { + if (w == 0 or h == 0) return; + + const r = @min(radius, @min(w / 2, h / 2)); + const ri = @as(i32, @intCast(r)); + + // Center rectangle + self.fillRect(x + ri, y, w - r * 2, h, color); + + // Left rectangle + self.fillRect(x, y + ri, r, h - r * 2, color); + + // Right rectangle + self.fillRect(x + @as(i32, @intCast(w)) - ri, y + ri, r, h - r * 2, color); + + // Four corners + if (r > 0) { + self.fillCorner(x + ri, y + ri, r, 2, color); // Top-left + self.fillCorner(x + @as(i32, @intCast(w)) - ri - 1, y + ri, r, 1, color); // Top-right + self.fillCorner(x + ri, y + @as(i32, @intCast(h)) - ri - 1, r, 3, color); // Bottom-left + self.fillCorner(x + @as(i32, @intCast(w)) - ri - 1, y + @as(i32, @intCast(h)) - ri - 1, r, 0, color); // Bottom-right + } + } + + /// Fill a quarter circle (corner) + fn fillCorner(self: *Self, cx: i32, cy: i32, radius: u32, quadrant: u8, color: Style.Color) void { + const r = @as(i32, @intCast(radius)); + const ox = self.offset.x + cx; + const oy = self.offset.y + cy; + + var dy: i32 = 0; + while (dy <= r) : (dy += 1) { + const dy_f = @as(f32, @floatFromInt(dy)); + const r_f = @as(f32, @floatFromInt(r)); + const dx = @as(i32, @intFromFloat(@sqrt(r_f * r_f - dy_f * dy_f))); + + switch (quadrant) { + 0 => { // Bottom-right + self.ctx.pushCommand(Command.rect(ox, oy + dy, @intCast(dx + 1), 1, color)); + }, + 1 => { // Top-right + self.ctx.pushCommand(Command.rect(ox, oy - dy, @intCast(dx + 1), 1, color)); + }, + 2 => { // Top-left + self.ctx.pushCommand(Command.rect(ox - dx, oy - dy, @intCast(dx + 1), 1, color)); + }, + 3 => { // Bottom-left + self.ctx.pushCommand(Command.rect(ox - dx, oy + dy, @intCast(dx + 1), 1, color)); + }, + else => {}, + } + } + } + + // ========================================================================= + // Gradient + // ========================================================================= + + /// Draw a horizontal gradient + pub fn horizontalGradient( + self: *Self, + x: i32, + y: i32, + w: u32, + h: u32, + start: Style.Color, + end: Style.Color, + ) void { + if (w == 0) return; + + var px: u32 = 0; + while (px < w) : (px += 1) { + const t = @as(f32, @floatFromInt(px)) / @as(f32, @floatFromInt(w - 1)); + const color = lerpColor(start, end, t); + self.ctx.pushCommand(Command.rect( + self.offset.x + x + @as(i32, @intCast(px)), + self.offset.y + y, + 1, + h, + color, + )); + } + } + + /// Draw a vertical gradient + pub fn verticalGradient( + self: *Self, + x: i32, + y: i32, + w: u32, + h: u32, + start: Style.Color, + end: Style.Color, + ) void { + if (h == 0) return; + + var py: u32 = 0; + while (py < h) : (py += 1) { + const t = @as(f32, @floatFromInt(py)) / @as(f32, @floatFromInt(h - 1)); + const color = lerpColor(start, end, t); + self.ctx.pushCommand(Command.rect( + self.offset.x + x, + self.offset.y + y + @as(i32, @intCast(py)), + w, + 1, + color, + )); + } + } + + /// Clear the canvas area + pub fn clear(self: *Self, color: Style.Color) void { + self.ctx.pushCommand(Command.rect( + self.bounds.x, + self.bounds.y, + self.bounds.w, + self.bounds.h, + color, + )); + } + + /// Get canvas width + pub fn width(self: Self) u32 { + return self.bounds.w; + } + + /// Get canvas height + pub fn height(self: Self) u32 { + return self.bounds.h; + } +}; + +/// Linear interpolation between colors +fn lerpColor(a: Style.Color, b: Style.Color, t: f32) Style.Color { + const t_clamped = @max(0.0, @min(1.0, t)); + return Style.Color.rgba( + @intFromFloat(@as(f32, @floatFromInt(a.r)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.r)) * t_clamped), + @intFromFloat(@as(f32, @floatFromInt(a.g)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.g)) * t_clamped), + @intFromFloat(@as(f32, @floatFromInt(a.b)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.b)) * t_clamped), + @intFromFloat(@as(f32, @floatFromInt(a.a)) * (1 - t_clamped) + @as(f32, @floatFromInt(b.a)) * t_clamped), + ); +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "Point operations" { + const p1 = Point.init(10, 20); + const p2 = Point.init(5, 10); + + const sum = p1.add(p2); + try std.testing.expectEqual(@as(i32, 15), sum.x); + try std.testing.expectEqual(@as(i32, 30), sum.y); + + const diff = p1.sub(p2); + try std.testing.expectEqual(@as(i32, 5), diff.x); + try std.testing.expectEqual(@as(i32, 10), diff.y); +} + +test "Canvas basic drawing" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 200; + + var canvas = Canvas.begin(&ctx); + canvas.fillRect(10, 10, 50, 50, Style.Color.rgba(255, 0, 0, 255)); + canvas.strokeRect(70, 10, 50, 50, Style.Color.rgba(0, 255, 0, 255)); + canvas.line(10, 100, 100, 150, Style.Color.rgba(0, 0, 255, 255)); + + try std.testing.expect(ctx.commands.items.len >= 3); + + ctx.endFrame(); +} + +test "Canvas circle" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 200; + + var canvas = Canvas.begin(&ctx); + canvas.fillCircle(100, 100, 50, Style.Color.rgba(255, 255, 0, 255)); + + try std.testing.expect(ctx.commands.items.len >= 1); + + ctx.endFrame(); +} + +test "lerpColor" { + const black = Style.Color.rgba(0, 0, 0, 255); + const white = Style.Color.rgba(255, 255, 255, 255); + + const mid = lerpColor(black, white, 0.5); + try std.testing.expect(mid.r > 120 and mid.r < 130); + try std.testing.expect(mid.g > 120 and mid.g < 130); +} diff --git a/src/widgets/chart.zig b/src/widgets/chart.zig new file mode 100644 index 0000000..23d58cf --- /dev/null +++ b/src/widgets/chart.zig @@ -0,0 +1,666 @@ +//! Chart Widgets - Data visualization +//! +//! Line chart, bar chart, and pie chart for data visualization. +//! All charts auto-scale to fit data. + +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 canvas = @import("canvas.zig"); + +/// Data point for charts +pub const DataPoint = struct { + value: f64, + label: ?[]const u8 = null, + color: ?Style.Color = null, +}; + +/// Data series for multi-series charts +pub const DataSeries = struct { + name: []const u8, + data: []const f64, + color: Style.Color, +}; + +/// Default chart colors (palette) +pub const default_colors = [_]Style.Color{ + Style.Color.rgba(100, 149, 237, 255), // Cornflower blue + Style.Color.rgba(255, 99, 132, 255), // Red + Style.Color.rgba(75, 192, 192, 255), // Teal + Style.Color.rgba(255, 206, 86, 255), // Yellow + Style.Color.rgba(153, 102, 255, 255), // Purple + Style.Color.rgba(255, 159, 64, 255), // Orange + Style.Color.rgba(54, 162, 235, 255), // Blue + Style.Color.rgba(201, 203, 207, 255), // Gray +}; + +// ============================================================================= +// Line Chart +// ============================================================================= + +/// Line chart configuration +pub const LineChartConfig = struct { + /// Show points + show_points: bool = true, + /// Point radius + point_radius: u32 = 4, + /// Show grid + show_grid: bool = true, + /// Show x labels + show_x_labels: bool = true, + /// Show y labels + show_y_labels: bool = true, + /// Padding + padding: u32 = 40, + /// Fill under line + fill: bool = false, + /// Smooth line (bezier) + smooth: bool = false, + /// Y axis min (null = auto) + y_min: ?f64 = null, + /// Y axis max (null = auto) + y_max: ?f64 = null, +}; + +/// Line chart colors +pub const LineChartColors = struct { + background: ?Style.Color = null, + grid: Style.Color = Style.Color.rgba(60, 60, 60, 255), + axis: Style.Color = Style.Color.rgba(100, 100, 100, 255), + label: Style.Color = Style.Color.rgba(180, 180, 180, 255), + line: Style.Color = Style.Color.rgba(100, 149, 237, 255), + point: Style.Color = Style.Color.rgba(100, 149, 237, 255), + fill_color: Style.Color = Style.Color.rgba(100, 149, 237, 80), + + pub fn fromTheme(theme: Style.Theme) LineChartColors { + return .{ + .grid = theme.secondary.darken(30), + .axis = theme.secondary, + .label = theme.foreground, + .line = theme.primary, + .point = theme.primary, + .fill_color = theme.primary.withAlpha(80), + }; + } +}; + +/// Draw a line chart +pub fn lineChart(ctx: *Context, data: []const f64) void { + lineChartEx(ctx, data, .{}, .{}); +} + +/// Draw a line chart with configuration +pub fn lineChartEx( + ctx: *Context, + data: []const f64, + config: LineChartConfig, + colors: LineChartColors, +) void { + const bounds = ctx.layout.nextRect(); + lineChartRect(ctx, bounds, data, config, colors); +} + +/// Draw a line chart in specific rectangle +pub fn lineChartRect( + ctx: *Context, + bounds: Layout.Rect, + data: []const f64, + config: LineChartConfig, + colors: LineChartColors, +) void { + if (bounds.isEmpty() or data.len < 2) return; + + // Background + if (colors.background) |bg| { + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg)); + } + + const padding = config.padding; + const chart_x = bounds.x + @as(i32, @intCast(padding)); + const chart_y = bounds.y + @as(i32, @intCast(padding / 2)); + const chart_w = bounds.w -| (padding * 2); + const chart_h = bounds.h -| padding; + + if (chart_w < 10 or chart_h < 10) return; + + // Find data range + var min_val: f64 = config.y_min orelse data[0]; + var max_val: f64 = config.y_max orelse data[0]; + + if (config.y_min == null or config.y_max == null) { + for (data) |v| { + if (config.y_min == null and v < min_val) min_val = v; + if (config.y_max == null and v > max_val) max_val = v; + } + } + + // Add padding to range + const range = max_val - min_val; + if (range == 0) { + min_val -= 1; + max_val += 1; + } + + // Draw grid + if (config.show_grid) { + const grid_lines: u32 = 5; + var i: u32 = 0; + while (i <= grid_lines) : (i += 1) { + const y_pos = chart_y + @as(i32, @intCast(chart_h * i / grid_lines)); + ctx.pushCommand(Command.rect(chart_x, y_pos, chart_w, 1, colors.grid)); + } + } + + // Draw y labels + if (config.show_y_labels) { + const label_count: u32 = 5; + var i: u32 = 0; + while (i <= label_count) : (i += 1) { + const val = max_val - (max_val - min_val) * @as(f64, @floatFromInt(i)) / @as(f64, @floatFromInt(label_count)); + const y_pos = chart_y + @as(i32, @intCast(chart_h * i / label_count)); + + // Format value + var buf: [16]u8 = undefined; + const label = std.fmt.bufPrint(&buf, "{d:.1}", .{val}) catch continue; + ctx.pushCommand(Command.text(bounds.x + 4, y_pos - 4, label, colors.label)); + } + } + + // Calculate point positions + const x_step = @as(f64, @floatFromInt(chart_w)) / @as(f64, @floatFromInt(data.len - 1)); + const y_scale = @as(f64, @floatFromInt(chart_h)) / (max_val - min_val); + + // Draw fill if enabled + if (config.fill) { + var xi: usize = 0; + while (xi < data.len - 1) : (xi += 1) { + const x1 = chart_x + @as(i32, @intFromFloat(@as(f64, @floatFromInt(xi)) * x_step)); + const x2 = chart_x + @as(i32, @intFromFloat(@as(f64, @floatFromInt(xi + 1)) * x_step)); + const y1 = chart_y + @as(i32, @intFromFloat((max_val - data[xi]) * y_scale)); + const y2 = chart_y + @as(i32, @intFromFloat((max_val - data[xi + 1]) * y_scale)); + const bottom = chart_y + @as(i32, @intCast(chart_h)); + + // Draw quad as two triangles + drawFilledQuad(ctx, x1, y1, x2, y2, x2, bottom, x1, bottom, colors.fill_color); + } + } + + // Draw lines + var xi: usize = 0; + while (xi < data.len - 1) : (xi += 1) { + const x1 = chart_x + @as(i32, @intFromFloat(@as(f64, @floatFromInt(xi)) * x_step)); + const x2 = chart_x + @as(i32, @intFromFloat(@as(f64, @floatFromInt(xi + 1)) * x_step)); + const y1 = chart_y + @as(i32, @intFromFloat((max_val - data[xi]) * y_scale)); + const y2 = chart_y + @as(i32, @intFromFloat((max_val - data[xi + 1]) * y_scale)); + + ctx.pushCommand(Command.line(x1, y1, x2, y2, colors.line)); + } + + // Draw points + if (config.show_points) { + xi = 0; + while (xi < data.len) : (xi += 1) { + const px = chart_x + @as(i32, @intFromFloat(@as(f64, @floatFromInt(xi)) * x_step)); + const py = chart_y + @as(i32, @intFromFloat((max_val - data[xi]) * y_scale)); + const r = config.point_radius; + + // Draw filled circle (simple approximation) + var dy: i32 = -@as(i32, @intCast(r)); + while (dy <= @as(i32, @intCast(r))) : (dy += 1) { + const dy_f = @as(f32, @floatFromInt(dy)); + const r_f = @as(f32, @floatFromInt(r)); + const dx = @as(i32, @intFromFloat(@sqrt(r_f * r_f - dy_f * dy_f))); + ctx.pushCommand(Command.rect(px - dx, py + dy, @intCast(dx * 2 + 1), 1, colors.point)); + } + } + } +} + +/// Helper to draw filled quad +fn drawFilledQuad( + ctx: *Context, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + x3: i32, + y3: i32, + x4: i32, + y4: i32, + color: Style.Color, +) void { + // Find y range + const min_y = @min(@min(y1, y2), @min(y3, y4)); + const max_y = @max(@max(y1, y2), @max(y3, y4)); + + // Scanline fill + var y = min_y; + while (y <= max_y) : (y += 1) { + var min_x: i32 = std.math.maxInt(i32); + var max_x: i32 = std.math.minInt(i32); + + // Check intersections with all edges + const edges = [_][4]i32{ + .{ x1, y1, x2, y2 }, + .{ x2, y2, x3, y3 }, + .{ x3, y3, x4, y4 }, + .{ x4, y4, x1, y1 }, + }; + + for (edges) |edge| { + const ex1 = edge[0]; + const ey1 = edge[1]; + const ex2 = edge[2]; + const ey2 = edge[3]; + + if ((ey1 <= y and y < ey2) or (ey2 <= y and y < ey1)) { + const x = ex1 + @divTrunc((y - ey1) * (ex2 - ex1), (ey2 - ey1)); + min_x = @min(min_x, x); + max_x = @max(max_x, x); + } + } + + if (min_x <= max_x) { + ctx.pushCommand(Command.rect(min_x, y, @intCast(max_x - min_x + 1), 1, color)); + } + } +} + +// ============================================================================= +// Bar Chart +// ============================================================================= + +/// Bar chart configuration +pub const BarChartConfig = struct { + /// Horizontal bars + horizontal: bool = false, + /// Show values on bars + show_values: bool = true, + /// Bar spacing (0.0-1.0) + spacing: f32 = 0.2, + /// Show grid + show_grid: bool = true, + /// Padding + padding: u32 = 40, + /// Stacked bars + stacked: bool = false, + /// Y axis min (null = auto, 0 for bar charts) + y_min: ?f64 = 0, + /// Y axis max (null = auto) + y_max: ?f64 = null, +}; + +/// Bar chart colors +pub const BarChartColors = struct { + background: ?Style.Color = null, + grid: Style.Color = Style.Color.rgba(60, 60, 60, 255), + axis: Style.Color = Style.Color.rgba(100, 100, 100, 255), + label: Style.Color = Style.Color.rgba(180, 180, 180, 255), + bar: Style.Color = Style.Color.rgba(100, 149, 237, 255), + value_text: Style.Color = Style.Color.rgba(255, 255, 255, 255), + + pub fn fromTheme(theme: Style.Theme) BarChartColors { + return .{ + .grid = theme.secondary.darken(30), + .axis = theme.secondary, + .label = theme.foreground, + .bar = theme.primary, + .value_text = theme.foreground, + }; + } +}; + +/// Draw a bar chart +pub fn barChart(ctx: *Context, data: []const DataPoint) void { + barChartEx(ctx, data, .{}, .{}); +} + +/// Draw a bar chart with configuration +pub fn barChartEx( + ctx: *Context, + data: []const DataPoint, + config: BarChartConfig, + colors: BarChartColors, +) void { + const bounds = ctx.layout.nextRect(); + barChartRect(ctx, bounds, data, config, colors); +} + +/// Draw a bar chart in specific rectangle +pub fn barChartRect( + ctx: *Context, + bounds: Layout.Rect, + data: []const DataPoint, + config: BarChartConfig, + colors: BarChartColors, +) void { + if (bounds.isEmpty() or data.len == 0) return; + + // Background + if (colors.background) |bg| { + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg)); + } + + const padding = config.padding; + const chart_x = bounds.x + @as(i32, @intCast(padding)); + const chart_y = bounds.y + @as(i32, @intCast(padding / 2)); + const chart_w = bounds.w -| (padding * 2); + const chart_h = bounds.h -| padding; + + if (chart_w < 10 or chart_h < 10) return; + + // Find data range + const min_val: f64 = config.y_min orelse 0; + var max_val: f64 = config.y_max orelse data[0].value; + + if (config.y_max == null) { + for (data) |d| { + if (d.value > max_val) max_val = d.value; + } + } + + // Add padding + if (max_val == min_val) { + max_val += 1; + } + max_val *= 1.1; // 10% padding at top + + // Draw grid + if (config.show_grid) { + const grid_lines: u32 = 5; + var i: u32 = 0; + while (i <= grid_lines) : (i += 1) { + const y_pos = chart_y + @as(i32, @intCast(chart_h * i / grid_lines)); + ctx.pushCommand(Command.rect(chart_x, y_pos, chart_w, 1, colors.grid)); + } + } + + // Calculate bar dimensions + const bar_total_w = @as(f64, @floatFromInt(chart_w)) / @as(f64, @floatFromInt(data.len)); + const bar_spacing = bar_total_w * config.spacing; + const bar_w = bar_total_w - bar_spacing; + + // Draw bars + for (data, 0..) |d, i| { + const bar_x = chart_x + @as(i32, @intFromFloat(@as(f64, @floatFromInt(i)) * bar_total_w + bar_spacing / 2)); + const bar_height = @as(u32, @intFromFloat((d.value - min_val) / (max_val - min_val) * @as(f64, @floatFromInt(chart_h)))); + const bar_y = chart_y + @as(i32, @intCast(chart_h - bar_height)); + + const bar_color = d.color orelse colors.bar; + ctx.pushCommand(Command.rect(bar_x, bar_y, @intFromFloat(bar_w), bar_height, bar_color)); + + // Draw value + if (config.show_values and bar_height > 15) { + var buf: [16]u8 = undefined; + const value_str = std.fmt.bufPrint(&buf, "{d:.0}", .{d.value}) catch continue; + const text_x = bar_x + @as(i32, @intFromFloat(bar_w / 2)) - @as(i32, @intCast(value_str.len * 4)); + ctx.pushCommand(Command.text(text_x, bar_y + 4, value_str, colors.value_text)); + } + + // Draw label + if (d.label) |lbl| { + const label_x = bar_x + @as(i32, @intFromFloat(bar_w / 2)) - @as(i32, @intCast(lbl.len * 4)); + const label_y = chart_y + @as(i32, @intCast(chart_h)) + 4; + ctx.pushCommand(Command.text(label_x, label_y, lbl, colors.label)); + } + } +} + +// ============================================================================= +// Pie Chart +// ============================================================================= + +/// Pie chart configuration +pub const PieChartConfig = struct { + /// Show labels + show_labels: bool = true, + /// Show values + show_values: bool = true, + /// Show percentages + show_percentages: bool = true, + /// Donut hole radius (0 = no hole) + donut_radius: f32 = 0.0, + /// Start angle (radians, 0 = right) + start_angle: f32 = -std.math.pi / 2.0, // Top + /// Padding + padding: u32 = 20, + /// Explode all slices + explode: f32 = 0.0, +}; + +/// Pie chart colors +pub const PieChartColors = struct { + background: ?Style.Color = null, + label: Style.Color = Style.Color.rgba(220, 220, 220, 255), + + pub fn fromTheme(theme: Style.Theme) PieChartColors { + return .{ + .label = theme.foreground, + }; + } +}; + +/// Draw a pie chart +pub fn pieChart(ctx: *Context, data: []const DataPoint) void { + pieChartEx(ctx, data, .{}, .{}); +} + +/// Draw a pie chart with configuration +pub fn pieChartEx( + ctx: *Context, + data: []const DataPoint, + config: PieChartConfig, + colors: PieChartColors, +) void { + const bounds = ctx.layout.nextRect(); + pieChartRect(ctx, bounds, data, config, colors); +} + +/// Draw a pie chart in specific rectangle +pub fn pieChartRect( + ctx: *Context, + bounds: Layout.Rect, + data: []const DataPoint, + config: PieChartConfig, + colors: PieChartColors, +) void { + if (bounds.isEmpty() or data.len == 0) return; + + // Background + if (colors.background) |bg| { + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg)); + } + + // Calculate total + var total: f64 = 0; + for (data) |d| { + total += d.value; + } + if (total == 0) return; + + // Calculate center and radius + const padding = config.padding; + const chart_w = bounds.w -| (padding * 2); + const chart_h = bounds.h -| (padding * 2); + const radius = @min(chart_w, chart_h) / 2; + const center_x = bounds.x + @as(i32, @intCast(bounds.w / 2)); + const center_y = bounds.y + @as(i32, @intCast(bounds.h / 2)); + + if (radius < 10) return; + + // Draw slices + var current_angle = config.start_angle; + const r_f = @as(f32, @floatFromInt(radius)); + + for (data, 0..) |d, i| { + const slice_angle = @as(f32, @floatFromInt(@as(u32, @intFromFloat(d.value / total * std.math.pi * 2.0 * 1000.0)))) / 1000.0; + const slice_color = d.color orelse default_colors[i % default_colors.len]; + + // Draw arc using triangles + const segments: u32 = @max(8, @as(u32, @intFromFloat(slice_angle * r_f / 4))); + const angle_step = slice_angle / @as(f32, @floatFromInt(segments)); + + var angle = current_angle; + var seg: u32 = 0; + while (seg < segments) : (seg += 1) { + const next_angle = angle + angle_step; + + const x1 = center_x + @as(i32, @intFromFloat(@cos(angle) * r_f)); + const y1 = center_y + @as(i32, @intFromFloat(@sin(angle) * r_f)); + const x2 = center_x + @as(i32, @intFromFloat(@cos(next_angle) * r_f)); + const y2 = center_y + @as(i32, @intFromFloat(@sin(next_angle) * r_f)); + + // Draw triangle + drawFilledTriangle(ctx, center_x, center_y, x1, y1, x2, y2, slice_color); + + angle = next_angle; + } + + // Draw label + if (config.show_labels and d.label != null) { + const mid_angle = current_angle + slice_angle / 2; + const label_r = r_f * 0.7; + const label_x = center_x + @as(i32, @intFromFloat(@cos(mid_angle) * label_r)); + const label_y = center_y + @as(i32, @intFromFloat(@sin(mid_angle) * label_r)); + + if (d.label) |lbl| { + const text_x = label_x - @as(i32, @intCast(lbl.len * 4)); + ctx.pushCommand(Command.text(text_x, label_y - 4, lbl, colors.label)); + } + } + + current_angle += slice_angle; + } + + // Draw donut hole + if (config.donut_radius > 0) { + const hole_r = @as(u32, @intFromFloat(r_f * config.donut_radius)); + if (colors.background) |bg| { + // Fill center with background + var dy: i32 = -@as(i32, @intCast(hole_r)); + while (dy <= @as(i32, @intCast(hole_r))) : (dy += 1) { + const dy_f = @as(f32, @floatFromInt(dy)); + const hr_f = @as(f32, @floatFromInt(hole_r)); + const dx = @as(i32, @intFromFloat(@sqrt(hr_f * hr_f - dy_f * dy_f))); + ctx.pushCommand(Command.rect(center_x - dx, center_y + dy, @intCast(dx * 2 + 1), 1, bg)); + } + } + } +} + +/// Draw a filled triangle +fn drawFilledTriangle( + ctx: *Context, + x1: i32, + y1: i32, + x2: i32, + y2: i32, + x3: i32, + y3: i32, + color: Style.Color, +) void { + // Sort vertices by y + var v1 = canvas.Point.init(x1, y1); + var v2 = canvas.Point.init(x2, y2); + var v3 = canvas.Point.init(x3, y3); + + if (v1.y > v2.y) std.mem.swap(canvas.Point, &v1, &v2); + if (v1.y > v3.y) std.mem.swap(canvas.Point, &v1, &v3); + if (v2.y > v3.y) std.mem.swap(canvas.Point, &v2, &v3); + + const total_height = v3.y - v1.y; + if (total_height == 0) return; + + var py = v1.y; + while (py <= v3.y) : (py += 1) { + const second_half = py > v2.y or v2.y == v1.y; + const segment_height = if (second_half) v3.y - v2.y else v2.y - v1.y; + + if (segment_height == 0) continue; + + const alpha = @as(f32, @floatFromInt(py - v1.y)) / @as(f32, @floatFromInt(total_height)); + const beta = @as(f32, @floatFromInt(py - (if (second_half) v2.y else v1.y))) / + @as(f32, @floatFromInt(segment_height)); + + var ax = v1.x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(v3.x - v1.x)) * alpha)); + var bx: i32 = undefined; + + if (second_half) { + bx = v2.x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(v3.x - v2.x)) * beta)); + } else { + bx = v1.x + @as(i32, @intFromFloat(@as(f32, @floatFromInt(v2.x - v1.x)) * beta)); + } + + if (ax > bx) std.mem.swap(i32, &ax, &bx); + + ctx.pushCommand(Command.rect(ax, py, @intCast(bx - ax + 1), 1, color)); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "DataPoint creation" { + const point = DataPoint{ + .value = 42.5, + .label = "Test", + }; + try std.testing.expectEqual(@as(f64, 42.5), point.value); + try std.testing.expectEqualStrings("Test", point.label.?); +} + +test "lineChart generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 300; + + const data = [_]f64{ 10, 20, 15, 30, 25 }; + lineChart(&ctx, &data); + + try std.testing.expect(ctx.commands.items.len >= 1); + + ctx.endFrame(); +} + +test "barChart generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 300; + + const data = [_]DataPoint{ + .{ .value = 10, .label = "A" }, + .{ .value = 20, .label = "B" }, + .{ .value = 15, .label = "C" }, + }; + barChart(&ctx, &data); + + try std.testing.expect(ctx.commands.items.len >= 1); + + ctx.endFrame(); +} + +test "pieChart generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 300; + + const data = [_]DataPoint{ + .{ .value = 30, .label = "A" }, + .{ .value = 50, .label = "B" }, + .{ .value = 20, .label = "C" }, + }; + pieChart(&ctx, &data); + + try std.testing.expect(ctx.commands.items.len >= 1); + + ctx.endFrame(); +} diff --git a/src/widgets/icon.zig b/src/widgets/icon.zig new file mode 100644 index 0000000..f1036c4 --- /dev/null +++ b/src/widgets/icon.zig @@ -0,0 +1,805 @@ +//! Icon Widget - Vector icon system +//! +//! A lightweight icon system using simple vector drawing. +//! Icons are defined as draw commands for resolution independence. + +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"); + +/// Icon size presets +pub const Size = enum { + small, // 12x12 + medium, // 16x16 + large, // 24x24 + xlarge, // 32x32 + + pub fn pixels(self: Size) u32 { + return switch (self) { + .small => 12, + .medium => 16, + .large => 24, + .xlarge => 32, + }; + } +}; + +/// Built-in icon types +pub const IconType = enum { + // Navigation + arrow_up, + arrow_down, + arrow_left, + arrow_right, + chevron_up, + chevron_down, + chevron_left, + chevron_right, + home, + menu, + more_horizontal, + more_vertical, + + // Actions + check, + close, + plus, + minus, + edit, + delete, + refresh, + search, + settings, + filter, + sort, + copy, + paste, + cut, + undo, + redo, + + // Files + file, + folder, + folder_open, + document, + image_file, + download, + upload, + save, + + // Status + info, + warning, + error_icon, + success, + question, + star, + star_filled, + heart, + heart_filled, + + // UI elements + eye, + eye_off, + lock, + unlock, + user, + users, + calendar, + clock, + bell, + mail, + + // Media + play, + pause, + stop, + volume, + volume_off, + + // Misc + grip, + drag, + expand, + collapse, + maximize, + minimize, + external_link, +}; + +/// Icon configuration +pub const Config = struct { + /// Icon size + size: Size = .medium, + /// Custom size (overrides size preset) + custom_size: ?u32 = null, + /// Stroke width + stroke_width: u32 = 2, + /// Fill icon + filled: bool = false, +}; + +/// Icon colors +pub const Colors = struct { + foreground: Style.Color = Style.Color.rgba(220, 220, 220, 255), + background: ?Style.Color = null, + + pub fn fromTheme(theme: Style.Theme) Colors { + return .{ + .foreground = theme.foreground, + }; + } +}; + +/// Draw an icon +pub fn icon(ctx: *Context, icon_type: IconType) void { + iconEx(ctx, icon_type, .{}, .{}); +} + +/// Draw an icon with configuration +pub fn iconEx( + ctx: *Context, + icon_type: IconType, + config: Config, + colors: Colors, +) void { + const bounds = ctx.layout.nextRect(); + iconRect(ctx, bounds, icon_type, config, colors); +} + +/// Draw an icon in specific rectangle +pub fn iconRect( + ctx: *Context, + bounds: Layout.Rect, + icon_type: IconType, + config: Config, + colors: Colors, +) void { + if (bounds.isEmpty()) return; + + // Background + if (colors.background) |bg| { + ctx.pushCommand(Command.rect(bounds.x, bounds.y, bounds.w, bounds.h, bg)); + } + + const size = config.custom_size orelse config.size.pixels(); + const x = bounds.x + @as(i32, @intCast((bounds.w -| size) / 2)); + const y = bounds.y + @as(i32, @intCast((bounds.h -| size) / 2)); + const s: i32 = @intCast(size); + const sw = config.stroke_width; + + // Pre-calculated divisions to avoid runtime @divTrunc issues + const s2 = @divTrunc(s, 2); + const s3 = @divTrunc(s, 3); + const s4 = @divTrunc(s, 4); + const s5 = @divTrunc(s, 5); + const s6 = @divTrunc(s, 6); + const s23 = @divTrunc(s * 2, 3); + const s34 = @divTrunc(s * 3, 4); + + const fg = colors.foreground; + + // Draw icon based on type + switch (icon_type) { + // Arrows + .arrow_up => { + drawLine(ctx, x + s2, y + 2, x + 2, y + s - 4, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s - 2, y + s - 4, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s2, y + s - 2, sw, fg); + }, + .arrow_down => { + drawLine(ctx, x + s2, y + s - 2, x + 2, y + 4, sw, fg); + drawLine(ctx, x + s2, y + s - 2, x + s - 2, y + 4, sw, fg); + drawLine(ctx, x + s2, y + s - 2, x + s2, y + 2, sw, fg); + }, + .arrow_left => { + drawLine(ctx, x + 2, y + s2, x + s - 4, y + 2, sw, fg); + drawLine(ctx, x + 2, y + s2, x + s - 4, y + s - 2, sw, fg); + drawLine(ctx, x + 2, y + s2, x + s - 2, y + s2, sw, fg); + }, + .arrow_right => { + drawLine(ctx, x + s - 2, y + s2, x + 4, y + 2, sw, fg); + drawLine(ctx, x + s - 2, y + s2, x + 4, y + s - 2, sw, fg); + drawLine(ctx, x + s - 2, y + s2, x + 2, y + s2, sw, fg); + }, + + // Chevrons + .chevron_up => { + drawLine(ctx, x + 2, y + s23, x + s2, y + s3, sw, fg); + drawLine(ctx, x + s2, y + s3, x + s - 2, y + s23, sw, fg); + }, + .chevron_down => { + drawLine(ctx, x + 2, y + s3, x + s2, y + s23, sw, fg); + drawLine(ctx, x + s2, y + s23, x + s - 2, y + s3, sw, fg); + }, + .chevron_left => { + drawLine(ctx, x + s23, y + 2, x + s3, y + s2, sw, fg); + drawLine(ctx, x + s3, y + s2, x + s23, y + s - 2, sw, fg); + }, + .chevron_right => { + drawLine(ctx, x + s3, y + 2, x + s23, y + s2, sw, fg); + drawLine(ctx, x + s23, y + s2, x + s3, y + s - 2, sw, fg); + }, + + // Actions + .check => { + drawLine(ctx, x + 2, y + s2, x + s3, y + s - 4, sw, fg); + drawLine(ctx, x + s3, y + s - 4, x + s - 2, y + 3, sw, fg); + }, + .close => { + drawLine(ctx, x + 3, y + 3, x + s - 3, y + s - 3, sw, fg); + drawLine(ctx, x + s - 3, y + 3, x + 3, y + s - 3, sw, fg); + }, + .plus => { + drawLine(ctx, x + s2, y + 3, x + s2, y + s - 3, sw, fg); + drawLine(ctx, x + 3, y + s2, x + s - 3, y + s2, sw, fg); + }, + .minus => { + drawLine(ctx, x + 3, y + s2, x + s - 3, y + s2, sw, fg); + }, + + // Home + .home => { + drawLine(ctx, x + 2, y + s2, x + s2, y + 2, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s - 2, y + s2, sw, fg); + drawLine(ctx, x + 3, y + s2, x + 3, y + s - 2, sw, fg); + drawLine(ctx, x + s - 3, y + s2, x + s - 3, y + s - 2, sw, fg); + drawLine(ctx, x + 3, y + s - 2, x + s - 3, y + s - 2, sw, fg); + }, + + // Menu + .menu => { + const bar_y1 = y + s4; + const bar_y2 = y + s2; + const bar_y3 = y + s34; + drawLine(ctx, x + 2, bar_y1, x + s - 2, bar_y1, sw, fg); + drawLine(ctx, x + 2, bar_y2, x + s - 2, bar_y2, sw, fg); + drawLine(ctx, x + 2, bar_y3, x + s - 2, bar_y3, sw, fg); + }, + + // More (dots) + .more_horizontal => { + const dot_r: u32 = @max(2, @as(u32, @intCast(s6))); + fillCircle(ctx, x + s4, y + s2, dot_r, fg); + fillCircle(ctx, x + s2, y + s2, dot_r, fg); + fillCircle(ctx, x + s34, y + s2, dot_r, fg); + }, + .more_vertical => { + const dot_r: u32 = @max(2, @as(u32, @intCast(s6))); + fillCircle(ctx, x + s2, y + s4, dot_r, fg); + fillCircle(ctx, x + s2, y + s2, dot_r, fg); + fillCircle(ctx, x + s2, y + s34, dot_r, fg); + }, + + // Search + .search => { + const r: u32 = @intCast(s3); + strokeCircle(ctx, x + s3, y + s3, r, sw, fg); + drawLine(ctx, x + s2, y + s2, x + s - 3, y + s - 3, sw, fg); + }, + + // Settings (gear) + .settings => { + const r: u32 = @intCast(s4); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s2, y + s4, sw, fg); + drawLine(ctx, x + s2, y + s - 2, x + s2, y + s34, sw, fg); + drawLine(ctx, x + 2, y + s2, x + s4, y + s2, sw, fg); + drawLine(ctx, x + s - 2, y + s2, x + s34, y + s2, sw, fg); + }, + + // File + .file => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 2, @intCast(s - 4), @intCast(s - 4), fg)); + drawLine(ctx, x + s - 6, y + 2, x + s - 2, y + 6, sw, fg); + }, + + // Folder + .folder => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 4, @intCast(s - 4), @intCast(s - 6), fg)); + drawLine(ctx, x + 2, y + 4, x + s3, y + 4, sw, fg); + drawLine(ctx, x + s3, y + 4, x + s3 + 2, y + 2, sw, fg); + drawLine(ctx, x + s3 + 2, y + 2, x + s2, y + 2, sw, fg); + }, + + // Document + .document => { + ctx.pushCommand(Command.rectOutline(x + 3, y + 2, @intCast(s - 6), @intCast(s - 4), fg)); + drawLine(ctx, x + 5, y + s3, x + s - 5, y + s3, 1, fg); + drawLine(ctx, x + 5, y + s2, x + s - 5, y + s2, 1, fg); + drawLine(ctx, x + 5, y + s23, x + s - 7, y + s23, 1, fg); + }, + + // Save (floppy) + .save => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 2, @intCast(s - 4), @intCast(s - 4), fg)); + ctx.pushCommand(Command.rect(x + 4, y + s2, @intCast(s - 8), @intCast(s3), fg)); + }, + + // Info + .info => { + const r: u32 = @intCast(s2 - 2); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + fillCircle(ctx, x + s2, y + s3, 2, fg); + drawLine(ctx, x + s2, y + s2 - 1, x + s2, y + s23 + 1, sw, fg); + }, + + // Warning (triangle) + .warning => { + drawLine(ctx, x + s2, y + 2, x + 2, y + s - 2, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s - 2, y + s - 2, sw, fg); + drawLine(ctx, x + 2, y + s - 2, x + s - 2, y + s - 2, sw, fg); + drawLine(ctx, x + s2, y + s3 + 1, x + s2, y + s2 + 1, sw, fg); + fillCircle(ctx, x + s2, y + s23, 2, fg); + }, + + // Error (X in circle) + .error_icon => { + const r: u32 = @intCast(s2 - 2); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + drawLine(ctx, x + s3, y + s3, x + s23, y + s23, sw, fg); + drawLine(ctx, x + s23, y + s3, x + s3, y + s23, sw, fg); + }, + + // Success (checkmark in circle) + .success => { + const r: u32 = @intCast(s2 - 2); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + drawLine(ctx, x + s4 + 1, y + s2, x + s2 - 1, y + s23, sw, fg); + drawLine(ctx, x + s2 - 1, y + s23, x + s34 - 1, y + s3, sw, fg); + }, + + // Question + .question => { + const r: u32 = @intCast(s2 - 2); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + ctx.pushCommand(Command.text(x + s2 - 3, y + s3, "?", fg)); + }, + + // Star + .star => { + const s25 = @divTrunc(s * 2, 5); + const s35 = @divTrunc(s * 3, 5); + drawLine(ctx, x + s2, y + 2, x + s3, y + s25, sw, fg); + drawLine(ctx, x + s3, y + s25, x + 2, y + s25, sw, fg); + drawLine(ctx, x + 2, y + s25, x + s4, y + s35, sw, fg); + drawLine(ctx, x + s4, y + s35, x + s4, y + s - 2, sw, fg); + drawLine(ctx, x + s4, y + s - 2, x + s2, y + s34, sw, fg); + drawLine(ctx, x + s2, y + s34, x + s34, y + s - 2, sw, fg); + drawLine(ctx, x + s34, y + s - 2, x + s34, y + s35, sw, fg); + drawLine(ctx, x + s34, y + s35, x + s - 2, y + s25, sw, fg); + drawLine(ctx, x + s - 2, y + s25, x + s23, y + s25, sw, fg); + drawLine(ctx, x + s23, y + s25, x + s2, y + 2, sw, fg); + }, + .star_filled => { + fillCircle(ctx, x + s2, y + s2, @intCast(s3), fg); + }, + + // Heart + .heart => { + drawLine(ctx, x + s2, y + s - 3, x + 3, y + s2, sw, fg); + drawLine(ctx, x + s2, y + s - 3, x + s - 3, y + s2, sw, fg); + fillCircle(ctx, x + s3, y + s3, @intCast(s5), fg); + fillCircle(ctx, x + s23, y + s3, @intCast(s5), fg); + }, + .heart_filled => { + fillCircle(ctx, x + s3, y + s3, @intCast(s4), fg); + fillCircle(ctx, x + s23, y + s3, @intCast(s4), fg); + var dy: i32 = 0; + while (dy < s2) : (dy += 1) { + const hw = @divTrunc(s2 - dy, 2); + ctx.pushCommand(Command.rect(x + s2 - hw, y + s2 + dy, @intCast(hw * 2), 1, fg)); + } + }, + + // Eye + .eye => { + ctx.pushCommand(Command.rectOutline(x + 2, y + s3, @intCast(s - 4), @intCast(s3), fg)); + fillCircle(ctx, x + s2, y + s2, @intCast(s6), fg); + }, + .eye_off => { + ctx.pushCommand(Command.rectOutline(x + 2, y + s3, @intCast(s - 4), @intCast(s3), fg)); + drawLine(ctx, x + 2, y + s - 3, x + s - 2, y + 3, sw, fg); + }, + + // Lock + .lock => { + ctx.pushCommand(Command.rectOutline(x + 3, y + s2, @intCast(s - 6), @intCast(s2 - 2), fg)); + drawLine(ctx, x + s3, y + s2, x + s3, y + s4, sw, fg); + drawLine(ctx, x + s23, y + s2, x + s23, y + s4, sw, fg); + drawLine(ctx, x + s3, y + s4, x + s23, y + s4, sw, fg); + }, + .unlock => { + ctx.pushCommand(Command.rectOutline(x + 3, y + s2, @intCast(s - 6), @intCast(s2 - 2), fg)); + drawLine(ctx, x + s3, y + s2, x + s3, y + s4, sw, fg); + drawLine(ctx, x + s3, y + s4, x + s2, y + s4, sw, fg); + }, + + // User + .user => { + fillCircle(ctx, x + s2, y + s3, @intCast(s5), fg); + drawLine(ctx, x + s4, y + s - 2, x + s4, y + s2 + 2, sw, fg); + drawLine(ctx, x + s34, y + s - 2, x + s34, y + s2 + 2, sw, fg); + drawLine(ctx, x + s4, y + s2 + 2, x + s34, y + s2 + 2, sw, fg); + }, + .users => { + fillCircle(ctx, x + s3, y + s3 + 1, @intCast(s6), fg); + fillCircle(ctx, x + s23, y + s4, @intCast(s6), fg); + }, + + // Calendar + .calendar => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 4, @intCast(s - 4), @intCast(s - 6), fg)); + drawLine(ctx, x + 2, y + s3, x + s - 2, y + s3, sw, fg); + drawLine(ctx, x + s3, y + 2, x + s3, y + 5, sw, fg); + drawLine(ctx, x + s23, y + 2, x + s23, y + 5, sw, fg); + }, + + // Clock + .clock => { + const r: u32 = @intCast(s2 - 2); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + drawLine(ctx, x + s2, y + s2, x + s2, y + s3, sw, fg); + drawLine(ctx, x + s2, y + s2, x + s23, y + s2, sw, fg); + }, + + // Bell + .bell => { + drawLine(ctx, x + s3, y + s23, x + s3, y + s3, sw, fg); + drawLine(ctx, x + s23, y + s23, x + s23, y + s3, sw, fg); + drawLine(ctx, x + s3, y + s3, x + s23, y + s3, sw, fg); + drawLine(ctx, x + 3, y + s23, x + s - 3, y + s23, sw, fg); + fillCircle(ctx, x + s2, y + s - 3, 2, fg); + }, + + // Mail + .mail => { + ctx.pushCommand(Command.rectOutline(x + 2, y + s4, @intCast(s - 4), @intCast(s2), fg)); + drawLine(ctx, x + 2, y + s4, x + s2, y + s2, sw, fg); + drawLine(ctx, x + s - 2, y + s4, x + s2, y + s2, sw, fg); + }, + + // Play + .play => { + drawLine(ctx, x + s3, y + 3, x + s3, y + s - 3, sw, fg); + drawLine(ctx, x + s3, y + 3, x + s23 + 1, y + s2, sw, fg); + drawLine(ctx, x + s3, y + s - 3, x + s23 + 1, y + s2, sw, fg); + }, + + // Pause + .pause => { + ctx.pushCommand(Command.rect(x + s4, y + 3, @intCast(s5), @intCast(s - 6), fg)); + ctx.pushCommand(Command.rect(x + s - s4 - s5, y + 3, @intCast(s5), @intCast(s - 6), fg)); + }, + + // Stop + .stop => { + ctx.pushCommand(Command.rect(x + 3, y + 3, @intCast(s - 6), @intCast(s - 6), fg)); + }, + + // Volume + .volume => { + ctx.pushCommand(Command.rect(x + 2, y + s3, 4, @intCast(s3), fg)); + drawLine(ctx, x + 6, y + s3, x + s2, y + 3, sw, fg); + drawLine(ctx, x + 6, y + s23, x + s2, y + s - 3, sw, fg); + drawLine(ctx, x + s23, y + s3, x + s23, y + s23, sw, fg); + }, + .volume_off => { + ctx.pushCommand(Command.rect(x + 2, y + s3, 4, @intCast(s3), fg)); + drawLine(ctx, x + 6, y + s3, x + s2, y + 3, sw, fg); + drawLine(ctx, x + 6, y + s23, x + s2, y + s - 3, sw, fg); + drawLine(ctx, x + s23, y + s3, x + s - 3, y + s23, sw, fg); + drawLine(ctx, x + s - 3, y + s3, x + s23, y + s23, sw, fg); + }, + + // Edit + .edit => { + drawLine(ctx, x + 3, y + s - 5, x + s - 5, y + 3, sw, fg); + drawLine(ctx, x + s - 5, y + 3, x + s - 3, y + 5, sw, fg); + drawLine(ctx, x + s - 3, y + 5, x + 5, y + s - 3, sw, fg); + }, + + // Delete (trash) + .delete => { + ctx.pushCommand(Command.rectOutline(x + 4, y + 4, @intCast(s - 8), @intCast(s - 6), fg)); + drawLine(ctx, x + 2, y + 4, x + s - 2, y + 4, sw, fg); + drawLine(ctx, x + s3, y + 2, x + s23, y + 2, sw, fg); + }, + + // Refresh + .refresh => { + const r: u32 = @intCast(s3); + strokeCircle(ctx, x + s2, y + s2, r, sw, fg); + drawLine(ctx, x + s2 + @as(i32, @intCast(r)), y + s2 - 3, x + s2 + @as(i32, @intCast(r)) + 3, y + s2, sw, fg); + }, + + // Filter + .filter => { + drawLine(ctx, x + 2, y + 3, x + s - 2, y + 3, sw, fg); + drawLine(ctx, x + 2, y + 3, x + s2, y + s2, sw, fg); + drawLine(ctx, x + s - 2, y + 3, x + s2, y + s2, sw, fg); + drawLine(ctx, x + s2, y + s2, x + s2, y + s - 3, sw, fg); + }, + + // Sort + .sort => { + drawLine(ctx, x + 3, y + s4, x + s - 3, y + s4, sw, fg); + drawLine(ctx, x + 5, y + s2, x + s - 5, y + s2, sw, fg); + drawLine(ctx, x + 7, y + s34, x + s - 7, y + s34, sw, fg); + }, + + // Copy + .copy => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 4, @intCast(s - 6), @intCast(s - 6), fg)); + ctx.pushCommand(Command.rectOutline(x + 4, y + 2, @intCast(s - 6), @intCast(s - 6), fg)); + }, + + // Paste + .paste => { + ctx.pushCommand(Command.rectOutline(x + 3, y + 3, @intCast(s - 6), @intCast(s - 5), fg)); + ctx.pushCommand(Command.rect(x + s3, y + 2, @intCast(s3), 3, fg)); + }, + + // Cut + .cut => { + fillCircle(ctx, x + s3, y + s23, @intCast(s6), fg); + fillCircle(ctx, x + s23, y + s23, @intCast(s6), fg); + drawLine(ctx, x + s3, y + s2, x + s2, y + 3, sw, fg); + drawLine(ctx, x + s23, y + s2, x + s2, y + 3, sw, fg); + }, + + // Undo + .undo => { + drawLine(ctx, x + 3, y + s3, x + s3, y + 3, sw, fg); + drawLine(ctx, x + 3, y + s3, x + s3, y + s2, sw, fg); + drawLine(ctx, x + 3, y + s3, x + s - 3, y + s3, sw, fg); + drawLine(ctx, x + s - 3, y + s3, x + s - 3, y + s23, sw, fg); + }, + + // Redo + .redo => { + drawLine(ctx, x + s - 3, y + s3, x + s23, y + 3, sw, fg); + drawLine(ctx, x + s - 3, y + s3, x + s23, y + s2, sw, fg); + drawLine(ctx, x + s - 3, y + s3, x + 3, y + s3, sw, fg); + drawLine(ctx, x + 3, y + s3, x + 3, y + s23, sw, fg); + }, + + // Folder open + .folder_open => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 4, @intCast(s - 4), @intCast(s - 6), fg)); + drawLine(ctx, x + 2, y + s2, x + s3, y + s2, sw, fg); + drawLine(ctx, x + s3, y + s2, x + s2, y + s3, sw, fg); + }, + + // Image file + .image_file => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 2, @intCast(s - 4), @intCast(s - 4), fg)); + drawLine(ctx, x + 4, y + s23, x + s3, y + s2, sw, fg); + drawLine(ctx, x + s3, y + s2, x + s23, y + s23, sw, fg); + fillCircle(ctx, x + s23, y + s3, 2, fg); + }, + + // Download + .download => { + drawLine(ctx, x + s2, y + 3, x + s2, y + s23, sw, fg); + drawLine(ctx, x + s2, y + s23, x + s3, y + s2, sw, fg); + drawLine(ctx, x + s2, y + s23, x + s23, y + s2, sw, fg); + drawLine(ctx, x + 3, y + s - 3, x + s - 3, y + s - 3, sw, fg); + }, + + // Upload + .upload => { + drawLine(ctx, x + s2, y + s23, x + s2, y + 3, sw, fg); + drawLine(ctx, x + s2, y + 3, x + s3, y + s4 + 1, sw, fg); + drawLine(ctx, x + s2, y + 3, x + s23, y + s4 + 1, sw, fg); + drawLine(ctx, x + 3, y + s - 3, x + s - 3, y + s - 3, sw, fg); + }, + + // Grip (drag handle) + .grip => { + const dot_r: u32 = 1; + fillCircle(ctx, x + s3, y + s3, dot_r, fg); + fillCircle(ctx, x + s23, y + s3, dot_r, fg); + fillCircle(ctx, x + s3, y + s2, dot_r, fg); + fillCircle(ctx, x + s23, y + s2, dot_r, fg); + fillCircle(ctx, x + s3, y + s23, dot_r, fg); + fillCircle(ctx, x + s23, y + s23, dot_r, fg); + }, + + // Drag + .drag => { + drawLine(ctx, x + s2, y + 2, x + s2, y + s - 2, sw, fg); + drawLine(ctx, x + 2, y + s2, x + s - 2, y + s2, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s3, y + s4, sw, fg); + drawLine(ctx, x + s2, y + 2, x + s23, y + s4, sw, fg); + }, + + // Expand + .expand => { + drawLine(ctx, x + 2, y + 2, x + s3, y + 2, sw, fg); + drawLine(ctx, x + 2, y + 2, x + 2, y + s3, sw, fg); + drawLine(ctx, x + s - 2, y + 2, x + s23, y + 2, sw, fg); + drawLine(ctx, x + s - 2, y + 2, x + s - 2, y + s3, sw, fg); + drawLine(ctx, x + 2, y + s - 2, x + s3, y + s - 2, sw, fg); + drawLine(ctx, x + 2, y + s - 2, x + 2, y + s23, sw, fg); + drawLine(ctx, x + s - 2, y + s - 2, x + s23, y + s - 2, sw, fg); + drawLine(ctx, x + s - 2, y + s - 2, x + s - 2, y + s23, sw, fg); + }, + + // Collapse + .collapse => { + drawLine(ctx, x + s3, y + s3, x + 2, y + s3, sw, fg); + drawLine(ctx, x + s3, y + s3, x + s3, y + 2, sw, fg); + drawLine(ctx, x + s23, y + s3, x + s - 2, y + s3, sw, fg); + drawLine(ctx, x + s23, y + s3, x + s23, y + 2, sw, fg); + drawLine(ctx, x + s3, y + s23, x + 2, y + s23, sw, fg); + drawLine(ctx, x + s3, y + s23, x + s3, y + s - 2, sw, fg); + drawLine(ctx, x + s23, y + s23, x + s - 2, y + s23, sw, fg); + drawLine(ctx, x + s23, y + s23, x + s23, y + s - 2, sw, fg); + }, + + // Maximize + .maximize => { + ctx.pushCommand(Command.rectOutline(x + 3, y + 3, @intCast(s - 6), @intCast(s - 6), fg)); + drawLine(ctx, x + 3, y + 5, x + s - 3, y + 5, sw, fg); + }, + + // Minimize + .minimize => { + drawLine(ctx, x + 3, y + s - 4, x + s - 3, y + s - 4, sw, fg); + }, + + // External link + .external_link => { + ctx.pushCommand(Command.rectOutline(x + 2, y + 4, @intCast(s - 6), @intCast(s - 6), fg)); + drawLine(ctx, x + s2, y + 2, x + s - 2, y + 2, sw, fg); + drawLine(ctx, x + s - 2, y + 2, x + s - 2, y + s2, sw, fg); + drawLine(ctx, x + s - 2, y + 2, x + s2, y + s2, sw, fg); + }, + } +} + +// ============================================================================= +// Helper functions +// ============================================================================= + +/// Draw a line with thickness +fn drawLine(ctx: *Context, x1: i32, y1: i32, x2: i32, y2: i32, thickness: u32, color: Style.Color) void { + if (thickness <= 1) { + ctx.pushCommand(Command.line(x1, y1, x2, y2, color)); + } else { + const dx = x2 - x1; + const dy = y2 - y1; + const len = @sqrt(@as(f32, @floatFromInt(dx * dx + dy * dy))); + + if (len < 1) { + ctx.pushCommand(Command.rect(x1, y1, thickness, thickness, color)); + return; + } + + const nx = -@as(f32, @floatFromInt(dy)) / len; + const ny = @as(f32, @floatFromInt(dx)) / len; + + const half = @as(f32, @floatFromInt(thickness)) / 2.0; + var offset: f32 = -half; + while (offset < half) : (offset += 1.0) { + const ox = @as(i32, @intFromFloat(nx * offset)); + const oy = @as(i32, @intFromFloat(ny * offset)); + ctx.pushCommand(Command.line(x1 + ox, y1 + oy, x2 + ox, y2 + oy, color)); + } + } +} + +/// Fill a circle +fn fillCircle(ctx: *Context, cx: i32, cy: i32, radius: u32, color: Style.Color) void { + if (radius == 0) { + ctx.pushCommand(Command.rect(cx, cy, 1, 1, color)); + return; + } + + const r = @as(i32, @intCast(radius)); + var dy: i32 = -r; + while (dy <= r) : (dy += 1) { + const dy_f = @as(f32, @floatFromInt(dy)); + const r_f = @as(f32, @floatFromInt(r)); + const dx = @as(i32, @intFromFloat(@sqrt(r_f * r_f - dy_f * dy_f))); + ctx.pushCommand(Command.rect(cx - dx, cy + dy, @intCast(dx * 2 + 1), 1, color)); + } +} + +/// Stroke a circle +fn strokeCircle(ctx: *Context, cx: i32, cy: i32, radius: u32, thickness: u32, color: Style.Color) void { + if (radius == 0) return; + + const r = @as(i32, @intCast(radius)); + var px: i32 = 0; + var py: i32 = r; + var d: i32 = 3 - 2 * r; + + while (px <= py) { + setPixelThick(ctx, cx + px, cy + py, thickness, color); + setPixelThick(ctx, cx - px, cy + py, thickness, color); + setPixelThick(ctx, cx + px, cy - py, thickness, color); + setPixelThick(ctx, cx - px, cy - py, thickness, color); + setPixelThick(ctx, cx + py, cy + px, thickness, color); + setPixelThick(ctx, cx - py, cy + px, thickness, color); + setPixelThick(ctx, cx + py, cy - px, thickness, color); + setPixelThick(ctx, cx - py, cy - px, thickness, color); + + if (d < 0) { + d = d + 4 * px + 6; + } else { + d = d + 4 * (px - py) + 10; + py -= 1; + } + px += 1; + } +} + +fn setPixelThick(ctx: *Context, pixel_x: i32, pixel_y: i32, thickness: u32, color: Style.Color) void { + if (thickness <= 1) { + ctx.pushCommand(Command.rect(pixel_x, pixel_y, 1, 1, color)); + } else { + const half = @as(i32, @intCast(thickness / 2)); + ctx.pushCommand(Command.rect(pixel_x - half, pixel_y - half, thickness, thickness, color)); + } +} + +// ============================================================================= +// Tests +// ============================================================================= + +test "Size pixels" { + try std.testing.expectEqual(@as(u32, 12), Size.small.pixels()); + try std.testing.expectEqual(@as(u32, 16), Size.medium.pixels()); + try std.testing.expectEqual(@as(u32, 24), Size.large.pixels()); + try std.testing.expectEqual(@as(u32, 32), Size.xlarge.pixels()); +} + +test "icon generates commands" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 32; + + icon(&ctx, .check); + + try std.testing.expect(ctx.commands.items.len >= 1); + + ctx.endFrame(); +} + +test "iconEx with config" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 48; + + iconEx(&ctx, .home, .{ .size = .large, .stroke_width = 3 }, .{}); + + try std.testing.expect(ctx.commands.items.len >= 1); + + ctx.endFrame(); +} + +test "multiple icons" { + var ctx = try Context.init(std.testing.allocator, 800, 600); + defer ctx.deinit(); + + ctx.beginFrame(); + ctx.layout.row_height = 24; + + const icons = [_]IconType{ .check, .close, .plus, .minus, .search }; + for (icons) |i| { + icon(&ctx, i); + } + + try std.testing.expect(ctx.commands.items.len >= 5); + + ctx.endFrame(); +} diff --git a/src/widgets/widgets.zig b/src/widgets/widgets.zig index 0617a2f..2781427 100644 --- a/src/widgets/widgets.zig +++ b/src/widgets/widgets.zig @@ -38,6 +38,9 @@ pub const datepicker = @import("datepicker.zig"); pub const numberentry = @import("numberentry.zig"); pub const richtext = @import("richtext.zig"); pub const breadcrumb = @import("breadcrumb.zig"); +pub const canvas = @import("canvas.zig"); +pub const chart = @import("chart.zig"); +pub const icon = @import("icon.zig"); // ============================================================================= // Re-exports for convenience @@ -288,6 +291,28 @@ pub const BreadcrumbConfig = breadcrumb.Config; pub const BreadcrumbColors = breadcrumb.Colors; pub const BreadcrumbResult = breadcrumb.Result; +// Canvas +pub const Canvas = canvas.Canvas; +pub const CanvasPoint = canvas.Point; + +// Chart +pub const Chart = chart; +pub const DataPoint = chart.DataPoint; +pub const DataSeries = chart.DataSeries; +pub const LineChartConfig = chart.LineChartConfig; +pub const LineChartColors = chart.LineChartColors; +pub const BarChartConfig = chart.BarChartConfig; +pub const BarChartColors = chart.BarChartColors; +pub const PieChartConfig = chart.PieChartConfig; +pub const PieChartColors = chart.PieChartColors; + +// Icon +pub const Icon = icon; +pub const IconType = icon.IconType; +pub const IconSize = icon.Size; +pub const IconConfig = icon.Config; +pub const IconColors = icon.Colors; + // ============================================================================= // Tests // =============================================================================