Sistema de rendering dual para zcatgui: Core: - RenderMode enum (simple/fancy) en style.zig - global_render_mode con helpers: isFancy(), setRenderMode() - fillRoundedRect con edge-fade AA en framebuffer.zig (~350 LOC) - Nuevos comandos: rounded_rect, rounded_rect_outline Widgets actualizados: - Button: corner_radius=4, usa roundedRect en fancy mode - Panel: corner_radius=6, show_shadow=true, sombra offset 4px - TextInput: corner_radius=3 - Select: corner_radius=3 - Modal: corner_radius=8, show_shadow=true, sombra offset 6px - Botones y input field del modal también redondeados Técnica edge-fade (de DVUI): - Anti-aliasing por gradiente alfa en bordes - Sin supersampling, mínimo impacto en rendimiento - Bordes suaves sin multisampling +589 líneas, 9 archivos modificados 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
409 lines
14 KiB
Zig
409 lines
14 KiB
Zig
//! SoftwareRenderer - Executes draw commands on a framebuffer
|
|
//!
|
|
//! This is the core of our rendering system.
|
|
//! It takes DrawCommands and turns them into pixels.
|
|
//!
|
|
//! ## UTF-8 Text Rendering
|
|
//!
|
|
//! The `drawText` function automatically decodes UTF-8 encoded strings and maps
|
|
//! the resulting Unicode codepoints to the font's character set (typically Latin-1).
|
|
//!
|
|
//! This allows seamless handling of text from various sources:
|
|
//! - SQLite databases (UTF-8 by default)
|
|
//! - Source code string literals (UTF-8 in Zig)
|
|
//! - User input (typically UTF-8 on modern systems)
|
|
//! - JSON/API responses (UTF-8 standard)
|
|
//!
|
|
//! Characters outside the font's range (Latin-1: 0x00-0xFF) are displayed as '?'.
|
|
//!
|
|
//! ## Example
|
|
//! ```zig
|
|
//! // Spanish text with accents - works automatically
|
|
//! ctx.pushCommand(Command.text(x, y, "¿Hola, España!", color));
|
|
//! ```
|
|
|
|
const std = @import("std");
|
|
|
|
const Command = @import("../core/command.zig");
|
|
const Style = @import("../core/style.zig");
|
|
const Layout = @import("../core/layout.zig");
|
|
const Framebuffer = @import("framebuffer.zig").Framebuffer;
|
|
const Font = @import("font.zig").Font;
|
|
const TtfFont = @import("ttf.zig").TtfFont;
|
|
|
|
const Color = Style.Color;
|
|
const Rect = Layout.Rect;
|
|
const DrawCommand = Command.DrawCommand;
|
|
|
|
/// Software renderer state
|
|
pub const SoftwareRenderer = struct {
|
|
framebuffer: *Framebuffer,
|
|
default_font: ?*Font,
|
|
/// TTF font (takes priority over bitmap if set)
|
|
ttf_font: ?*TtfFont = null,
|
|
|
|
/// Clipping stack
|
|
clip_stack: [16]Rect,
|
|
clip_depth: usize,
|
|
|
|
const Self = @This();
|
|
|
|
/// Initialize the renderer
|
|
pub fn init(framebuffer: *Framebuffer) Self {
|
|
return .{
|
|
.framebuffer = framebuffer,
|
|
.default_font = null,
|
|
.ttf_font = null,
|
|
.clip_stack = undefined,
|
|
.clip_depth = 0,
|
|
};
|
|
}
|
|
|
|
/// Set the default bitmap font
|
|
pub fn setDefaultFont(self: *Self, font: *Font) void {
|
|
self.default_font = font;
|
|
}
|
|
|
|
/// Set the TTF font (takes priority over bitmap font)
|
|
pub fn setTtfFont(self: *Self, font: *TtfFont) void {
|
|
self.ttf_font = font;
|
|
}
|
|
|
|
/// Clear the TTF font (revert to bitmap)
|
|
pub fn clearTtfFont(self: *Self) void {
|
|
self.ttf_font = null;
|
|
}
|
|
|
|
/// Get the current clip rectangle
|
|
pub fn getClip(self: Self) Rect {
|
|
if (self.clip_depth == 0) {
|
|
return Rect.init(0, 0, self.framebuffer.width, self.framebuffer.height);
|
|
}
|
|
return self.clip_stack[self.clip_depth - 1];
|
|
}
|
|
|
|
/// Execute a single draw command
|
|
pub fn execute(self: *Self, cmd: DrawCommand) void {
|
|
switch (cmd) {
|
|
.rect => |r| self.drawRect(r),
|
|
.rounded_rect => |r| self.drawRoundedRect(r),
|
|
.text => |t| self.drawText(t),
|
|
.line => |l| self.drawLine(l),
|
|
.rect_outline => |r| self.drawRectOutline(r),
|
|
.rounded_rect_outline => |r| self.drawRoundedRectOutline(r),
|
|
.clip => |c| self.pushClip(c),
|
|
.clip_end => self.popClip(),
|
|
.nop => {},
|
|
}
|
|
}
|
|
|
|
/// Execute all commands in a list
|
|
pub fn executeAll(self: *Self, commands: []const DrawCommand) void {
|
|
for (commands) |cmd| {
|
|
self.execute(cmd);
|
|
}
|
|
}
|
|
|
|
/// Execute commands only within dirty regions (partial redraw)
|
|
/// This is an optimization that skips rendering commands outside dirty areas.
|
|
/// For full redraw, pass a single Rect covering the entire screen.
|
|
pub fn executeWithDirtyRegions(
|
|
self: *Self,
|
|
commands: []const DrawCommand,
|
|
dirty_regions: []const Rect,
|
|
clear_color: Color,
|
|
) void {
|
|
// First, clear only the dirty regions
|
|
for (dirty_regions) |dirty| {
|
|
self.framebuffer.clearRect(dirty.x, dirty.y, dirty.w, dirty.h, clear_color);
|
|
}
|
|
|
|
// Then render commands, but only if they intersect dirty regions
|
|
for (commands) |cmd| {
|
|
const cmd_rect = commandBounds(cmd);
|
|
if (cmd_rect) |bounds| {
|
|
// Check if command intersects any dirty region
|
|
var needs_render = false;
|
|
for (dirty_regions) |dirty| {
|
|
if (rectsIntersect(bounds, dirty)) {
|
|
needs_render = true;
|
|
break;
|
|
}
|
|
}
|
|
if (!needs_render) continue;
|
|
}
|
|
// Commands without bounds (clip, clip_end, nop) always execute
|
|
self.execute(cmd);
|
|
}
|
|
}
|
|
|
|
/// Clear the framebuffer
|
|
pub fn clear(self: *Self, color: Color) void {
|
|
self.framebuffer.clear(color);
|
|
}
|
|
|
|
/// Clear a rectangular region (for partial redraw)
|
|
pub fn clearRect(self: *Self, x: i32, y: i32, w: u32, h: u32, color: Color) void {
|
|
self.framebuffer.clearRect(x, y, w, h, color);
|
|
}
|
|
|
|
// =========================================================================
|
|
// Private drawing functions
|
|
// =========================================================================
|
|
|
|
fn drawRect(self: *Self, r: Command.RectCommand) void {
|
|
const clip = self.getClip();
|
|
|
|
// Clip the rectangle
|
|
const clipped = Rect.init(r.x, r.y, r.w, r.h).intersection(clip);
|
|
if (clipped.isEmpty()) return;
|
|
|
|
self.framebuffer.fillRect(
|
|
clipped.x,
|
|
clipped.y,
|
|
clipped.w,
|
|
clipped.h,
|
|
r.color,
|
|
);
|
|
}
|
|
|
|
fn drawText(self: *Self, t: Command.TextCommand) void {
|
|
const clip = self.getClip();
|
|
|
|
// Use TTF font if available (takes priority)
|
|
if (self.ttf_font) |ttf| {
|
|
ttf.drawText(self.framebuffer, t.x, t.y, t.text, t.color, clip);
|
|
return;
|
|
}
|
|
|
|
// Fall back to bitmap font
|
|
const font = if (t.font) |f|
|
|
@as(*Font, @ptrCast(@alignCast(f)))
|
|
else
|
|
self.default_font orelse return;
|
|
|
|
// UTF-8 text rendering - decode codepoints properly
|
|
var x = t.x;
|
|
var i: usize = 0;
|
|
while (i < t.text.len) {
|
|
// Check if character is visible
|
|
if (x >= clip.right()) break;
|
|
|
|
// Decode UTF-8 codepoint
|
|
const byte = t.text[i];
|
|
var codepoint: u21 = undefined;
|
|
var bytes_consumed: usize = 1;
|
|
|
|
if (byte < 0x80) {
|
|
// ASCII (0x00-0x7F)
|
|
codepoint = byte;
|
|
} else if (byte < 0xC0) {
|
|
// Invalid start byte (continuation byte), skip
|
|
i += 1;
|
|
continue;
|
|
} else if (byte < 0xE0) {
|
|
// 2-byte sequence (0xC0-0xDF)
|
|
if (i + 1 < t.text.len) {
|
|
codepoint = (@as(u21, byte & 0x1F) << 6) |
|
|
@as(u21, t.text[i + 1] & 0x3F);
|
|
bytes_consumed = 2;
|
|
} else {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
} else if (byte < 0xF0) {
|
|
// 3-byte sequence (0xE0-0xEF)
|
|
if (i + 2 < t.text.len) {
|
|
codepoint = (@as(u21, byte & 0x0F) << 12) |
|
|
(@as(u21, t.text[i + 1] & 0x3F) << 6) |
|
|
@as(u21, t.text[i + 2] & 0x3F);
|
|
bytes_consumed = 3;
|
|
} else {
|
|
i += 1;
|
|
continue;
|
|
}
|
|
} else {
|
|
// 4-byte sequence (0xF0-0xF7) - beyond Latin-1, skip
|
|
bytes_consumed = 4;
|
|
i += bytes_consumed;
|
|
x += @as(i32, @intCast(font.charWidth())); // placeholder space
|
|
continue;
|
|
}
|
|
|
|
i += bytes_consumed;
|
|
|
|
// Handle newlines
|
|
if (codepoint == '\n') {
|
|
continue;
|
|
}
|
|
|
|
// Convert codepoint to Latin-1 for rendering
|
|
// Latin-1 covers 0x00-0xFF directly
|
|
const char_to_render: u8 = if (codepoint <= 0xFF)
|
|
@intCast(codepoint)
|
|
else
|
|
'?'; // Replacement for chars outside Latin-1
|
|
|
|
// Render character
|
|
font.drawChar(self.framebuffer, x, t.y, char_to_render, t.color, clip);
|
|
|
|
x += @as(i32, @intCast(font.charWidth()));
|
|
}
|
|
}
|
|
|
|
fn drawLine(self: *Self, l: Command.LineCommand) void {
|
|
// TODO: Clip line to clip rectangle
|
|
self.framebuffer.drawLine(l.x1, l.y1, l.x2, l.y2, l.color);
|
|
}
|
|
|
|
fn drawRectOutline(self: *Self, r: Command.RectOutlineCommand) void {
|
|
const clip = self.getClip();
|
|
|
|
// Draw each edge as a filled rect
|
|
// Top
|
|
const top_clipped = Rect.init(r.x, r.y, r.w, r.thickness).intersection(clip);
|
|
if (!top_clipped.isEmpty()) {
|
|
self.framebuffer.fillRect(top_clipped.x, top_clipped.y, top_clipped.w, top_clipped.h, r.color);
|
|
}
|
|
|
|
// Bottom
|
|
const bottom_y = r.y + @as(i32, @intCast(r.h)) - @as(i32, @intCast(r.thickness));
|
|
const bottom_clipped = Rect.init(r.x, bottom_y, r.w, r.thickness).intersection(clip);
|
|
if (!bottom_clipped.isEmpty()) {
|
|
self.framebuffer.fillRect(bottom_clipped.x, bottom_clipped.y, bottom_clipped.w, bottom_clipped.h, r.color);
|
|
}
|
|
|
|
// Left
|
|
const inner_y = r.y + @as(i32, @intCast(r.thickness));
|
|
const inner_h = r.h -| (r.thickness * 2);
|
|
const left_clipped = Rect.init(r.x, inner_y, r.thickness, inner_h).intersection(clip);
|
|
if (!left_clipped.isEmpty()) {
|
|
self.framebuffer.fillRect(left_clipped.x, left_clipped.y, left_clipped.w, left_clipped.h, r.color);
|
|
}
|
|
|
|
// Right
|
|
const right_x = r.x + @as(i32, @intCast(r.w)) - @as(i32, @intCast(r.thickness));
|
|
const right_clipped = Rect.init(right_x, inner_y, r.thickness, inner_h).intersection(clip);
|
|
if (!right_clipped.isEmpty()) {
|
|
self.framebuffer.fillRect(right_clipped.x, right_clipped.y, right_clipped.w, right_clipped.h, r.color);
|
|
}
|
|
}
|
|
|
|
fn drawRoundedRect(self: *Self, r: Command.RoundedRectCommand) void {
|
|
// TODO: Apply clipping (for now, draw directly)
|
|
// The fillRoundedRect function handles bounds checking internally
|
|
self.framebuffer.fillRoundedRect(r.x, r.y, r.w, r.h, r.color, r.radius, r.aa);
|
|
}
|
|
|
|
fn drawRoundedRectOutline(self: *Self, r: Command.RoundedRectOutlineCommand) void {
|
|
// TODO: Apply clipping
|
|
self.framebuffer.drawRoundedRect(r.x, r.y, r.w, r.h, r.color, r.radius, r.thickness, r.aa);
|
|
}
|
|
|
|
fn pushClip(self: *Self, c: Command.ClipCommand) void {
|
|
if (self.clip_depth >= self.clip_stack.len) return;
|
|
|
|
const new_clip = Rect.init(c.x, c.y, c.w, c.h);
|
|
const current = self.getClip();
|
|
const clipped = new_clip.intersection(current);
|
|
|
|
self.clip_stack[self.clip_depth] = clipped;
|
|
self.clip_depth += 1;
|
|
}
|
|
|
|
fn popClip(self: *Self) void {
|
|
if (self.clip_depth > 0) {
|
|
self.clip_depth -= 1;
|
|
}
|
|
}
|
|
};
|
|
|
|
// =============================================================================
|
|
// Helper functions for partial redraw
|
|
// =============================================================================
|
|
|
|
/// Get bounding rectangle of a draw command (null for commands without bounds)
|
|
fn commandBounds(cmd: DrawCommand) ?Rect {
|
|
return switch (cmd) {
|
|
.rect => |r| Rect.init(r.x, r.y, r.w, r.h),
|
|
.rounded_rect => |r| Rect.init(r.x, r.y, r.w, r.h),
|
|
.text => |t| blk: {
|
|
// Estimate text bounds (width based on text length, height based on font)
|
|
// This is approximate; actual font metrics would be better
|
|
const char_width = 8; // Default font width
|
|
const char_height = 16; // Default font height
|
|
const text_width = @as(u32, @intCast(t.text.len)) * char_width;
|
|
break :blk Rect.init(t.x, t.y, text_width, char_height);
|
|
},
|
|
.line => |l| blk: {
|
|
const min_x = @min(l.x1, l.x2);
|
|
const min_y = @min(l.y1, l.y2);
|
|
const max_x = @max(l.x1, l.x2);
|
|
const max_y = @max(l.y1, l.y2);
|
|
break :blk Rect.init(
|
|
min_x,
|
|
min_y,
|
|
@intCast(@as(u32, @intCast(max_x - min_x)) + 1),
|
|
@intCast(@as(u32, @intCast(max_y - min_y)) + 1),
|
|
);
|
|
},
|
|
.rect_outline => |r| Rect.init(r.x, r.y, r.w, r.h),
|
|
.rounded_rect_outline => |r| Rect.init(r.x, r.y, r.w, r.h),
|
|
.clip, .clip_end, .nop => null,
|
|
};
|
|
}
|
|
|
|
/// Check if two rectangles intersect
|
|
fn rectsIntersect(a: Rect, b: Rect) bool {
|
|
const a_right = a.x + @as(i32, @intCast(a.w));
|
|
const a_bottom = a.y + @as(i32, @intCast(a.h));
|
|
const b_right = b.x + @as(i32, @intCast(b.w));
|
|
const b_bottom = b.y + @as(i32, @intCast(b.h));
|
|
|
|
return a.x < b_right and a_right > b.x and a.y < b_bottom and a_bottom > b.y;
|
|
}
|
|
|
|
// =============================================================================
|
|
// Tests
|
|
// =============================================================================
|
|
|
|
test "SoftwareRenderer basic" {
|
|
var fb = try Framebuffer.init(std.testing.allocator, 100, 100);
|
|
defer fb.deinit();
|
|
|
|
var renderer = SoftwareRenderer.init(&fb);
|
|
|
|
renderer.clear(Color.black);
|
|
renderer.execute(Command.rect(10, 10, 20, 20, Color.red));
|
|
|
|
const pixel = fb.getPixel(15, 15);
|
|
try std.testing.expect(pixel != null);
|
|
try std.testing.expectEqual(Color.red.toABGR(), pixel.?);
|
|
}
|
|
|
|
test "SoftwareRenderer clipping" {
|
|
var fb = try Framebuffer.init(std.testing.allocator, 100, 100);
|
|
defer fb.deinit();
|
|
|
|
var renderer = SoftwareRenderer.init(&fb);
|
|
|
|
renderer.clear(Color.black);
|
|
|
|
// Set clip to 50x50
|
|
renderer.execute(Command.clip(0, 0, 50, 50));
|
|
|
|
// Draw rect that extends beyond clip
|
|
renderer.execute(Command.rect(40, 40, 30, 30, Color.red));
|
|
|
|
renderer.execute(Command.clipEnd());
|
|
|
|
// Check inside clip (should be red)
|
|
const inside = fb.getPixel(45, 45);
|
|
try std.testing.expect(inside != null);
|
|
try std.testing.expectEqual(Color.red.toABGR(), inside.?);
|
|
|
|
// Check outside clip (should be black)
|
|
const outside = fb.getPixel(55, 55);
|
|
try std.testing.expect(outside != null);
|
|
try std.testing.expectEqual(Color.black.toABGR(), outside.?);
|
|
}
|