zcatgui/src/render/software.zig
reugenio 364a7d963f feat: Paridad visual DVUI - RenderMode dual (simple/fancy)
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>
2025-12-17 01:02:46 +01:00

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.?);
}