Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254const std = @import("std");const View = @import("root.zig").View;const Allocator = std.mem.Allocator;const Renderer = @This();const Writer = std.Io.Writer;const ArrayList = std.ArrayList;
allocator: Allocator,writer: *Writer,/// Snapshot of last frame, as raw bytes (lines separated by '\n').prev: ArrayList(u8) = .empty,/// Number of lines the previous frame occupied (0 on first render).prev_lines: usize = 0,prev_view: View = .{},prev_title: ArrayList(u8) = .empty,first_frame: bool = true,
pub fn init(allocator: Allocator, writer: *Writer) Renderer { return .{ .allocator = allocator, .writer = writer };}
pub fn deinit(self: *Renderer) void { self.prev.deinit(self.allocator); self.prev_title.deinit(self.allocator);}
pub fn shutdown(self: *Renderer) !void { if (self.prev_view.cursor == null) try self.writer.writeAll("\x1b[?25h"); try self.disableMouse(self.prev_view.mouse); if (self.prev_view.bracketed_paste) try self.writer.writeAll("\x1b[?2004l"); if (self.prev_view.focus_reporting) try self.writer.writeAll("\x1b[?1004l"); // Alt-screen apps exit the alt buffer (which discards the rendered frame // anyway). Inline apps leave the final frame on screen — bubbletea // convention is that the model's last `View()` is the post-quit message // — and rely on `Program.teardown` to advance one line below it. if (self.prev_view.alt_screen) try self.writer.writeAll("\x1b[?1049l"); try self.writer.flush();}
fn mouseSetCode(mode: View.Mouse) ?[]const u8 { return switch (mode) { .off => null, .normal => "\x1b[?1000h\x1b[?1006h", .button_motion => "\x1b[?1002h\x1b[?1006h", .all_motion => "\x1b[?1003h\x1b[?1006h", };}
fn disableMouse(self: *Renderer, mode: View.Mouse) !void { switch (mode) { .off => {}, .normal => try self.writer.writeAll("\x1b[?1000l\x1b[?1006l"), .button_motion => try self.writer.writeAll("\x1b[?1002l\x1b[?1006l"), .all_motion => try self.writer.writeAll("\x1b[?1003l\x1b[?1006l"), }}
fn eraseInline(self: *Renderer) !void { if (self.prev_lines == 0) return; if (self.prev_lines > 1) try self.cursorUp(self.prev_lines - 1); try self.writer.writeAll("\r"); var i: usize = 0; while (i < self.prev_lines) : (i += 1) { try self.writer.writeAll("\x1b[K"); if (i + 1 < self.prev_lines) try self.writer.writeAll("\n"); } if (self.prev_lines > 1) try self.cursorUp(self.prev_lines - 1); try self.writer.writeAll("\r"); self.prev.clearRetainingCapacity(); self.prev_lines = 0;}
/// Render `frame` along with `view` metadata.pub fn render(self: *Renderer, frame: []const u8, view: View) !void { const alt_changed = view.alt_screen != self.prev_view.alt_screen; if (alt_changed) { if (view.alt_screen) try self.writer.writeAll("\x1b[?1049h\x1b[H") else try self.writer.writeAll("\x1b[?1049l"); // Whichever way it changed, the previous frame is now invisible. self.prev.clearRetainingCapacity(); self.prev_lines = 0; }
if (view.mouse != self.prev_view.mouse) { try self.disableMouse(self.prev_view.mouse); if (mouseSetCode(view.mouse)) |seq| try self.writer.writeAll(seq); }
if (view.bracketed_paste != self.prev_view.bracketed_paste) { try self.writer.writeAll(if (view.bracketed_paste) "\x1b[?2004h" else "\x1b[?2004l"); }
if (view.focus_reporting != self.prev_view.focus_reporting) { try self.writer.writeAll(if (view.focus_reporting) "\x1b[?1004h" else "\x1b[?1004l"); }
try self.writer.writeAll("\x1b[?25l");
if (self.first_frame or alt_changed) try self.paintAll(frame) else try self.paintDiff(frame);
if (view.window_title) |title| { const changed = self.first_frame or self.prev_view.window_title == null or !std.mem.eql(u8, self.prev_title.items, title); if (changed) { try self.writer.print("\x1b]2;{s}\x1b\\", .{title}); self.prev_title.clearRetainingCapacity(); try self.prev_title.appendSlice(self.allocator, title); } }
if (view.cursor) |cursor| { const shape_code: u8 = switch (cursor.shape) { .block => if (cursor.blink) 1 else 2, .underline => if (cursor.blink) 3 else 4, .bar => if (cursor.blink) 5 else 6, }; try self.writer.print("\x1b[{d} q", .{shape_code}); try self.writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); try self.writer.writeAll("\x1b[?25h"); }
try self.writer.flush(); self.prev.clearRetainingCapacity(); try self.prev.appendSlice(self.allocator, frame); self.prev_lines = countLines(frame); self.prev_view = view; self.first_frame = false;}
fn paintAll(self: *Renderer, frame: []const u8) !void { try self.writer.writeAll("\r"); var iter: LineIter = .init(frame); var first = true; while (iter.next()) |line| { if (!first) try self.writer.writeAll("\r\n"); try self.writer.writeAll(line); try self.writer.writeAll("\x1b[K"); first = false; } try self.writer.writeAll("\r");}
fn paintDiff(self: *Renderer, frame: []const u8) !void { if (self.prev_lines > 1) try self.cursorUp(self.prev_lines - 1); try self.writer.writeAll("\r");
var new_iter: LineIter = .init(frame); var old_iter: LineIter = .init(self.prev.items);
var new_line = new_iter.next(); var old_line = old_iter.next(); var emitted: usize = 0; var new_emitted: usize = 0; while (new_line != null or old_line != null) : ({ new_line = new_iter.next(); old_line = old_iter.next(); }) { if (emitted > 0) try self.writer.writeAll("\r\n");
if (new_line) |nl| { const changed = old_line == null or !std.mem.eql(u8, old_line.?, nl); if (changed) { try self.writer.writeAll(nl); try self.writer.writeAll("\x1b[K"); } new_emitted += 1; } else try self.writer.writeAll("\x1b[K"); emitted += 1; } try self.writer.writeAll("\r");
// If the new frame is shorter, the cursor sits below its last line. Move // back up so subsequent rendering (and `prev_lines` bookkeeping) treat // the cursor as resting on the new frame's final line. if (emitted > new_emitted and new_emitted > 0) try self.cursorUp(emitted - new_emitted);}
fn cursorUp(self: *Renderer, lines: usize) !void { if (lines == 0) return; try self.writer.print("\x1b[{d}A", .{lines});}
fn countLines(string: []const u8) usize { if (string.len == 0) return 0; const newlines = std.mem.count(u8, string, "\n"); return newlines + 1 - @intFromBool(string[string.len - 1] == '\n');}
const LineIter = struct { data: []const u8, pos: usize = 0, done: bool = false,
fn init(data: []const u8) LineIter { return .{ .data = data }; }
fn next(self: *LineIter) ?[]const u8 { if (self.done) return null; if (self.pos >= self.data.len) { self.done = true; return null; } const start = self.pos; while (self.pos < self.data.len and self.data[self.pos] != '\n') : (self.pos += 1) {} const end = self.pos; if (self.pos < self.data.len) self.pos += 1; return self.data[start..end]; }};
test "countLines" { try std.testing.expectEqual(@as(usize, 0), countLines("")); try std.testing.expectEqual(@as(usize, 1), countLines("a")); try std.testing.expectEqual(@as(usize, 2), countLines("a\nb")); try std.testing.expectEqual(@as(usize, 2), countLines("a\nb\n"));}
test "LineIter" { var it: LineIter = .init("a\nbb\nccc"); try std.testing.expectEqualStrings("a", it.next().?); try std.testing.expectEqualStrings("bb", it.next().?); try std.testing.expectEqualStrings("ccc", it.next().?); try std.testing.expectEqual(@as(?[]const u8, null), it.next());}