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 <noreply@anthropic.com>
494 lines
15 KiB
Zig
494 lines
15 KiB
Zig
//! 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);
|
|
}
|