Mejoras de performance v1.1

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 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 12:23:21 +01:00
parent 560ed1b355
commit 1df3172afc
4 changed files with 382 additions and 54 deletions

View file

@ -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 { pub fn writeChar(self: *AnsiBackend, char: u21) !void {
var buf: [4]u8 = undefined; var buf: [4]u8 = undefined;
const len = std.unicode.utf8Encode(char, &buf) catch return; const len = std.unicode.utf8Encode(char, &buf) catch return;
_ = self.stdout.write(buf[0..len]) catch {}; _ = 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. /// Flushes output to the terminal.
pub fn flush(self: *AnsiBackend) !void { pub fn flush(self: *AnsiBackend) !void {
// stdout is typically unbuffered or line-buffered // stdout is typically unbuffered or line-buffered

View file

@ -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. /// 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 { pub const Cell = struct {
/// The character to display (Unicode codepoint, space by default). /// The character to display (UTF-8 encoded).
char: u21 = ' ', symbol: Symbol = Symbol.space,
/// Foreground color. /// Foreground color.
fg: Color = .reset, fg: Color = .reset,
/// Background color. /// Background color.
bg: Color = .reset, bg: Color = .reset,
/// Text modifiers (bold, italic, etc.). /// Text modifiers (bold, italic, etc.).
modifiers: Modifier = .{}, modifiers: Modifier = .{},
/// Whether this cell has been modified and needs redraw.
dirty: bool = true,
pub const empty: Cell = .{}; pub const empty: Cell = .{};
/// Creates a cell with the given character. /// Creates a cell with the given character (codepoint).
pub fn init(char: u21) Cell { pub fn init(cp: u21) Cell {
return .{ .char = char }; 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. /// Sets the style of the cell.
@ -157,13 +227,45 @@ pub const Cell = struct {
if (s.background) |bg_color| self.bg = bg_color; if (s.background) |bg_color| self.bg = bg_color;
self.modifiers = self.modifiers.insert(s.add_modifiers); self.modifiers = self.modifiers.insert(s.add_modifiers);
self.modifiers = self.modifiers.remove(s.sub_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. /// Resets the cell to empty.
pub fn reset(self: *Cell) void { pub fn reset(self: *Cell) void {
self.* = Cell.empty; 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. /// A buffer holding a grid of cells representing terminal state.
@ -192,6 +294,37 @@ pub const Buffer = struct {
self.allocator.free(self.cells); 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). /// Gets the index in the cells array for position (x, y).
fn indexAt(self: *const Buffer, x: u16, y: u16) ?usize { fn indexAt(self: *const Buffer, x: u16, y: u16) ?usize {
if (!self.area.contains(x, y)) { if (!self.area.contains(x, y)) {
@ -214,10 +347,15 @@ pub const Buffer = struct {
return self.cells[idx]; 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. /// 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| { if (self.getPtr(x, y)) |cell| {
cell.char = char; cell.setChar(cp);
cell.setStyle(s); cell.setStyle(s);
} }
} }
@ -238,7 +376,7 @@ pub const Buffer = struct {
} }
/// Fills the entire buffer (or a sub-area) with a character and style. /// 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); const target = self.area.intersection(rect);
if (target.isEmpty()) return; if (target.isEmpty()) return;
@ -246,7 +384,7 @@ pub const Buffer = struct {
while (y < target.bottom()) : (y += 1) { while (y < target.bottom()) : (y += 1) {
var cur_x = target.x; var cur_x = target.x;
while (cur_x < target.right()) : (cur_x += 1) { 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); @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 { pub fn markDirty(self: *Buffer) void {
for (self.cells) |*cell| { _ = self;
cell.dirty = true; // 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. /// Sets the style for an entire area without changing content.
pub fn markClean(self: *Buffer) void { pub fn setStyle(self: *Buffer, rect: Rect, s: Style) void {
for (self.cells) |*cell| { const target = self.area.intersection(rect);
cell.dirty = false; 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 // 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" { test "Rect basic operations" {
const r = Rect.init(10, 20, 100, 50); 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)); buf.setChar(5, 5, 'X', Style.default.fg(Color.red));
const cell = buf.get(5, 5).?; 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); try std.testing.expectEqual(Color.red, cell.fg);
} }
@ -329,9 +616,56 @@ test "Buffer setString" {
const written = buf.setString(0, 0, "Hello", Style{}); const written = buf.setString(0, 0, "Hello", Style{});
try std.testing.expectEqual(@as(u16, 5), written); 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, 'H'), buf.get(0, 0).?.char());
try std.testing.expectEqual(@as(u21, 'e'), buf.get(1, 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(2, 0).?.char());
try std.testing.expectEqual(@as(u21, 'l'), buf.get(3, 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, '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());
} }

View file

@ -37,6 +37,10 @@ pub const buffer = @import("buffer.zig");
pub const Cell = buffer.Cell; pub const Cell = buffer.Cell;
pub const Buffer = buffer.Buffer; pub const Buffer = buffer.Buffer;
pub const Rect = buffer.Rect; 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 text = @import("text.zig");
pub const Span = text.Span; pub const Span = text.Span;

View file

@ -109,34 +109,17 @@ pub const Terminal = struct {
/// ///
/// Compares current and previous buffers, only outputting differences. /// Compares current and previous buffers, only outputting differences.
fn flush(self: *Terminal) !void { 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);
var y: u16 = rect.y; while (diff_iter.next()) |update| {
while (y < rect.bottom()) : (y += 1) { try self.backend.moveCursor(update.x, update.y);
var x: u16 = rect.x; try self.backend.setStyle(update.cell.fg, update.cell.bg, update.cell.modifiers);
while (x < rect.right()) : (x += 1) { // Write the symbol directly (already UTF-8 encoded)
const current = self.current_buffer.get(x, y) orelse continue; try self.backend.writeSymbol(update.cell.symbol.slice());
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 // Update previous buffer
if (self.previous_buffer.getPtr(x, y)) |prev| { if (self.previous_buffer.getPtr(update.x, update.y)) |prev| {
prev.* = current; prev.* = update.cell;
}
}
} }
} }