zcatgui/src/render/framebuffer.zig
reugenio 3d44631cc3 fix: Eliminar código olvidado en drawRoundedRect que rellenaba área
Bug crítico: Al dar focus a cualquier widget, TODO el fondo se pintaba
de azul semitransparente, no solo el borde de focus.

Causa: En drawRoundedRect() había código de una implementación anterior
que hacía fillRoundedRect() antes de dibujar el outline. Cuando focusRing
llamaba a drawRoundedRect con color azul, primero rellenaba todo el área.

Fix: Eliminadas 8 líneas de código obsoleto (comentarios de estrategia
abandonada + la llamada a fillRoundedRect).

También: Limpieza de código debug en advanced_table.zig.

Crédito: Bug encontrado por Gemini tras descripción del problema.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-19 12:48:30 +01:00

612 lines
22 KiB
Zig

//! Framebuffer - Pixel buffer for software rendering
//!
//! A simple 2D array of RGBA pixels.
//! The software rasterizer writes to this, then it's blitted to the screen.
const std = @import("std");
const Allocator = std.mem.Allocator;
const Style = @import("../core/style.zig");
const Color = Style.Color;
/// A 2D pixel buffer
pub const Framebuffer = struct {
allocator: Allocator,
pixels: []u32,
width: u32,
height: u32,
const Self = @This();
/// Create a new framebuffer
pub fn init(allocator: Allocator, width: u32, height: u32) !Self {
const size = @as(usize, width) * @as(usize, height);
const pixels = try allocator.alloc(u32, size);
@memset(pixels, 0);
return .{
.allocator = allocator,
.pixels = pixels,
.width = width,
.height = height,
};
}
/// Free the framebuffer
pub fn deinit(self: Self) void {
self.allocator.free(self.pixels);
}
/// Resize the framebuffer
pub fn resize(self: *Self, width: u32, height: u32) !void {
if (width == self.width and height == self.height) return;
const size = @as(usize, width) * @as(usize, height);
const new_pixels = try self.allocator.alloc(u32, size);
@memset(new_pixels, 0);
self.allocator.free(self.pixels);
self.pixels = new_pixels;
self.width = width;
self.height = height;
}
/// Clear the entire buffer to a color
pub fn clear(self: *Self, color: Color) void {
const c = color.toABGR();
@memset(self.pixels, c);
}
/// Clear a rectangular region to a color (for partial redraw)
pub fn clearRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void {
const x_start: u32 = if (x < 0) 0 else @intCast(@min(x, @as(i32, @intCast(self.width))));
const y_start: u32 = if (y < 0) 0 else @intCast(@min(y, @as(i32, @intCast(self.height))));
const x_end = @min(x_start + w, self.width);
const y_end = @min(y_start + h, self.height);
if (x_start >= x_end or y_start >= y_end) return;
const c = color.toABGR();
const row_width = x_end - x_start;
var py = y_start;
while (py < y_end) : (py += 1) {
const row_start = py * self.width + x_start;
@memset(self.pixels[row_start..][0..row_width], c);
}
}
/// Get pixel at (x, y)
pub fn getPixel(self: Self, x: i32, y: i32) ?u32 {
if (x < 0 or y < 0) return null;
const ux = @as(u32, @intCast(x));
const uy = @as(u32, @intCast(y));
if (ux >= self.width or uy >= self.height) return null;
const idx = uy * self.width + ux;
return self.pixels[idx];
}
/// Set pixel at (x, y)
pub fn setPixel(self: *Self, x: i32, y: i32, color: Color) void {
if (x < 0 or y < 0) return;
const ux = @as(u32, @intCast(x));
const uy = @as(u32, @intCast(y));
if (ux >= self.width or uy >= self.height) return;
const idx = uy * self.width + ux;
if (color.a == 255) {
self.pixels[idx] = color.toABGR();
} else if (color.a > 0) {
// Blend with existing pixel
const existing = self.pixels[idx];
const bg = Color{
.r = @truncate(existing),
.g = @truncate(existing >> 8),
.b = @truncate(existing >> 16),
.a = @truncate(existing >> 24),
};
self.pixels[idx] = color.blend(bg).toABGR();
}
}
/// Draw a filled rectangle
/// Optimized with SIMD-friendly @memset for solid colors (alpha=255)
pub fn fillRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void {
const x_start = @max(0, x);
const y_start = @max(0, y);
const x_end = @min(@as(i32, @intCast(self.width)), x + @as(i32, @intCast(w)));
const y_end = @min(@as(i32, @intCast(self.height)), y + @as(i32, @intCast(h)));
if (x_start >= x_end or y_start >= y_end) return;
const c = color.toABGR();
const row_width = @as(u32, @intCast(x_end - x_start));
const ux_start = @as(u32, @intCast(x_start));
// FAST PATH: Solid colors (alpha=255) use @memset which is SIMD-optimized
if (color.a == 255) {
var py: u32 = @intCast(y_start);
const uy_end: u32 = @intCast(y_end);
while (py < uy_end) : (py += 1) {
const row_start = py * self.width + ux_start;
@memset(self.pixels[row_start..][0..row_width], c);
}
return;
}
// SLOW PATH: Alpha blending (pixel by pixel)
if (color.a > 0) {
var py = y_start;
while (py < y_end) : (py += 1) {
const row_start = @as(u32, @intCast(py)) * self.width;
var px = x_start;
while (px < x_end) : (px += 1) {
const idx = row_start + @as(u32, @intCast(px));
const existing = self.pixels[idx];
const bg = Color{
.r = @truncate(existing),
.g = @truncate(existing >> 8),
.b = @truncate(existing >> 16),
.a = @truncate(existing >> 24),
};
self.pixels[idx] = color.blend(bg).toABGR();
}
}
}
}
/// Draw a rectangle outline
pub fn drawRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void {
if (w == 0 or h == 0) return;
// Top and bottom
self.fillRect(x, y, w, 1, color);
self.fillRect(x, y + @as(i32, @intCast(h)) - 1, w, 1, color);
// Left and right
self.fillRect(x, y + 1, 1, h -| 2, color);
self.fillRect(x + @as(i32, @intCast(w)) - 1, y + 1, 1, h -| 2, color);
}
/// Draw a horizontal line
pub fn drawHLine(self: *Self, x: i32, y: i32, w: u32, color: Color) void {
self.fillRect(x, y, w, 1, color);
}
/// Draw a vertical line
pub fn drawVLine(self: *Self, x: i32, y: i32, h: u32, color: Color) void {
self.fillRect(x, y, 1, h, color);
}
/// Draw a line (Bresenham's algorithm)
pub fn drawLine(self: *Self, x0: i32, y0: i32, x1: i32, y1: i32, color: Color) void {
var x = x0;
var y = y0;
const dx = @abs(x1 - x0);
const dy = @abs(y1 - y0);
const sx: i32 = if (x0 < x1) 1 else -1;
const sy: i32 = if (y0 < y1) 1 else -1;
var err = @as(i32, @intCast(dx)) - @as(i32, @intCast(dy));
while (true) {
self.setPixel(x, y, color);
if (x == x1 and y == y1) break;
const e2 = err * 2;
if (e2 > -@as(i32, @intCast(dy))) {
err -= @as(i32, @intCast(dy));
x += sx;
}
if (e2 < @as(i32, @intCast(dx))) {
err += @as(i32, @intCast(dx));
y += sy;
}
}
}
/// Get raw pixel data (for blitting to SDL texture)
pub fn getData(self: Self) []const u32 {
return self.pixels;
}
/// Get pitch in bytes
pub fn getPitch(self: Self) u32 {
return self.width * 4;
}
// =========================================================================
// Rounded Rectangle Drawing (Fancy Mode)
// =========================================================================
/// Draw a filled rounded rectangle with optional edge-fade anti-aliasing
/// radius: corner radius in pixels
/// aa: if true, applies 1-pixel edge fade for smooth borders
pub fn fillRoundedRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color, radius: u8, aa: bool) void {
if (w == 0 or h == 0) return;
// Clamp radius to half the smallest dimension
const max_radius = @min(w, h) / 2;
const r: u32 = @min(@as(u32, radius), max_radius);
if (r == 0) {
// No radius, use fast path
self.fillRect(x, y, w, h, color);
return;
}
// Calculate bounds
const x_start = @max(0, x);
const y_start = @max(0, y);
const x_end = @min(@as(i32, @intCast(self.width)), x + @as(i32, @intCast(w)));
const y_end = @min(@as(i32, @intCast(self.height)), y + @as(i32, @intCast(h)));
if (x_start >= x_end or y_start >= y_end) return;
// Corner circle centers (relative to rect origin)
const r_i32: i32 = @intCast(r);
const w_i32: i32 = @intCast(w);
const h_i32: i32 = @intCast(h);
// Corner centers in screen coordinates
const tl_cx = x + r_i32; // top-left
const tl_cy = y + r_i32;
const tr_cx = x + w_i32 - r_i32; // top-right
const tr_cy = y + r_i32;
const bl_cx = x + r_i32; // bottom-left
const bl_cy = y + h_i32 - r_i32;
const br_cx = x + w_i32 - r_i32; // bottom-right
const br_cy = y + h_i32 - r_i32;
const r_f: f32 = @floatFromInt(r);
var py = y_start;
while (py < y_end) : (py += 1) {
const row_start = @as(u32, @intCast(py)) * self.width;
var px = x_start;
while (px < x_end) : (px += 1) {
// Check which region the pixel is in
const in_corner = self.getCornerDistance(px, py, x, y, w_i32, h_i32, tl_cx, tl_cy, tr_cx, tr_cy, bl_cx, bl_cy, br_cx, br_cy, r_f);
if (in_corner) |dist| {
// In corner region - check distance to arc
if (dist <= r_f) {
// Inside the arc
if (aa and dist > r_f - 1.0) {
// Edge fade zone (last pixel)
const alpha_f = r_f - dist;
const alpha: u8 = @intFromFloat(@min(255.0, @max(0.0, alpha_f * 255.0)));
const aa_color = color.withAlpha(@as(u8, @intCast((@as(u16, color.a) * alpha) / 255)));
self.blendPixelAt(row_start + @as(u32, @intCast(px)), aa_color);
} else {
// Fully inside
self.blendPixelAt(row_start + @as(u32, @intCast(px)), color);
}
}
// Outside arc - don't draw
} else {
// Not in corner region - check edge fade for straight edges
if (aa) {
const edge_dist = self.getEdgeDistance(px, py, x, y, w_i32, h_i32);
if (edge_dist < 1.0) {
const alpha: u8 = @intFromFloat(@min(255.0, edge_dist * 255.0));
const aa_color = color.withAlpha(@as(u8, @intCast((@as(u16, color.a) * alpha) / 255)));
self.blendPixelAt(row_start + @as(u32, @intCast(px)), aa_color);
} else {
self.blendPixelAt(row_start + @as(u32, @intCast(px)), color);
}
} else {
self.blendPixelAt(row_start + @as(u32, @intCast(px)), color);
}
}
}
}
}
/// Draw a rounded rectangle outline with optional AA
pub fn drawRoundedRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color, radius: u8, thickness: u8, aa: bool) void {
if (w == 0 or h == 0 or thickness == 0) return;
const t: u32 = thickness;
// Draw rounded rect outline using stroke approach
const max_radius = @min(w, h) / 2;
const r: u32 = @min(@as(u32, radius), max_radius);
const inner_r: u32 = if (r > t) r - t else 0;
// Draw using edge detection
const x_start = @max(0, x);
const y_start = @max(0, y);
const x_end = @min(@as(i32, @intCast(self.width)), x + @as(i32, @intCast(w)));
const y_end = @min(@as(i32, @intCast(self.height)), y + @as(i32, @intCast(h)));
if (x_start >= x_end or y_start >= y_end) return;
const r_i32: i32 = @intCast(r);
const w_i32: i32 = @intCast(w);
const h_i32: i32 = @intCast(h);
const t_i32: i32 = @intCast(t);
const r_f: f32 = @floatFromInt(r);
const inner_r_f: f32 = @floatFromInt(inner_r);
const t_f: f32 = @floatFromInt(t);
// Corner centers
const tl_cx = x + r_i32;
const tl_cy = y + r_i32;
const tr_cx = x + w_i32 - r_i32;
const tr_cy = y + r_i32;
const bl_cx = x + r_i32;
const bl_cy = y + h_i32 - r_i32;
const br_cx = x + w_i32 - r_i32;
const br_cy = y + h_i32 - r_i32;
var py = y_start;
while (py < y_end) : (py += 1) {
const row_start = @as(u32, @intCast(py)) * self.width;
var px = x_start;
while (px < x_end) : (px += 1) {
// Check if pixel is in the stroke region (between outer and inner bounds)
const in_stroke = self.isInStroke(px, py, x, y, w_i32, h_i32, t_i32, tl_cx, tl_cy, tr_cx, tr_cy, bl_cx, bl_cy, br_cx, br_cy, r_f, inner_r_f, t_f, aa);
if (in_stroke) |alpha_mult| {
if (alpha_mult >= 1.0) {
self.blendPixelAt(row_start + @as(u32, @intCast(px)), color);
} else if (alpha_mult > 0.0) {
const alpha: u8 = @intFromFloat(@min(255.0, alpha_mult * 255.0));
const aa_color = color.withAlpha(@as(u8, @intCast((@as(u16, color.a) * alpha) / 255)));
self.blendPixelAt(row_start + @as(u32, @intCast(px)), aa_color);
}
}
}
}
}
// Helper: blend pixel at index with alpha
fn blendPixelAt(self: *Self, idx: u32, color: Color) void {
if (idx >= self.pixels.len) return;
if (color.a == 255) {
self.pixels[idx] = color.toABGR();
} else if (color.a > 0) {
const existing = self.pixels[idx];
const bg = Color{
.r = @truncate(existing),
.g = @truncate(existing >> 8),
.b = @truncate(existing >> 16),
.a = @truncate(existing >> 24),
};
self.pixels[idx] = color.blend(bg).toABGR();
}
}
// Helper: get distance from pixel to corner arc (null if not in corner region)
fn getCornerDistance(
self: *Self,
px: i32,
py: i32,
rect_x: i32,
rect_y: i32,
rect_w: i32,
rect_h: i32,
tl_cx: i32,
tl_cy: i32,
tr_cx: i32,
tr_cy: i32,
bl_cx: i32,
bl_cy: i32,
br_cx: i32,
br_cy: i32,
radius: f32,
) ?f32 {
_ = self;
_ = rect_w;
_ = rect_h;
// Check if pixel is in a corner region
// Top-left corner
if (px < tl_cx and py < tl_cy) {
const dx: f32 = @floatFromInt(tl_cx - px);
const dy: f32 = @floatFromInt(tl_cy - py);
return @sqrt(dx * dx + dy * dy);
}
// Top-right corner
if (px > tr_cx and py < tr_cy) {
const dx: f32 = @floatFromInt(px - tr_cx);
const dy: f32 = @floatFromInt(tr_cy - py);
return @sqrt(dx * dx + dy * dy);
}
// Bottom-left corner
if (px < bl_cx and py > bl_cy) {
const dx: f32 = @floatFromInt(bl_cx - px);
const dy: f32 = @floatFromInt(py - bl_cy);
return @sqrt(dx * dx + dy * dy);
}
// Bottom-right corner
if (px > br_cx and py > br_cy) {
const dx: f32 = @floatFromInt(px - br_cx);
const dy: f32 = @floatFromInt(py - br_cy);
return @sqrt(dx * dx + dy * dy);
}
// Also check if outside rect bounds entirely
if (px < rect_x or py < rect_y) return radius + 10.0; // Outside
return null; // Not in corner region
}
// Helper: get minimum distance to edge (for straight edges AA)
fn getEdgeDistance(self: *Self, px: i32, py: i32, rect_x: i32, rect_y: i32, rect_w: i32, rect_h: i32) f32 {
_ = self;
const left: f32 = @floatFromInt(px - rect_x);
const right: f32 = @floatFromInt((rect_x + rect_w - 1) - px);
const top: f32 = @floatFromInt(py - rect_y);
const bottom: f32 = @floatFromInt((rect_y + rect_h - 1) - py);
// Return minimum distance to any edge (clamped to positive)
return @max(0.0, @min(@min(left, right), @min(top, bottom))) + 1.0;
}
// Helper: check if pixel is in stroke region for outline
fn isInStroke(
self: *Self,
px: i32,
py: i32,
rect_x: i32,
rect_y: i32,
rect_w: i32,
rect_h: i32,
thickness: i32,
tl_cx: i32,
tl_cy: i32,
tr_cx: i32,
tr_cy: i32,
bl_cx: i32,
bl_cy: i32,
br_cx: i32,
br_cy: i32,
outer_r: f32,
inner_r: f32,
t_f: f32,
aa: bool,
) ?f32 {
_ = self;
_ = thickness;
// Check corners first
// Top-left
if (px < tl_cx and py < tl_cy) {
const dx: f32 = @floatFromInt(tl_cx - px);
const dy: f32 = @floatFromInt(tl_cy - py);
const dist = @sqrt(dx * dx + dy * dy);
if (dist > outer_r) {
if (aa and dist < outer_r + 1.0) return outer_r + 1.0 - dist;
return null;
}
if (dist < inner_r) {
if (aa and dist > inner_r - 1.0) return dist - (inner_r - 1.0);
return null;
}
return 1.0;
}
// Top-right
if (px > tr_cx and py < tr_cy) {
const dx: f32 = @floatFromInt(px - tr_cx);
const dy: f32 = @floatFromInt(tr_cy - py);
const dist = @sqrt(dx * dx + dy * dy);
if (dist > outer_r) {
if (aa and dist < outer_r + 1.0) return outer_r + 1.0 - dist;
return null;
}
if (dist < inner_r) {
if (aa and dist > inner_r - 1.0) return dist - (inner_r - 1.0);
return null;
}
return 1.0;
}
// Bottom-left
if (px < bl_cx and py > bl_cy) {
const dx: f32 = @floatFromInt(bl_cx - px);
const dy: f32 = @floatFromInt(py - bl_cy);
const dist = @sqrt(dx * dx + dy * dy);
if (dist > outer_r) {
if (aa and dist < outer_r + 1.0) return outer_r + 1.0 - dist;
return null;
}
if (dist < inner_r) {
if (aa and dist > inner_r - 1.0) return dist - (inner_r - 1.0);
return null;
}
return 1.0;
}
// Bottom-right
if (px > br_cx and py > br_cy) {
const dx: f32 = @floatFromInt(px - br_cx);
const dy: f32 = @floatFromInt(py - br_cy);
const dist = @sqrt(dx * dx + dy * dy);
if (dist > outer_r) {
if (aa and dist < outer_r + 1.0) return outer_r + 1.0 - dist;
return null;
}
if (dist < inner_r) {
if (aa and dist > inner_r - 1.0) return dist - (inner_r - 1.0);
return null;
}
return 1.0;
}
// Straight edges
const left_dist: f32 = @floatFromInt(px - rect_x);
const right_dist: f32 = @floatFromInt((rect_x + rect_w - 1) - px);
const top_dist: f32 = @floatFromInt(py - rect_y);
const bottom_dist: f32 = @floatFromInt((rect_y + rect_h - 1) - py);
// Check if in stroke region for straight edges
const in_left_stroke = left_dist >= 0 and left_dist < t_f;
const in_right_stroke = right_dist >= 0 and right_dist < t_f;
const in_top_stroke = top_dist >= 0 and top_dist < t_f;
const in_bottom_stroke = bottom_dist >= 0 and bottom_dist < t_f;
if (in_left_stroke or in_right_stroke or in_top_stroke or in_bottom_stroke) {
// AA for outer edge
if (aa) {
const min_outer = @min(@min(left_dist, right_dist), @min(top_dist, bottom_dist));
if (min_outer < 1.0 and min_outer >= 0) {
return min_outer;
}
}
return 1.0;
}
return null;
}
};
// =============================================================================
// Tests
// =============================================================================
test "Framebuffer basic" {
var fb = try Framebuffer.init(std.testing.allocator, 100, 100);
defer fb.deinit();
fb.clear(Color.black);
fb.setPixel(50, 50, Color.white);
const pixel = fb.getPixel(50, 50);
try std.testing.expect(pixel != null);
}
test "Framebuffer fillRect" {
var fb = try Framebuffer.init(std.testing.allocator, 100, 100);
defer fb.deinit();
fb.clear(Color.black);
fb.fillRect(10, 10, 20, 20, Color.red);
// Check inside
const inside = fb.getPixel(15, 15);
try std.testing.expect(inside != null);
try std.testing.expectEqual(Color.red.toABGR(), inside.?);
// Check outside
const outside = fb.getPixel(5, 5);
try std.testing.expect(outside != null);
try std.testing.expectEqual(Color.black.toABGR(), outside.?);
}
test "Framebuffer out of bounds" {
var fb = try Framebuffer.init(std.testing.allocator, 100, 100);
defer fb.deinit();
// These should not crash
fb.setPixel(-1, 50, Color.white);
fb.setPixel(50, -1, Color.white);
fb.setPixel(100, 50, Color.white);
fb.setPixel(50, 100, Color.white);
try std.testing.expect(fb.getPixel(-1, 50) == null);
}