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