From 1df3172afcff9593a222cbcd7b2e9b4ce7dd6e7b Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 12:23:21 +0100 Subject: [PATCH] Mejoras de performance v1.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buffer optimizations: - Symbol: nuevo tipo compacto para almacenar UTF-8 (4 bytes max) - Evita conversion codepoint->UTF8 en cada render - fromCodepoint() y fromSlice() para crear symbols - slice() para output directo sin conversion - Cell: refactorizado para usar Symbol - Eliminado campo 'dirty' (innecesario con diff) - Nuevo metodo eql() para comparacion eficiente - char() accessor para compatibilidad legacy - Buffer.diff(): nuevo sistema de renderizado diferencial - DiffIterator compara buffers celda a celda - Solo retorna celdas que cambiaron - Reduce drasticamente I/O a terminal - Buffer.resize(): nuevo metodo para redimensionar - Preserva contenido existente donde posible - Backend.writeSymbol(): escribe UTF-8 directo - Mas eficiente que writeChar() con conversion - Terminal.flush(): usa diff iterator - Solo escribe celdas modificadas Tests: 18 tests (9 nuevos para Symbol, Cell, diff) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/backend/backend.zig | 9 +- src/buffer.zig | 386 +++++++++++++++++++++++++++++++++++++--- src/root.zig | 4 + src/terminal.zig | 37 ++-- 4 files changed, 382 insertions(+), 54 deletions(-) diff --git a/src/backend/backend.zig b/src/backend/backend.zig index f26de56..e3e8ee9 100644 --- a/src/backend/backend.zig +++ b/src/backend/backend.zig @@ -186,13 +186,20 @@ pub const AnsiBackend = struct { } } - /// Writes a single character. + /// Writes a single character (codepoint). pub fn writeChar(self: *AnsiBackend, char: u21) !void { var buf: [4]u8 = undefined; const len = std.unicode.utf8Encode(char, &buf) catch return; _ = self.stdout.write(buf[0..len]) catch {}; } + /// Writes a UTF-8 encoded symbol directly (more efficient than writeChar). + pub fn writeSymbol(self: *AnsiBackend, symbol: []const u8) !void { + if (symbol.len > 0) { + _ = self.stdout.write(symbol) catch {}; + } + } + /// Flushes output to the terminal. pub fn flush(self: *AnsiBackend) !void { // stdout is typically unbuffered or line-buffered diff --git a/src/buffer.zig b/src/buffer.zig index a369acf..cf8ed75 100644 --- a/src/buffer.zig +++ b/src/buffer.zig @@ -129,26 +129,96 @@ pub const Margin = struct { } }; +/// Compact UTF-8 symbol storage (up to 4 bytes). +/// +/// This is more memory-efficient than storing a u21 codepoint because: +/// 1. Most common characters (ASCII) use only 1 byte +/// 2. No conversion needed when writing to terminal (already UTF-8) +/// 3. Supports grapheme clusters up to 4 bytes (covers most use cases) +pub const Symbol = struct { + /// UTF-8 encoded bytes (up to 4 bytes, padded with zeros) + data: [4]u8 = .{ ' ', 0, 0, 0 }, + /// Length of the UTF-8 sequence (1-4) + len: u3 = 1, + + pub const default_val: Symbol = .{}; + pub const space: Symbol = .{}; + + /// Creates a Symbol from a UTF-8 string slice. + /// Takes only the first grapheme (up to 4 bytes). + pub fn fromSlice(bytes: []const u8) Symbol { + if (bytes.len == 0) return Symbol.space; + + var sym: Symbol = .{ .data = .{ 0, 0, 0, 0 }, .len = 0 }; + const copy_len = @min(bytes.len, 4); + for (0..copy_len) |i| { + sym.data[i] = bytes[i]; + } + sym.len = @intCast(copy_len); + return sym; + } + + /// Creates a Symbol from a Unicode codepoint. + pub fn fromCodepoint(cp: u21) Symbol { + var sym: Symbol = .{ .data = .{ 0, 0, 0, 0 }, .len = 0 }; + + if (cp < 0x80) { + sym.data[0] = @intCast(cp); + sym.len = 1; + } else if (cp < 0x800) { + sym.data[0] = @intCast(0xC0 | (cp >> 6)); + sym.data[1] = @intCast(0x80 | (cp & 0x3F)); + sym.len = 2; + } else if (cp < 0x10000) { + sym.data[0] = @intCast(0xE0 | (cp >> 12)); + sym.data[1] = @intCast(0x80 | ((cp >> 6) & 0x3F)); + sym.data[2] = @intCast(0x80 | (cp & 0x3F)); + sym.len = 3; + } else { + sym.data[0] = @intCast(0xF0 | (cp >> 18)); + sym.data[1] = @intCast(0x80 | ((cp >> 12) & 0x3F)); + sym.data[2] = @intCast(0x80 | ((cp >> 6) & 0x3F)); + sym.data[3] = @intCast(0x80 | (cp & 0x3F)); + sym.len = 4; + } + return sym; + } + + /// Returns the symbol as a slice for output. + pub fn slice(self: Symbol) []const u8 { + return self.data[0..self.len]; + } + + /// Checks equality with another Symbol. + pub fn eql(self: Symbol, other: Symbol) bool { + return std.mem.eql(u8, &self.data, &other.data) and self.len == other.len; + } +}; + /// A single cell in the terminal buffer. /// -/// Contains a character (as Unicode codepoint) and styling information. +/// Contains a character (as UTF-8 Symbol) and styling information. +/// Optimized for memory layout and cache efficiency. pub const Cell = struct { - /// The character to display (Unicode codepoint, space by default). - char: u21 = ' ', + /// The character to display (UTF-8 encoded). + symbol: Symbol = Symbol.space, /// Foreground color. fg: Color = .reset, /// Background color. bg: Color = .reset, /// Text modifiers (bold, italic, etc.). modifiers: Modifier = .{}, - /// Whether this cell has been modified and needs redraw. - dirty: bool = true, pub const empty: Cell = .{}; - /// Creates a cell with the given character. - pub fn init(char: u21) Cell { - return .{ .char = char }; + /// Creates a cell with the given character (codepoint). + pub fn init(cp: u21) Cell { + return .{ .symbol = Symbol.fromCodepoint(cp) }; + } + + /// Creates a cell from a UTF-8 string. + pub fn fromStr(s: []const u8) Cell { + return .{ .symbol = Symbol.fromSlice(s) }; } /// Sets the style of the cell. @@ -157,13 +227,45 @@ pub const Cell = struct { if (s.background) |bg_color| self.bg = bg_color; self.modifiers = self.modifiers.insert(s.add_modifiers); self.modifiers = self.modifiers.remove(s.sub_modifiers); - self.dirty = true; + } + + /// Sets the character from a codepoint. + pub fn setChar(self: *Cell, cp: u21) void { + self.symbol = Symbol.fromCodepoint(cp); + } + + /// Sets the character from a UTF-8 string. + pub fn setSymbol(self: *Cell, s: []const u8) void { + self.symbol = Symbol.fromSlice(s); } /// Resets the cell to empty. pub fn reset(self: *Cell) void { self.* = Cell.empty; } + + /// Checks if two cells are equal (same content and style). + pub fn eql(self: Cell, other: Cell) bool { + return self.symbol.eql(other.symbol) and + std.meta.eql(self.fg, other.fg) and + std.meta.eql(self.bg, other.bg) and + std.meta.eql(self.modifiers, other.modifiers); + } + + /// Legacy accessor for char (returns first codepoint or space). + pub fn char(self: Cell) u21 { + const bytes = self.symbol.slice(); + if (bytes.len == 0) return ' '; + const decoded = std.unicode.utf8Decode(bytes) catch return ' '; + return decoded; + } +}; + +/// A single cell update for differential rendering. +pub const CellUpdate = struct { + x: u16, + y: u16, + cell: Cell, }; /// A buffer holding a grid of cells representing terminal state. @@ -192,6 +294,37 @@ pub const Buffer = struct { self.allocator.free(self.cells); } + /// Resizes the buffer to a new area. + /// Preserves existing content where possible. + pub fn resize(self: *Buffer, new_rect: Rect) !void { + if (new_rect.width == self.area.width and new_rect.height == self.area.height) { + self.area = new_rect; + return; + } + + const new_size = new_rect.area(); + const new_cells = try self.allocator.alloc(Cell, new_size); + @memset(new_cells, Cell.empty); + + // Copy existing content + const min_width = @min(self.area.width, new_rect.width); + const min_height = @min(self.area.height, new_rect.height); + + var y: u16 = 0; + while (y < min_height) : (y += 1) { + const old_row_start = @as(usize, y) * @as(usize, self.area.width); + const new_row_start = @as(usize, y) * @as(usize, new_rect.width); + @memcpy( + new_cells[new_row_start..][0..min_width], + self.cells[old_row_start..][0..min_width], + ); + } + + self.allocator.free(self.cells); + self.cells = new_cells; + self.area = new_rect; + } + /// Gets the index in the cells array for position (x, y). fn indexAt(self: *const Buffer, x: u16, y: u16) ?usize { if (!self.area.contains(x, y)) { @@ -214,10 +347,15 @@ pub const Buffer = struct { return self.cells[idx]; } + /// Alias for getPtr for compatibility. + pub fn getCell(self: *Buffer, x: u16, y: u16) ?*Cell { + return self.getPtr(x, y); + } + /// Sets a single character at (x, y) with the given style. - pub fn setChar(self: *Buffer, x: u16, y: u16, char: u21, s: Style) void { + pub fn setChar(self: *Buffer, x: u16, y: u16, cp: u21, s: Style) void { if (self.getPtr(x, y)) |cell| { - cell.char = char; + cell.setChar(cp); cell.setStyle(s); } } @@ -238,7 +376,7 @@ pub const Buffer = struct { } /// Fills the entire buffer (or a sub-area) with a character and style. - pub fn fill(self: *Buffer, rect: Rect, char: u21, s: Style) void { + pub fn fill(self: *Buffer, rect: Rect, cp: u21, s: Style) void { const target = self.area.intersection(rect); if (target.isEmpty()) return; @@ -246,7 +384,7 @@ pub const Buffer = struct { while (y < target.bottom()) : (y += 1) { var cur_x = target.x; while (cur_x < target.right()) : (cur_x += 1) { - self.setChar(cur_x, y, char, s); + self.setChar(cur_x, y, cp, s); } } } @@ -256,25 +394,174 @@ pub const Buffer = struct { @memset(self.cells, Cell.empty); } - /// Marks all cells as dirty (need redraw). + /// Marks all cells for redraw (legacy compatibility). + /// With the new diff-based system, this is a no-op but kept for API compatibility. pub fn markDirty(self: *Buffer) void { - for (self.cells) |*cell| { - cell.dirty = true; + _ = self; + // No-op: diff-based rendering doesn't use per-cell dirty flags + } + + /// Marks all cells as clean (legacy compatibility). + pub fn markClean(self: *Buffer) void { + _ = self; + // No-op: diff-based rendering doesn't use per-cell dirty flags + } + + /// Computes the diff between this buffer and another. + /// Returns an iterator over changed cells. + /// This buffer is the "new" state, other is the "old" state. + pub fn diff(self: *const Buffer, other: *const Buffer) DiffIterator { + return DiffIterator{ + .new_buf = self, + .old_buf = other, + .index = 0, + }; + } + + /// Copies content from another buffer into this one. + /// Only copies the overlapping region. + pub fn merge(self: *Buffer, other: *const Buffer) void { + const target = self.area.intersection(other.area); + if (target.isEmpty()) return; + + var y = target.y; + while (y < target.bottom()) : (y += 1) { + var cur_x = target.x; + while (cur_x < target.right()) : (cur_x += 1) { + if (other.get(cur_x, y)) |cell| { + if (self.getPtr(cur_x, y)) |self_cell| { + self_cell.* = cell; + } + } + } } } - /// Marks all cells as clean. - pub fn markClean(self: *Buffer) void { - for (self.cells) |*cell| { - cell.dirty = false; + /// Sets the style for an entire area without changing content. + pub fn setStyle(self: *Buffer, rect: Rect, s: Style) void { + const target = self.area.intersection(rect); + if (target.isEmpty()) return; + + var y = target.y; + while (y < target.bottom()) : (y += 1) { + var cur_x = target.x; + while (cur_x < target.right()) : (cur_x += 1) { + if (self.getPtr(cur_x, y)) |cell| { + cell.setStyle(s); + } + } } } }; +/// Iterator for buffer differences. +pub const DiffIterator = struct { + new_buf: *const Buffer, + old_buf: *const Buffer, + index: usize, + + /// Returns the next changed cell, or null if done. + pub fn next(self: *DiffIterator) ?CellUpdate { + const total = self.new_buf.cells.len; + + while (self.index < total) { + const idx = self.index; + self.index += 1; + + const new_cell = self.new_buf.cells[idx]; + + // Check if old buffer has same index + const old_cell = if (idx < self.old_buf.cells.len) + self.old_buf.cells[idx] + else + Cell.empty; + + // If cells differ, return the update + if (!new_cell.eql(old_cell)) { + const local_x: u16 = @intCast(idx % self.new_buf.area.width); + const local_y: u16 = @intCast(idx / self.new_buf.area.width); + return CellUpdate{ + .x = self.new_buf.area.x + local_x, + .y = self.new_buf.area.y + local_y, + .cell = new_cell, + }; + } + } + + return null; + } + + /// Counts remaining differences without consuming the iterator. + pub fn countRemaining(self: *DiffIterator) usize { + var count: usize = 0; + var temp = self.*; + while (temp.next()) |_| { + count += 1; + } + return count; + } +}; + // ============================================================================ // Tests // ============================================================================ +test "Symbol fromCodepoint ASCII" { + const sym = Symbol.fromCodepoint('A'); + try std.testing.expectEqual(@as(u3, 1), sym.len); + try std.testing.expectEqualStrings("A", sym.slice()); +} + +test "Symbol fromCodepoint UTF-8" { + // 2-byte UTF-8 (e.g., copyright symbol) + const sym2 = Symbol.fromCodepoint(0xA9); // © + try std.testing.expectEqual(@as(u3, 2), sym2.len); + + // 3-byte UTF-8 (e.g., box drawing) + const sym3 = Symbol.fromCodepoint(0x2500); // ─ + try std.testing.expectEqual(@as(u3, 3), sym3.len); + + // 4-byte UTF-8 (e.g., emoji) + const sym4 = Symbol.fromCodepoint(0x1F600); // grinning face + try std.testing.expectEqual(@as(u3, 4), sym4.len); +} + +test "Symbol fromSlice" { + const sym = Symbol.fromSlice("X"); + try std.testing.expectEqualStrings("X", sym.slice()); + + const sym_multi = Symbol.fromSlice("AB"); + try std.testing.expectEqualStrings("AB", sym_multi.slice()); +} + +test "Symbol equality" { + const sym1 = Symbol.fromCodepoint('A'); + const sym2 = Symbol.fromCodepoint('A'); + const sym3 = Symbol.fromCodepoint('B'); + + try std.testing.expect(sym1.eql(sym2)); + try std.testing.expect(!sym1.eql(sym3)); +} + +test "Cell init and char" { + const cell = Cell.init('X'); + try std.testing.expectEqual(@as(u21, 'X'), cell.char()); +} + +test "Cell equality" { + var cell1 = Cell.init('A'); + cell1.fg = Color.red; + + var cell2 = Cell.init('A'); + cell2.fg = Color.red; + + var cell3 = Cell.init('A'); + cell3.fg = Color.blue; + + try std.testing.expect(cell1.eql(cell2)); + try std.testing.expect(!cell1.eql(cell3)); +} + test "Rect basic operations" { const r = Rect.init(10, 20, 100, 50); @@ -317,7 +604,7 @@ test "Buffer creation and access" { buf.setChar(5, 5, 'X', Style.default.fg(Color.red)); const cell = buf.get(5, 5).?; - try std.testing.expectEqual(@as(u21, 'X'), cell.char); + try std.testing.expectEqual(@as(u21, 'X'), cell.char()); try std.testing.expectEqual(Color.red, cell.fg); } @@ -329,9 +616,56 @@ test "Buffer setString" { const written = buf.setString(0, 0, "Hello", Style{}); try std.testing.expectEqual(@as(u16, 5), written); - try std.testing.expectEqual(@as(u21, 'H'), buf.get(0, 0).?.char); - try std.testing.expectEqual(@as(u21, 'e'), buf.get(1, 0).?.char); - try std.testing.expectEqual(@as(u21, 'l'), buf.get(2, 0).?.char); - try std.testing.expectEqual(@as(u21, 'l'), buf.get(3, 0).?.char); - try std.testing.expectEqual(@as(u21, 'o'), buf.get(4, 0).?.char); + try std.testing.expectEqual(@as(u21, 'H'), buf.get(0, 0).?.char()); + try std.testing.expectEqual(@as(u21, 'e'), buf.get(1, 0).?.char()); + try std.testing.expectEqual(@as(u21, 'l'), buf.get(2, 0).?.char()); + try std.testing.expectEqual(@as(u21, 'l'), buf.get(3, 0).?.char()); + try std.testing.expectEqual(@as(u21, 'o'), buf.get(4, 0).?.char()); +} + +test "Buffer diff empty buffers" { + const allocator = std.testing.allocator; + var buf1 = try Buffer.init(allocator, Rect.init(0, 0, 10, 10)); + defer buf1.deinit(); + var buf2 = try Buffer.init(allocator, Rect.init(0, 0, 10, 10)); + defer buf2.deinit(); + + var diff_iter = buf1.diff(&buf2); + try std.testing.expectEqual(@as(?CellUpdate, null), diff_iter.next()); +} + +test "Buffer diff with changes" { + const allocator = std.testing.allocator; + var buf1 = try Buffer.init(allocator, Rect.init(0, 0, 10, 10)); + defer buf1.deinit(); + var buf2 = try Buffer.init(allocator, Rect.init(0, 0, 10, 10)); + defer buf2.deinit(); + + // Modify buf1 + buf1.setChar(5, 5, 'X', Style.default); + + var diff_iter = buf1.diff(&buf2); + const update = diff_iter.next(); + try std.testing.expect(update != null); + try std.testing.expectEqual(@as(u16, 5), update.?.x); + try std.testing.expectEqual(@as(u16, 5), update.?.y); + try std.testing.expectEqual(@as(u21, 'X'), update.?.cell.char()); + + // No more differences + try std.testing.expectEqual(@as(?CellUpdate, null), diff_iter.next()); +} + +test "Buffer resize" { + const allocator = std.testing.allocator; + var buf = try Buffer.init(allocator, Rect.init(0, 0, 10, 10)); + defer buf.deinit(); + + buf.setChar(5, 5, 'X', Style.default); + + try buf.resize(Rect.init(0, 0, 20, 20)); + try std.testing.expectEqual(@as(u16, 20), buf.area.width); + try std.testing.expectEqual(@as(u16, 20), buf.area.height); + + // Original content should be preserved + try std.testing.expectEqual(@as(u21, 'X'), buf.get(5, 5).?.char()); } diff --git a/src/root.zig b/src/root.zig index b6ce679..27a2f4f 100644 --- a/src/root.zig +++ b/src/root.zig @@ -37,6 +37,10 @@ pub const buffer = @import("buffer.zig"); pub const Cell = buffer.Cell; pub const Buffer = buffer.Buffer; pub const Rect = buffer.Rect; +pub const Symbol = buffer.Symbol; +pub const Margin = buffer.Margin; +pub const CellUpdate = buffer.CellUpdate; +pub const DiffIterator = buffer.DiffIterator; pub const text = @import("text.zig"); pub const Span = text.Span; diff --git a/src/terminal.zig b/src/terminal.zig index 8719d50..8ef9688 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -109,34 +109,17 @@ pub const Terminal = struct { /// /// Compares current and previous buffers, only outputting differences. fn flush(self: *Terminal) !void { - const rect = self.current_buffer.area; + // Use diff iterator for efficient rendering + var diff_iter = self.current_buffer.diff(&self.previous_buffer); + while (diff_iter.next()) |update| { + try self.backend.moveCursor(update.x, update.y); + try self.backend.setStyle(update.cell.fg, update.cell.bg, update.cell.modifiers); + // Write the symbol directly (already UTF-8 encoded) + try self.backend.writeSymbol(update.cell.symbol.slice()); - var y: u16 = rect.y; - while (y < rect.bottom()) : (y += 1) { - var x: u16 = rect.x; - while (x < rect.right()) : (x += 1) { - const current = self.current_buffer.get(x, y) orelse continue; - const previous = self.previous_buffer.get(x, y); - - // Only update if changed - const needs_update = if (previous) |prev| - current.char != prev.char or - !colorEqual(current.fg, prev.fg) or - !colorEqual(current.bg, prev.bg) or - !modifierEqual(current.modifiers, prev.modifiers) - else - true; - - if (needs_update) { - try self.backend.moveCursor(x, y); - try self.backend.setStyle(current.fg, current.bg, current.modifiers); - try self.backend.writeChar(current.char); - - // Update previous buffer - if (self.previous_buffer.getPtr(x, y)) |prev| { - prev.* = current; - } - } + // Update previous buffer + if (self.previous_buffer.getPtr(update.x, update.y)) |prev| { + prev.* = update.cell; } }