From a0db41f87c04c41817fca26b6c596ea480811c79 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 2 May 2024 12:50:33 -0500 Subject: [PATCH] render: add cli renderer Add a renderer for applications running on the primary screen. This renderer uses relative cursor positioning, and works with all the same primitives as the alternate screen. Fix a bug where repositioning was never turned back to false. This was a nasty one with huge perf implications. Set internal cells to a space and default on init. This prevents us from writing them. As a result, we now issue a hardware clear on resize. --- build.zig | 1 + examples/cli.zig | 106 +++++++++++++++++++++++++++++++++++++++++ src/InternalScreen.zig | 3 +- src/Tty.zig | 4 ++ src/Vaxis.zig | 106 +++++++++++++++++++++++++++++++++-------- src/ctlseqs.zig | 7 +++ 6 files changed, 207 insertions(+), 20 deletions(-) create mode 100644 examples/cli.zig diff --git a/build.zig b/build.zig index fa7e0bf..d538780 100644 --- a/build.zig +++ b/build.zig @@ -38,6 +38,7 @@ pub fn build(b: *std.Build) void { // Examples const Example = enum { + cli, image, main, nvim, diff --git a/examples/cli.zig b/examples/cli.zig new file mode 100644 index 0000000..c149acb --- /dev/null +++ b/examples/cli.zig @@ -0,0 +1,106 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const Cell = vaxis.Cell; +const TextInput = vaxis.widgets.TextInput; + +const log = std.log.scoped(.main); +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer { + const deinit_status = gpa.deinit(); + if (deinit_status == .leak) { + log.err("memory leak", .{}); + } + } + const alloc = gpa.allocator(); + + var vx = try vaxis.init(alloc, .{}); + defer vx.deinit(alloc); + + var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; + + try loop.run(); + defer loop.stop(); + + try vx.queryTerminal(); + + var text_input = TextInput.init(alloc, &vx.unicode); + defer text_input.deinit(); + + var selected_option: ?usize = null; + + const options = [_][]const u8{ + "option 1", + "option 2", + "option 3", + }; + + // The main event loop. Vaxis provides a thread safe, blocking, buffered + // queue which can serve as the primary event queue for an application + while (true) { + // nextEvent blocks until an event is in the queue + const event = loop.nextEvent(); + // exhaustive switching ftw. Vaxis will send events if your Event + // enum has the fields for those events (ie "key_press", "winsize") + switch (event) { + .key_press => |key| { + if (key.codepoint == 'c' and key.mods.ctrl) { + break; + } else if (key.matches(vaxis.Key.tab, .{})) { + if (selected_option == null) { + selected_option = 0; + } else { + selected_option.? = @min(options.len - 1, selected_option.? + 1); + } + } else if (key.matches(vaxis.Key.tab, .{ .shift = true })) { + if (selected_option == null) { + selected_option = 0; + } else { + selected_option.? = selected_option.? -| 1; + } + } else if (key.matches(vaxis.Key.enter, .{})) { + if (selected_option) |i| { + log.err("enter", .{}); + try text_input.insertSliceAtCursor(options[i]); + selected_option = null; + } + } else { + if (selected_option == null) + try text_input.update(.{ .key_press = key }); + } + }, + .winsize => |ws| { + try vx.resize(alloc, ws); + }, + else => {}, + } + + const win = vx.window(); + win.clear(); + + text_input.draw(win); + + if (selected_option) |i| { + win.hideCursor(); + for (options, 0..) |opt, j| { + log.err("i = {d}, j = {d}, opt = {s}", .{ i, j, opt }); + var seg = [_]vaxis.Segment{.{ + .text = opt, + .style = if (j == i) .{ .reverse = true } else .{}, + }}; + _ = try win.print(&seg, .{ .row_offset = j + 1 }); + } + } + try vx.render(); + } +} + +// Our Event. This can contain internal events as well as Vaxis events. +// Internal events can be posted into the same queue as vaxis events to allow +// for a single event loop with exhaustive switching. Booya +const Event = union(enum) { + key_press: vaxis.Key, + winsize: vaxis.Winsize, + focus_in, + foo: u8, +}; diff --git a/src/InternalScreen.zig b/src/InternalScreen.zig index bb8b8e9..f77749d 100644 --- a/src/InternalScreen.zig +++ b/src/InternalScreen.zig @@ -16,7 +16,7 @@ pub const InternalCell = struct { uri_id: std.ArrayList(u8) = undefined, // if we got skipped because of a wide character skipped: bool = false, - default: bool = false, + default: bool = true, pub fn eql(self: InternalCell, cell: Cell) bool { // fastpath when both cells are default @@ -55,6 +55,7 @@ pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !InternalScreen { .uri = std.ArrayList(u8).init(alloc), .uri_id = std.ArrayList(u8).init(alloc), }; + try screen.buf[i].char.append(' '); } screen.width = w; screen.height = h; diff --git a/src/Tty.zig b/src/Tty.zig index b38e228..fdb2536 100644 --- a/src/Tty.zig +++ b/src/Tty.zig @@ -32,6 +32,10 @@ state: struct { kitty_keyboard: bool = false, bracketed_paste: bool = false, mouse: bool = false, + cursor: struct { + row: usize = 0, + col: usize = 0, + } = .{}, } = .{}, /// initializes a Tty instance by opening /dev/tty and "making it raw" diff --git a/src/Vaxis.zig b/src/Vaxis.zig index fb9b2c6..6b372d0 100644 --- a/src/Vaxis.zig +++ b/src/Vaxis.zig @@ -94,7 +94,8 @@ pub fn deinit(self: *Vaxis, alloc: ?std.mem.Allocator) void { /// resize allocates a slice of cells equal to the number of cells /// required to display the screen (ie width x height). Any previous screen is -/// freed when resizing +/// freed when resizing. The cursor will be sent to it's home position and a +/// hardware clear-below-cursor will be sent pub fn resize(self: *Vaxis, alloc: std.mem.Allocator, winsize: Winsize) !void { log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); self.screen.deinit(alloc); @@ -105,7 +106,18 @@ pub fn resize(self: *Vaxis, alloc: std.mem.Allocator, winsize: Winsize) !void { // every cell self.screen_last.deinit(alloc); self.screen_last = try InternalScreen.init(alloc, winsize.cols, winsize.rows); - // try self.screen_last.resize(alloc, winsize.cols, winsize.rows); + var tty = self.tty orelse return; + if (tty.state.alt_screen) + _ = try tty.write(ctlseqs.home) + else { + _ = try tty.buffered_writer.write("\r"); + var i: usize = 0; + while (i < tty.state.cursor.row) : (i += 1) { + _ = try tty.buffered_writer.write(ctlseqs.ri); + } + } + _ = try tty.write(ctlseqs.erase_below_cursor); + try tty.flush(); } /// returns a Window comprising of the entire terminal screen @@ -216,7 +228,15 @@ pub fn render(self: *Vaxis) !void { // this if we have an update to make. We also need to hide cursor // and then reshow it if needed _ = try tty.write(ctlseqs.hide_cursor); - _ = try tty.write(ctlseqs.home); + if (tty.state.alt_screen) + _ = try tty.write(ctlseqs.home) + else { + _ = try tty.write("\r"); + var i: usize = 0; + while (i < tty.state.cursor.row) : (i += 1) { + _ = try tty.write(ctlseqs.ri); + } + } _ = try tty.write(ctlseqs.sgr_reset); // initialize some variables @@ -225,6 +245,10 @@ pub fn render(self: *Vaxis) !void { var col: usize = 0; var cursor: Style = .{}; var link: Hyperlink = .{}; + var cursor_pos: struct { + row: usize = 0, + col: usize = 0, + } = .{}; // Clear all images _ = try tty.write(ctlseqs.kitty_graphics_clear); @@ -232,15 +256,15 @@ pub fn render(self: *Vaxis) !void { var i: usize = 0; while (i < self.screen.buf.len) { const cell = self.screen.buf[i]; + const w = blk: { + if (cell.char.width != 0) break :blk cell.char.width; + + const method: gwidth.Method = self.caps.unicode; + const width = gwidth.gwidth(cell.char.grapheme, method, &self.unicode.width_data) catch 1; + break :blk @max(1, width); + }; defer { // advance by the width of this char mod 1 - const w = blk: { - if (cell.char.width != 0) break :blk cell.char.width; - - const method: gwidth.Method = self.caps.unicode; - const width = gwidth.gwidth(cell.char.grapheme, method, &self.unicode.width_data) catch 1; - break :blk @max(1, width); - }; std.debug.assert(w > 0); var j = i + 1; while (j < i + w) : (j += 1) { @@ -277,7 +301,24 @@ pub fn render(self: *Vaxis) !void { // reposition the cursor, if needed if (reposition) { - try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 }); + reposition = false; + if (tty.state.alt_screen) + try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 }) + else { + if (cursor_pos.row == row) { + const n = col - cursor_pos.col; + try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cuf, .{n}); + } else { + const n = row - cursor_pos.row; + var _i: usize = 0; + _ = try tty.buffered_writer.write("\r"); + while (_i < n) : (_i += 1) { + _ = try tty.buffered_writer.write("\n"); + } + if (col > 0) + try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cuf, .{col}); + } + } } if (cell.image) |img| { @@ -433,17 +474,43 @@ pub fn render(self: *Vaxis) !void { try std.fmt.format(writer, ctlseqs.osc8, .{ ps, cell.link.uri }); } _ = try tty.write(cell.char.grapheme); + cursor_pos.col = col + w; + cursor_pos.row = row; + log.debug("cursor: row: {}, col: {}, grapheme: '{s}'", .{ cursor_pos.row, cursor_pos.col, cell.char.grapheme }); } if (self.screen.cursor_vis) { - try std.fmt.format( - tty.buffered_writer.writer(), - ctlseqs.cup, - .{ - self.screen.cursor_row + 1, - self.screen.cursor_col + 1, - }, - ); + if (tty.state.alt_screen) { + try std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.cup, + .{ + self.screen.cursor_row + 1, + self.screen.cursor_col + 1, + }, + ); + } else { + // TODO: position cursor relative to current location + _ = try tty.write("\r"); + var r: usize = 0; + if (self.screen.cursor_row >= cursor_pos.row) { + while (r < (self.screen.cursor_row - cursor_pos.row)) : (r += 1) { + _ = try tty.write("\n"); + } + } else { + while (r < (cursor_pos.row - self.screen.cursor_row)) : (r += 1) { + _ = try tty.write(ctlseqs.ri); + } + } + if (self.screen.cursor_col > 0) { + try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cuf, .{self.screen.cursor_col}); + } + } + self.tty.?.state.cursor.row = self.screen.cursor_row; + self.tty.?.state.cursor.col = self.screen.cursor_col; _ = try tty.write(ctlseqs.show_cursor); + } else { + self.tty.?.state.cursor.row = cursor_pos.row; + self.tty.?.state.cursor.col = cursor_pos.col; } if (self.screen.mouse_shape != self.screen_last.mouse_shape) { try std.fmt.format( @@ -461,6 +528,7 @@ pub fn render(self: *Vaxis) !void { ); self.screen_last.cursor_shape = self.screen.cursor_shape; } + log.debug("tty_cursor: row: {}, col: {}", .{ self.tty.?.state.cursor.row, self.tty.?.state.cursor.col }); } fn enableKittyKeyboard(self: *Vaxis, flags: Key.KittyFlags) !void { diff --git a/src/ctlseqs.zig b/src/ctlseqs.zig index b496d44..75bd1b6 100644 --- a/src/ctlseqs.zig +++ b/src/ctlseqs.zig @@ -37,6 +37,13 @@ pub const cup = "\x1b[{d};{d}H"; pub const hide_cursor = "\x1b[?25l"; pub const show_cursor = "\x1b[?25h"; pub const cursor_shape = "\x1b[{d} q"; +pub const ri = "\x1bM"; +pub const ind = "\n"; +pub const cuf = "\x1b[{d}C"; +pub const cub = "\x1b[{d}D"; + +// Erase +pub const erase_below_cursor = "\x1b[J"; // alt screen pub const smcup = "\x1b[?1049h";