Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -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()`.
Expand Down
1 change: 1 addition & 0 deletions build.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/core/command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down
9 changes: 9 additions & 0 deletions src/core/context.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
};
82 changes: 34 additions & 48 deletions src/core/program.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 = .{},
Expand All @@ -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
Expand Down Expand Up @@ -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 = .{
Expand Down Expand Up @@ -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")) {
Expand Down Expand Up @@ -579,13 +584,19 @@ 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| {
const writer = term.writer();
try writer.writeAll(ansi.alt_screen_exit);
try term.flush();
}
self.renderer.invalidate();
},
.repaint => {
self.renderer.invalidate();
},
.set_title => |title| {
if (self.terminal) |*term| {
Expand All @@ -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 };
Expand Down Expand Up @@ -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();
}
}

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions src/core/sub_program.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
3 changes: 3 additions & 0 deletions src/root.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading