From b6dbc4db27f84acb282a13170acde3e00c7e6c3a Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Thu, 13 Aug 2026 17:45:55 +0200 Subject: [PATCH] feat: rewrite only the lines that changed (#125) `Program.render` homed the cursor and rewrote every visible line on any view change, so a spinner ticking beside a screenful of streaming text cost a full frame of output ten times a second. The cell-level `Screen.renderDiff` in the codebase was never wired into the render loop, which works on view strings rather than cells. Adds a line-level renderer in `src/terminal/frame.zig`, matching the granularity the render loop actually has. It keeps the previous frame, compares line by line, and rewrites only the rows that differ. On a 48-row log view with a spinner, 600 frames cost 1,649,400 bytes before and 23,115 after -- 2749 bytes per frame down to 38. Diffing addresses rows absolutely, so it steps aside and repaints in full whenever frame row n is not terminal row n: - a line wider than the terminal, which would wrap and shift the rows below it - a frame taller than the terminal, which would scroll - a line that leaves a colour or attribute switched on, since the lines under it inherit that styling and cannot be redrawn alone - the frame after any of the above The runtime also invalidates after a resize, a suspend/resume, an inline image, `println`, and alt-screen switches. `Cmd.repaint` and `Program.invalidate()` cover anything else that writes to the terminal behind the framework's back. `Options.render_mode = .full` restores the previous behaviour. The renderer is a standalone value over a `std.Io.Writer`, so `tests/render_tests.zig` can replay its output through a small virtual screen and assert that a diffed frame leaves the terminal in exactly the state a full repaint would -- content and cursor position both. --- REFERENCE.md | 38 ++++ build.zig | 1 + src/core/command.zig | 4 + src/core/context.zig | 9 + src/core/program.zig | 82 +++---- src/core/sub_program.zig | 1 + src/root.zig | 3 + src/terminal/frame.zig | 398 ++++++++++++++++++++++++++++++++++ src/terminal/terminal.zig | 1 + tests/render_tests.zig | 435 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 924 insertions(+), 48 deletions(-) create mode 100644 src/terminal/frame.zig create mode 100644 tests/render_tests.zig diff --git a/REFERENCE.md b/REFERENCE.md index 0f8ebe0..e20b9d1 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -39,6 +39,7 @@ return .show_cursor; // Show terminal cursor return .hide_cursor; // Hide terminal cursor return .{ .set_title = "My App" }; // Set terminal window title return .{ .println = "Log message" }; // Print above the program output +return .repaint; // Repaint the whole frame next render return .{ .image_file = .{ // Draw image via Kitty/iTerm2/Sixel when available .path = "assets/cat.png", .width_cells = 40, @@ -874,6 +875,7 @@ var program = zz.Program(Model).initWithOptions(init.gpa, init.io, init.environ_ .unicode_width_strategy = null, // null=auto, .legacy_wcwidth, .unicode .suspend_enabled = true, // Enable Ctrl+Z suspend/resume .escape_timeout_ms = 50, // How long a lone ESC waits for a sequence + .render_mode = .diff, // .diff rewrites changed lines, .full rewrites everything .title = "My App", // Window title .log_file = "debug.log", // Debug log file path .input = custom_stdin, // Custom input (for testing/piping) @@ -893,6 +895,42 @@ By default (`null`/`auto`), ZigZag: - probes kitty text-sizing support, - applies terminal/multiplexer heuristics (e.g. tmux/screen/zellij favor legacy width). +### Rendering + +`view()` returns the whole frame as a string; the runtime works out what to +send to the terminal. + +With the default `render_mode = .diff`, a frame is compared line by line +against the one before it and only the rows that changed are rewritten. An +animated spinner next to a screenful of streaming text costs a few dozen bytes +per frame instead of a few thousand, which is the difference between a busy +event loop and an idle one at 60fps. + +Diffing needs frame row `n` to really be terminal row `n`, so it steps aside +and repaints in full whenever that does not hold: + +- a line wider than the terminal (it would wrap and shift everything below it) +- a frame taller than the terminal (it would scroll) +- a line that leaves a colour or attribute switched on, since the lines under + it inherit that styling and cannot be redrawn on their own +- the frame after any of the above + +The runtime also repaints in full after a resize, a suspend/resume, an inline +image, `println`, and alt-screen switches. If something *outside* the framework +writes to the terminal — a library printing to stderr, a shelled-out command — +tell the renderer its picture is stale: + +```zig +return .repaint; // from update() +program.invalidate(); // from a custom event loop +``` + +Set `render_mode = .full` to always rewrite every line, which is +self-correcting at the cost of a screenful of output per frame. + +The renderer is usable on its own — `zz.FrameRenderer` over any +`std.Io.Writer` — if you drive the terminal yourself. + ### Allocator lifetimes `ctx.allocator` is a frame allocator that is reset before each `tick()`. diff --git a/build.zig b/build.zig index 52141bf..bc93d67 100644 --- a/build.zig +++ b/build.zig @@ -98,6 +98,7 @@ pub fn build(b: *std.Build) void { "tests/unicode_tests.zig", "tests/program_tests.zig", "tests/command_tests.zig", + "tests/render_tests.zig", "tests/focus_tests.zig", "tests/modal_tests.zig", "tests/tooltip_tests.zig", diff --git a/src/core/command.zig b/src/core/command.zig index 4463ec3..164d416 100644 --- a/src/core/command.zig +++ b/src/core/command.zig @@ -188,6 +188,10 @@ pub fn Cmd(comptime Msg: type) type { /// Print a line above the program output println: []const u8, + /// Repaint the whole frame on the next render. + /// Use after something outside the framework wrote to the terminal. + repaint, + /// Draw an image file using the best available protocol (Kitty, iTerm2, Sixel) image_file: ImageFile, diff --git a/src/core/context.zig b/src/core/context.zig index 1c6b380..3478f19 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -10,6 +10,7 @@ const unicode_mod = @import("../unicode.zig"); const Logger = @import("log.zig").Logger; const theme_mod = @import("../style/theme.zig"); const Environment = @import("environment.zig").Environment; +const frame_mod = @import("../terminal/frame.zig"); /// Runtime context passed to init, update, and view functions pub const Context = struct { @@ -389,4 +390,12 @@ pub const Options = struct { /// input follows. Lower values make Escape feel snappier; raise it if /// sequences arrive in pieces over a slow link (ssh, serial). escape_timeout_ms: u32 = 50, + + /// How a new frame is pushed to the terminal. + /// + /// `.diff` rewrites only the lines that changed since the previous frame, + /// which is what keeps a spinner or a streaming log from costing a full + /// screen of output several times a second. It falls back to a full + /// repaint on its own whenever the screen cannot be addressed by row. + render_mode: frame_mod.Mode = .diff, }; diff --git a/src/core/program.zig b/src/core/program.zig index 60033ec..79b56a8 100644 --- a/src/core/program.zig +++ b/src/core/program.zig @@ -13,6 +13,7 @@ const command = @import("command.zig"); const Logger = @import("log.zig").Logger; const unicode = @import("../unicode.zig"); const Environment = @import("environment.zig").Environment; +const frame = @import("../terminal/frame.zig"); pub const Cmd = command.Cmd; pub const Msg = message; @@ -80,8 +81,7 @@ pub fn Program(comptime Model: type) type { pending_tick_scheduled_at: u64, every_interval: ?u64, last_every_tick: u64, - last_view_hash: u64, - last_line_count: usize, + renderer: frame.Renderer, pending_image: ?PendingImage, logger: ?Logger, /// Retains escape sequences that a read cut in half. @@ -128,8 +128,7 @@ pub fn Program(comptime Model: type) type { .pending_tick_scheduled_at = 0, .every_interval = null, .last_every_tick = 0, - .last_view_hash = 0, - .last_line_count = 0, + .renderer = frame.Renderer.init(allocator, options.render_mode), .pending_image = null, .logger = null, .input_parser = .{}, @@ -151,6 +150,7 @@ pub fn Program(comptime Model: type) type { if (self.logger) |*l| { l.deinit(); } + self.renderer.deinit(); self.arena.deinit(); // Call model's deinit if it exists @@ -269,6 +269,10 @@ pub fn Program(comptime Model: type) type { self.context.width = size.cols; self.context.height = size.rows; + // The terminal reflows on resize, so the previous frame no + // longer describes what is on screen. + self.renderer.invalidate(); + // Only send window_size message if the user model supports it if (@hasField(UserMsg, "window_size")) { const cmd = self.dispatchToModel(.{ .window_size = .{ @@ -502,8 +506,9 @@ pub fn Program(comptime Model: type) type { self.last_every_tick = resume_elapsed; self.pending_tick_scheduled_at = resume_elapsed; - // Force re-render - self.last_view_hash = 0; + // The terminal was handed back to the shell in between, so nothing + // about the previous frame can be relied on. + self.renderer.invalidate(); // Dispatch resumed message if model supports it if (@hasField(UserMsg, "resumed")) { @@ -579,6 +584,8 @@ pub fn Program(comptime Model: type) type { try writer.writeAll(ansi.alt_screen_enter); try term.flush(); } + // Switching buffers swaps out everything on screen. + self.renderer.invalidate(); }, .exit_alt_screen => { if (self.terminal) |*term| { @@ -586,6 +593,10 @@ pub fn Program(comptime Model: type) type { try writer.writeAll(ansi.alt_screen_exit); try term.flush(); } + self.renderer.invalidate(); + }, + .repaint => { + self.renderer.invalidate(); }, .set_title => |title| { if (self.terminal) |*term| { @@ -602,6 +613,8 @@ pub fn Program(comptime Model: type) type { try writer.writeAll(ansi.cursor_restore); try term.flush(); } + // This wrote over the frame area. + self.renderer.invalidate(); }, .image_file => |image| { self.pending_image = .{ .auto = image }; @@ -754,6 +767,9 @@ pub fn Program(comptime Model: type) type { }, } try term.flush(); + // An image covers cells the renderer thinks it owns; the next + // frame repaints over it the way a full redraw always did. + self.renderer.invalidate(); } } @@ -862,49 +878,19 @@ pub fn Program(comptime Model: type) type { fn render(self: *Self) !void { const view_output = self.model.view(&self.context); - // Compute hash of view output - const view_hash = std.hash.Wyhash.hash(0, view_output); - - // Only redraw if view changed - if (view_hash != self.last_view_hash) { - const writer = self.terminal.?.writer(); - - // Start synchronized output (prevents tearing on supporting terminals) - try writer.writeAll(ansi.sync_start); - - // Move cursor home (don't clear entire screen to reduce flicker) - try writer.writeAll(ansi.cursor_home); - - // Write each line, clearing to end of line - var lines = std.mem.splitScalar(u8, view_output, '\n'); - var first = true; - var line_count: usize = 0; - while (lines.next()) |line| { - if (!first) try writer.writeAll("\r\n"); - first = false; - try writer.writeAll(line); - try writer.writeAll(ansi.line_clear_right); - line_count += 1; - } - - // Clear remaining lines if previous content was taller - if (self.last_line_count > line_count) { - var remaining = self.last_line_count - line_count; - while (remaining > 0) : (remaining -= 1) { - try writer.writeAll("\r\n"); - try writer.writeAll(ansi.line_clear); - } - } - self.last_line_count = line_count; - - // End synchronized output - try writer.writeAll(ansi.sync_end); - - try self.terminal.?.flush(); + const wrote = try self.renderer.render( + self.terminal.?.writer(), + view_output, + .{ .width = self.context.width, .height = self.context.height }, + ); + if (wrote) try self.terminal.?.flush(); + } - // Save hash for comparison - self.last_view_hash = view_hash; - } + /// Repaint the whole frame on the next render, discarding what the + /// renderer believes is on screen. Needed after anything else writes to + /// the terminal. + pub fn invalidate(self: *Self) void { + self.renderer.invalidate(); } /// Send a message to the model diff --git a/src/core/sub_program.zig b/src/core/sub_program.zig index 7a0e1a2..938dbbd 100644 --- a/src/core/sub_program.zig +++ b/src/core/sub_program.zig @@ -73,6 +73,7 @@ pub fn SubProgram(comptime ChildModel: type, comptime ParentMsg: type) type { .exit_alt_screen => .exit_alt_screen, .set_title => |t| .{ .set_title = t }, .println => |l| .{ .println = l }, + .repaint => .repaint, .batch => .none, // Complex: would need recursive translation .sequence => .none, .image_file => |img| .{ .image_file = img }, diff --git a/src/root.zig b/src/root.zig index e9a1fb9..2309339 100644 --- a/src/root.zig +++ b/src/root.zig @@ -85,6 +85,9 @@ pub const lerp = animation.lerp; pub const terminal = @import("terminal/terminal.zig"); pub const Terminal = terminal.Terminal; pub const ansi = terminal.ansi; +pub const frame = terminal.frame; +pub const FrameRenderer = frame.Renderer; +pub const RenderMode = frame.Mode; // Input pub const input = struct { diff --git a/src/terminal/frame.zig b/src/terminal/frame.zig new file mode 100644 index 0000000..6798fe9 --- /dev/null +++ b/src/terminal/frame.zig @@ -0,0 +1,398 @@ +//! Frame rendering: turns the string a `view` returns into terminal output. +//! +//! The straightforward approach — home the cursor and rewrite every line — is +//! robust but costs a screenful of output for a one-character change, which is +//! exactly what an animated spinner asks for ten times a second. `.diff` +//! compares the frame against the one before it and touches only the lines +//! that actually differ. + +const std = @import("std"); +const Writer = std.Io.Writer; +const ansi = @import("ansi.zig"); +const measure = @import("../layout/measure.zig"); + +/// How a new frame is pushed to the terminal. +pub const Mode = enum { + /// Rewrite every line of the frame whenever the view changes. + full, + /// Rewrite only the lines that differ from the previous frame, falling + /// back to `full` whenever the screen cannot be addressed by row. + diff, +}; + +/// Terminal dimensions the frame is rendered against. +pub const Size = struct { + width: u16, + height: u16, +}; + +/// Stateful renderer: owns the previous frame so it can be diffed against. +pub const Renderer = struct { + mode: Mode, + previous: std.array_list.Managed(u8), + last_line_count: usize = 0, + last_hash: u64 = 0, + /// Set when the screen no longer matches `previous` — before the first + /// frame, and after anything that wrote outside the renderer's control + /// (resize, suspend, `println`, an inline image). + dirty: bool = true, + + pub fn init(allocator: std.mem.Allocator, mode: Mode) Renderer { + return .{ + .mode = mode, + .previous = std.array_list.Managed(u8).init(allocator), + }; + } + + pub fn deinit(self: *Renderer) void { + self.previous.deinit(); + } + + /// Repaint the next frame in full, even if the view has not changed. + pub fn invalidate(self: *Renderer) void { + self.dirty = true; + } + + /// Whether the next `render` will produce output regardless of the view. + pub fn needsRepaint(self: *const Renderer) bool { + return self.dirty; + } + + /// Write `view` to `writer`, returning true when anything was emitted. + /// + /// The caller still owns flushing; nothing here writes to the terminal + /// directly. + pub fn render(self: *Renderer, writer: *Writer, view: []const u8, size: Size) !bool { + const hash = std.hash.Wyhash.hash(0, view); + if (!self.dirty and hash == self.last_hash) return false; + + // Only the diff path cares what the frame looks like. + const shape: Shape = if (self.mode == .diff) + scan(view, size) + else + .{ .addressable = true, .styles_closed = true }; + + const use_diff = self.mode == .diff and + !self.dirty and + self.previous.items.len > 0 and + shape.addressable and + shape.styles_closed; + + // Synchronized output keeps terminals that support it from showing a + // half-drawn frame. + try writer.writeAll(ansi.sync_start); + const line_count = if (use_diff) + try self.writeDiff(writer, view, size) + else + try self.writeFull(writer, view); + try writer.writeAll(ansi.sync_end); + + self.remember(view, line_count, hash); + + // A frame that wrapped, scrolled, or left a style open no longer maps + // cleanly onto rows, so the frame after it starts from scratch. + if (!shape.addressable or !shape.styles_closed) self.dirty = true; + + return true; + } + + fn remember(self: *Renderer, view: []const u8, line_count: usize, hash: u64) void { + self.last_line_count = line_count; + self.last_hash = hash; + + self.previous.clearRetainingCapacity(); + self.previous.appendSlice(view) catch { + // Without a copy of what is on screen there is nothing to diff + // against; fall back to a full repaint rather than guessing. + self.previous.clearRetainingCapacity(); + self.dirty = true; + return; + }; + self.dirty = false; + } + + /// Rewrite the whole frame from the top-left corner. + fn writeFull(self: *Renderer, writer: *Writer, view: []const u8) !usize { + try writer.writeAll(ansi.cursor_home); + + var lines = std.mem.splitScalar(u8, view, '\n'); + var first = true; + var line_count: usize = 0; + while (lines.next()) |line| { + if (!first) try writer.writeAll("\r\n"); + first = false; + try writer.writeAll(line); + try writer.writeAll(ansi.line_clear_right); + line_count += 1; + } + + // Clear the rows the previous frame used and this one does not. + if (self.last_line_count > line_count) { + var remaining = self.last_line_count - line_count; + while (remaining > 0) : (remaining -= 1) { + try writer.writeAll("\r\n"); + try writer.writeAll(ansi.line_clear); + } + } + + return line_count; + } + + /// Rewrite only the rows whose content changed. + fn writeDiff(self: *Renderer, writer: *Writer, view: []const u8, size: Size) !usize { + var new_lines = std.mem.splitScalar(u8, view, '\n'); + var old_lines = std.mem.splitScalar(u8, self.previous.items, '\n'); + + var line_count: usize = 0; + var last_line: []const u8 = ""; + while (new_lines.next()) |line| { + const row: u16 = @intCast(line_count); + line_count += 1; + last_line = line; + + // A row the previous frame never reached is blank on screen, so it + // always has to be written. + if (old_lines.next()) |old_line| { + if (std.mem.eql(u8, old_line, line)) continue; + } + + try ansi.cursorTo0(writer, row, 0); + try writer.writeAll(line); + try writer.writeAll(ansi.line_clear_right); + } + + // Clear the rows the previous frame used and this one does not. + var row = line_count; + while (row < self.last_line_count) : (row += 1) { + try ansi.cursorTo0(writer, @intCast(row), 0); + try writer.writeAll(ansi.line_clear); + } + + // Leave the cursor where a full repaint would have left it, so + // anything drawn relative to it — a visible cursor, a `.cursor`-placed + // image — does not depend on which path ran. Clearing trailing rows + // already ends on the last of them, which is where a full repaint + // finishes too. + if (row == line_count) { + const col = @min(measure.width(last_line), size.width); + try ansi.cursorTo0(writer, @intCast(line_count -| 1), @intCast(col)); + } + + return line_count; + } +}; + +/// What a frame looks like on screen, as far as the diff path cares. +const Shape = struct { + /// Every line fits on one row and the frame fits on screen, so row `n` of + /// the frame really is row `n` of the terminal. + addressable: bool, + /// No line leaves a style or hyperlink open. Lines that inherit styling + /// from the line above them cannot be repainted independently. + styles_closed: bool, +}; + +fn scan(view: []const u8, size: Size) Shape { + var addressable = size.width > 0 and size.height > 0; + var styles_closed = true; + + var lines = std.mem.splitScalar(u8, view, '\n'); + var count: usize = 0; + while (lines.next()) |line| { + count += 1; + if (addressable and (count > size.height or measure.width(line) > size.width)) { + addressable = false; + } + if (styles_closed and leavesStyleOpen(line)) styles_closed = false; + if (!addressable and !styles_closed) break; + } + + return .{ .addressable = addressable, .styles_closed = styles_closed }; +} + +/// One bit per aspect of the SGR state a line can leave switched on. +const Attr = struct { + const fg: u16 = 1 << 0; + const bg: u16 = 1 << 1; + const underline_color: u16 = 1 << 2; + const bold_dim: u16 = 1 << 3; + const italic: u16 = 1 << 4; + const underline: u16 = 1 << 5; + const blink: u16 = 1 << 6; + const reverse: u16 = 1 << 7; + const hidden: u16 = 1 << 8; + const strike: u16 = 1 << 9; + /// Anything unrecognised, tracked so it errs towards a full repaint. + const other: u16 = 1 << 10; +}; + +/// Whether `line` ends with an SGR attribute or a hyperlink still in effect. +/// +/// Such a line changes how the lines below it are drawn, so those lines cannot +/// be repainted on their own. +fn leavesStyleOpen(line: []const u8) bool { + var active: u16 = 0; + var link_open = false; + + var i: usize = 0; + while (i < line.len) { + if (line[i] != 0x1b or i + 1 >= line.len) { + i += 1; + continue; + } + + switch (line[i + 1]) { + '[' => { + const params_start = i + 2; + var end = params_start; + while (end < line.len and line[end] >= 0x20 and line[end] <= 0x3f) : (end += 1) {} + if (end >= line.len) break; // truncated sequence + if (line[end] == 'm') active = applySgr(line[params_start..end], active); + i = end + 1; + }, + ']' => { + const payload_start = i + 2; + var end = payload_start; + while (end < line.len and line[end] != 0x07 and line[end] != 0x1b) : (end += 1) {} + + // OSC 8 ; params ; URI opens a hyperlink; an empty URI closes it. + const payload = line[payload_start..end]; + if (std.mem.startsWith(u8, payload, "8;")) { + const uri_start = (std.mem.indexOfScalarPos(u8, payload, 2, ';') orelse + payload.len -| 1) + 1; + link_open = uri_start < payload.len; + } + + i = if (end >= line.len) + line.len + else if (line[end] == 0x07) + end + 1 + else + end + 2; // ST + }, + else => i += 2, + } + } + + return active != 0 or link_open; +} + +/// Fold one `CSI ... m` sequence into the running attribute set. +fn applySgr(params: []const u8, current: u16) u16 { + // `CSI m` with no parameters means `CSI 0m`. + if (params.len == 0) return 0; + + var state = current; + var it = std.mem.splitScalar(u8, params, ';'); + while (it.next()) |param| { + // A colon introduces sub-parameters that refine the code before it. + const colon = std.mem.indexOfScalar(u8, param, ':'); + const head = if (colon) |c| param[0..c] else param; + const code = std.fmt.parseInt(u16, head, 10) catch { + state |= Attr.other; + continue; + }; + + switch (code) { + 0 => state = 0, + 1, 2 => state |= Attr.bold_dim, + 3 => state |= Attr.italic, + 4, 21 => state |= Attr.underline, + 5, 6 => state |= Attr.blink, + 7 => state |= Attr.reverse, + 8 => state |= Attr.hidden, + 9 => state |= Attr.strike, + 22 => state &= ~Attr.bold_dim, + 23 => state &= ~Attr.italic, + 24 => state &= ~Attr.underline, + 25 => state &= ~Attr.blink, + 27 => state &= ~Attr.reverse, + 28 => state &= ~Attr.hidden, + 29 => state &= ~Attr.strike, + 30...37, 90...97 => state |= Attr.fg, + 39 => state &= ~Attr.fg, + 40...47, 100...107 => state |= Attr.bg, + 49 => state &= ~Attr.bg, + 59 => state &= ~Attr.underline_color, + 38, 48, 58 => { + state |= switch (code) { + 38 => Attr.fg, + 48 => Attr.bg, + else => Attr.underline_color, + }; + // The colon form packs its arguments into this parameter; the + // semicolon form spreads them across the ones that follow, and + // those must not be read as codes of their own. + if (colon != null) continue; + const kind = it.next() orelse break; + const arg_count: usize = if (std.mem.eql(u8, kind, "2")) + 3 // r;g;b + else if (std.mem.eql(u8, kind, "5")) + 1 // palette index + else + 0; + for (0..arg_count) |_| { + _ = it.next() orelse break; + } + }, + else => state |= Attr.other, + } + } + + return state; +} + +test "leavesStyleOpen: plain text closes nothing" { + try std.testing.expect(!leavesStyleOpen("")); + try std.testing.expect(!leavesStyleOpen("just text")); +} + +test "leavesStyleOpen: a reset closes the line" { + try std.testing.expect(!leavesStyleOpen("\x1b[31mred\x1b[0m")); + try std.testing.expect(!leavesStyleOpen("\x1b[1;4;31mfancy\x1b[m tail")); + try std.testing.expect(!leavesStyleOpen("\x1b[38;2;255;0;0mred\x1b[0m")); +} + +test "leavesStyleOpen: an unclosed attribute leaves the line open" { + try std.testing.expect(leavesStyleOpen("\x1b[31mred")); + try std.testing.expect(leavesStyleOpen("\x1b[41mbackground")); + try std.testing.expect(leavesStyleOpen("\x1b[1mbold\x1b[0m\x1b[4munderline")); +} + +test "leavesStyleOpen: extended colour arguments are not read as codes" { + // The trailing `0` parameters here belong to the colour, not to SGR 0. + try std.testing.expect(leavesStyleOpen("\x1b[38;2;255;0;0mred")); + try std.testing.expect(leavesStyleOpen("\x1b[48;5;0mblack background")); + try std.testing.expect(leavesStyleOpen("\x1b[38:2::255:0:0mcolon form")); +} + +test "leavesStyleOpen: attributes turned off individually" { + try std.testing.expect(!leavesStyleOpen("\x1b[1mbold\x1b[22m")); + try std.testing.expect(!leavesStyleOpen("\x1b[31mred\x1b[39m")); + try std.testing.expect(!leavesStyleOpen("\x1b[41mbg\x1b[49m")); + try std.testing.expect(leavesStyleOpen("\x1b[1;31mboth\x1b[22m")); +} + +test "leavesStyleOpen: non-SGR sequences are ignored" { + try std.testing.expect(!leavesStyleOpen("\x1b[2Ktext")); + try std.testing.expect(!leavesStyleOpen("\x1b[10;5Htext")); +} + +test "leavesStyleOpen: hyperlinks" { + try std.testing.expect(leavesStyleOpen("\x1b]8;;https://example.com\x07link text")); + try std.testing.expect(!leavesStyleOpen("\x1b]8;;https://example.com\x07link\x1b]8;;\x07")); + try std.testing.expect(!leavesStyleOpen("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\")); +} + +test "scan: frame geometry" { + const size = Size{ .width = 10, .height = 3 }; + + try std.testing.expect(scan("abc\ndef", size).addressable); + try std.testing.expect(!scan("abc\ndef\nghi\njkl", size).addressable); + try std.testing.expect(!scan("this line is too wide", size).addressable); + // Escape sequences do not count towards the visible width. + try std.testing.expect(scan("\x1b[31mabc\x1b[0m", size).addressable); + // Double-width characters do. + try std.testing.expect(scan("日本語だ", size).addressable); + try std.testing.expect(!scan("日本語だよね", size).addressable); +} diff --git a/src/terminal/terminal.zig b/src/terminal/terminal.zig index 428fc88..b32263a 100644 --- a/src/terminal/terminal.zig +++ b/src/terminal/terminal.zig @@ -5,6 +5,7 @@ const std = @import("std"); const FixedWriter = std.Io.Writer.fixed; const builtin = @import("builtin"); pub const ansi = @import("ansi.zig"); +pub const frame = @import("frame.zig"); const unicode = @import("../unicode.zig"); const Environment = @import("../core/environment.zig").Environment; diff --git a/tests/render_tests.zig b/tests/render_tests.zig new file mode 100644 index 0000000..bd066d2 --- /dev/null +++ b/tests/render_tests.zig @@ -0,0 +1,435 @@ +//! Frame renderer tests. +//! +//! The important property is not which bytes come out but what the terminal +//! ends up showing: `VirtualScreen` replays the renderer's output so a diffed +//! frame can be compared against the same frame painted in full. + +const std = @import("std"); +const testing = std.testing; +const zz = @import("zigzag"); + +const Renderer = zz.FrameRenderer; +const Size = zz.frame.Size; + +const size_80x24 = Size{ .width = 80, .height = 24 }; + +/// Minimal terminal model: enough of the control sequences the renderer emits +/// to reconstruct what would be on screen. +const VirtualScreen = struct { + rows: [64][256]u8, + used: usize, + cursor_row: usize = 0, + cursor_col: usize = 0, + + fn init() VirtualScreen { + var self = VirtualScreen{ .rows = undefined, .used = 0 }; + for (&self.rows) |*row| @memset(row, ' '); + return self; + } + + fn apply(self: *VirtualScreen, output: []const u8) !void { + var i: usize = 0; + while (i < output.len) { + const c = output[i]; + + if (c == 0x1b) { + i += try self.applyEscape(output[i..]); + continue; + } + + if (c == '\r') { + self.cursor_col = 0; + i += 1; + continue; + } + if (c == '\n') { + self.cursor_row += 1; + i += 1; + continue; + } + + try testing.expect(self.cursor_row < self.rows.len); + try testing.expect(self.cursor_col < self.rows[0].len); + self.rows[self.cursor_row][self.cursor_col] = c; + self.cursor_col += 1; + self.used = @max(self.used, self.cursor_row + 1); + i += 1; + } + } + + /// Returns how many bytes the sequence at the front of `data` occupies. + fn applyEscape(self: *VirtualScreen, data: []const u8) !usize { + if (data.len < 2 or data[1] != '[') return 1; + + var end: usize = 2; + while (end < data.len and (data[end] < 0x40 or data[end] > 0x7e)) : (end += 1) {} + if (end >= data.len) return data.len; + + const params = data[2..end]; + const final = data[end]; + const consumed = end + 1; + + switch (final) { + 'H' => { + if (params.len == 0) { + self.cursor_row = 0; + self.cursor_col = 0; + } else { + var it = std.mem.splitScalar(u8, params, ';'); + const row = try std.fmt.parseInt(usize, it.next().?, 10); + const col = try std.fmt.parseInt(usize, it.next() orelse "1", 10); + self.cursor_row = row - 1; + self.cursor_col = col - 1; + } + self.used = @max(self.used, self.cursor_row + 1); + }, + 'K' => { + const from = if (std.mem.eql(u8, params, "2")) 0 else self.cursor_col; + @memset(self.rows[self.cursor_row][from..], ' '); + self.used = @max(self.used, self.cursor_row + 1); + }, + // Style and synchronized-output sequences do not move the cursor + // or change cell contents. + else => {}, + } + + return consumed; + } + + /// Visible content, trailing blanks trimmed, as a newline-joined string. + fn text(self: *const VirtualScreen, out: *std.array_list.Managed(u8)) !void { + out.clearRetainingCapacity(); + + var last: usize = 0; + for (0..self.used) |row| { + if (std.mem.trimEnd(u8, &self.rows[row], " ").len > 0) last = row + 1; + } + + for (0..last) |row| { + if (row > 0) try out.append('\n'); + try out.appendSlice(std.mem.trimEnd(u8, &self.rows[row], " ")); + } + } +}; + +/// Drives a renderer and keeps a virtual screen in sync with its output. +const Harness = struct { + renderer: Renderer, + screen: VirtualScreen, + out: std.Io.Writer.Allocating, + + fn init(mode: zz.RenderMode) Harness { + return .{ + .renderer = Renderer.init(testing.allocator, mode), + .screen = VirtualScreen.init(), + .out = std.Io.Writer.Allocating.init(testing.allocator), + }; + } + + fn deinit(self: *Harness) void { + self.renderer.deinit(); + self.out.deinit(); + } + + /// Renders one frame; returns the bytes it produced. + fn render(self: *Harness, view: []const u8, size: Size) ![]const u8 { + self.out.clearRetainingCapacity(); + _ = try self.renderer.render(&self.out.writer, view, size); + const written = self.out.written(); + try self.screen.apply(written); + return written; + } + + fn expectScreen(self: *const Harness, expected: []const u8) !void { + var buf = std.array_list.Managed(u8).init(testing.allocator); + defer buf.deinit(); + try self.screen.text(&buf); + try testing.expectEqualStrings(expected, buf.items); + } +}; + +fn countOccurrences(haystack: []const u8, needle: []const u8) usize { + var count: usize = 0; + var i: usize = 0; + while (std.mem.indexOfPos(u8, haystack, i, needle)) |found| { + count += 1; + i = found + needle.len; + } + return count; +} + +test "unchanged view produces no output" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("hello\nworld", size_80x24); + const second = try h.render("hello\nworld", size_80x24); + + try testing.expectEqual(@as(usize, 0), second.len); +} + +test "first frame is painted in full" { + var h = Harness.init(.diff); + defer h.deinit(); + + const out = try h.render("alpha\nbeta\ngamma", size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try h.expectScreen("alpha\nbeta\ngamma"); +} + +test "diff rewrites only the changed line" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("alpha\nbeta\ngamma", size_80x24); + const out = try h.render("alpha\nBETA\ngamma", size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "BETA") != null); + try testing.expect(std.mem.indexOf(u8, out, "alpha") == null); + try testing.expect(std.mem.indexOf(u8, out, "gamma") == null); + // Row 2 of the terminal, one-indexed. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[2;1H") != null); + + try h.expectScreen("alpha\nBETA\ngamma"); +} + +test "a spinner frame costs one line of output, not a screenful" { + var h = Harness.init(.diff); + defer h.deinit(); + + var body = std.array_list.Managed(u8).init(testing.allocator); + defer body.deinit(); + for (0..23) |i| { + var buf: [64]u8 = undefined; + try body.appendSlice(try std.fmt.bufPrint(&buf, "line {d} of streaming output\n", .{i})); + } + + const frames = [_][]const u8{ "|", "/", "-", "\\" }; + var previous: []const u8 = undefined; + + for (frames, 0..) |spinner, i| { + var view = std.array_list.Managed(u8).init(testing.allocator); + defer view.deinit(); + try view.appendSlice(body.items); + try view.appendSlice(spinner); + + const out = try h.render(view.items, size_80x24); + if (i == 0) { + previous = ""; + continue; + } + // Only the spinner row is touched: nowhere near the ~700 bytes the + // body would cost. + try testing.expect(out.len < 64); + try testing.expect(std.mem.indexOf(u8, out, "streaming output") == null); + } +} + +test "shrinking frame clears the rows it gave up" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("one\ntwo\nthree\nfour", size_80x24); + _ = try h.render("one\ntwo", size_80x24); + + try h.expectScreen("one\ntwo"); +} + +test "growing frame writes the rows it gained" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("one\ntwo", size_80x24); + _ = try h.render("one\ntwo\nthree\nfour", size_80x24); + + try h.expectScreen("one\ntwo\nthree\nfour"); +} + +test "diff and full renderers leave the same screen" { + const script = [_][]const u8{ + "alpha\nbeta\ngamma", + "alpha\nBETA\ngamma", + "alpha\nBETA\ngamma\ndelta", + "alpha", + "alpha\n\n\nomega", + "", + "one\ntwo\nthree", + "one\ntwo\nthree", + "\x1b[31mred\x1b[0m\nplain", + "\x1b[31mred\x1b[0m\nPLAIN", + "one\ntwo\nthree\nfour\nfive", + "one\nX\nthree\nY\nfive", + }; + + var diff_h = Harness.init(.diff); + defer diff_h.deinit(); + var full_h = Harness.init(.full); + defer full_h.deinit(); + + for (script) |view| { + _ = try diff_h.render(view, size_80x24); + _ = try full_h.render(view, size_80x24); + + var diff_text = std.array_list.Managed(u8).init(testing.allocator); + defer diff_text.deinit(); + var full_text = std.array_list.Managed(u8).init(testing.allocator); + defer full_text.deinit(); + try diff_h.screen.text(&diff_text); + try full_h.screen.text(&full_text); + + try testing.expectEqualStrings(full_text.items, diff_text.items); + } +} + +test "diff and full leave the cursor in the same place" { + const script = [_][]const u8{ + "alpha\nbeta", + "alpha\nbeta gamma", + "alpha", + "alpha\nbeta\ngamma", + }; + + var diff_h = Harness.init(.diff); + defer diff_h.deinit(); + var full_h = Harness.init(.full); + defer full_h.deinit(); + + for (script) |view| { + _ = try diff_h.render(view, size_80x24); + _ = try full_h.render(view, size_80x24); + + try testing.expectEqual(full_h.screen.cursor_row, diff_h.screen.cursor_row); + try testing.expectEqual(full_h.screen.cursor_col, diff_h.screen.cursor_col); + } +} + +test "a line wider than the terminal falls back to a full repaint" { + var h = Harness.init(.diff); + defer h.deinit(); + + const narrow = Size{ .width = 10, .height = 24 }; + _ = try h.render("short\nalso short", narrow); + + const out = try h.render("short\nthis line is far too wide to fit", narrow); + + // Wrapping shifts every row below it, so absolute addressing is off the + // table: the whole frame is rewritten from home. + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "short") != null); +} + +test "a frame taller than the terminal falls back to a full repaint" { + var h = Harness.init(.diff); + defer h.deinit(); + + const short = Size{ .width = 80, .height = 3 }; + _ = try h.render("a\nb\nc", short); + + const out = try h.render("a\nb\nc\nd", short); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + + // The frame scrolled, so the one after it cannot be diffed either. + const next = try h.render("a\nb\nc\ne", short); + try testing.expect(std.mem.indexOf(u8, next, "\x1b[H") != null); +} + +test "a style left open disables diffing for the following frame" { + var h = Harness.init(.diff); + defer h.deinit(); + + // The background colour on the first line bleeds onto the second, so the + // second cannot be repainted on its own. + _ = try h.render("\x1b[41mred background\nstill red", size_80x24); + const out = try h.render("\x1b[41mred background\nSTILL RED", size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "red background") != null); +} + +test "styles closed per line still diff" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("\x1b[31mred\x1b[0m\n\x1b[1mbold\x1b[0m plain", size_80x24); + const out = try h.render("\x1b[31mred\x1b[0m\n\x1b[1mbold\x1b[0m PLAIN", size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") == null); + try testing.expect(std.mem.indexOf(u8, out, "PLAIN") != null); +} + +test "truecolor sequences do not read as a reset" { + var h = Harness.init(.diff); + defer h.deinit(); + + // `38;2;r;g;b` contains a literal 0 parameter; treating it as SGR 0 would + // wrongly mark the line closed and diff a frame that bleeds colour. + _ = try h.render("\x1b[38;2;255;0;0mred\nsecond", size_80x24); + const out = try h.render("\x1b[38;2;255;0;0mred\nSECOND", size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); +} + +test "invalidate forces a repaint of an unchanged view" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("stable\nframe", size_80x24); + try testing.expectEqual(@as(usize, 0), (try h.render("stable\nframe", size_80x24)).len); + + h.renderer.invalidate(); + try testing.expect(h.renderer.needsRepaint()); + + const out = try h.render("stable\nframe", size_80x24); + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(!h.renderer.needsRepaint()); +} + +test "full mode always rewrites every line" { + var h = Harness.init(.full); + defer h.deinit(); + + _ = try h.render("alpha\nbeta\ngamma", size_80x24); + const out = try h.render("alpha\nBETA\ngamma", size_80x24); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") != null); + try testing.expect(std.mem.indexOf(u8, out, "alpha") != null); + try testing.expect(std.mem.indexOf(u8, out, "gamma") != null); +} + +test "every frame is wrapped in synchronized output" { + var h = Harness.init(.diff); + defer h.deinit(); + + const first = try h.render("a\nb", size_80x24); + try testing.expectEqual(@as(usize, 1), countOccurrences(first, "\x1b[?2026h")); + try testing.expectEqual(@as(usize, 1), countOccurrences(first, "\x1b[?2026l")); + + const second = try h.render("a\nc", size_80x24); + try testing.expectEqual(@as(usize, 1), countOccurrences(second, "\x1b[?2026h")); + try testing.expectEqual(@as(usize, 1), countOccurrences(second, "\x1b[?2026l")); +} + +test "wide characters are measured by display width, not bytes" { + var h = Harness.init(.diff); + defer h.deinit(); + + // Four double-width characters occupy eight columns: they fit in ten, and + // the frame stays diffable. + const narrow = Size{ .width = 10, .height = 4 }; + _ = try h.render("日本語だ\nplain", narrow); + const out = try h.render("日本語だ\nPLAIN", narrow); + + try testing.expect(std.mem.indexOf(u8, out, "\x1b[H") == null); +} + +test "empty view is handled" { + var h = Harness.init(.diff); + defer h.deinit(); + + _ = try h.render("", size_80x24); + _ = try h.render("content", size_80x24); + _ = try h.render("", size_80x24); + + try h.expectScreen(""); +}