From f21559cc51b3c3c15ff09181a1c9af3eef8e3a4e Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Thu, 6 Jun 2024 16:41:32 -0500 Subject: [PATCH] widgets(terminal): begin terminal widget --- build.zig | 1 + examples/vt.zig | 136 ++++++++++++ src/Parser.zig | 1 - src/main.zig | 1 + src/widgets.zig | 1 + src/widgets/terminal/Command.zig | 84 ++++++++ src/widgets/terminal/Parser.zig | 141 +++++++++++++ src/widgets/terminal/Pty.zig | 59 ++++++ src/widgets/terminal/Screen.zig | 333 ++++++++++++++++++++++++++++++ src/widgets/terminal/Terminal.zig | 232 +++++++++++++++++++++ 10 files changed, 988 insertions(+), 1 deletion(-) create mode 100644 examples/vt.zig create mode 100644 src/widgets/terminal/Command.zig create mode 100644 src/widgets/terminal/Parser.zig create mode 100644 src/widgets/terminal/Pty.zig create mode 100644 src/widgets/terminal/Screen.zig create mode 100644 src/widgets/terminal/Terminal.zig diff --git a/build.zig b/build.zig index a2426dc..940799b 100644 --- a/build.zig +++ b/build.zig @@ -64,6 +64,7 @@ pub fn build(b: *std.Build) void { table, text_input, vaxis, + vt, xev, }; const example_option = b.option(Example, "example", "Example to run (default: text_input)") orelse .text_input; diff --git a/examples/vt.zig b/examples/vt.zig new file mode 100644 index 0000000..9a9386d --- /dev/null +++ b/examples/vt.zig @@ -0,0 +1,136 @@ +const std = @import("std"); +const vaxis = @import("vaxis"); +const Cell = vaxis.Cell; + +const Event = union(enum) { + key_press: vaxis.Key, + winsize: vaxis.Winsize, +}; + +pub const panic = vaxis.panic_handler; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer { + const deinit_status = gpa.deinit(); + //fail test; can't try in defer as defer is executed after we return + if (deinit_status == .leak) { + std.log.err("memory leak", .{}); + } + } + const alloc = gpa.allocator(); + + var tty = try vaxis.Tty.init(); + var vx = try vaxis.init(alloc, .{}); + defer vx.deinit(alloc, tty.anyWriter()); + + var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; + try loop.init(); + + try loop.start(); + defer loop.stop(); + + try vx.enterAltScreen(tty.anyWriter()); + try vx.queryTerminal(tty.anyWriter(), 1 * std.time.ns_per_s); + var env = try std.process.getEnvMap(alloc); + defer env.deinit(); + + const vt_opts: vaxis.widgets.Terminal.Options = .{ + .winsize = .{ + .rows = 24, + .cols = 100, + .x_pixel = 0, + .y_pixel = 0, + }, + .scrollback_size = 0, + }; + const argv1 = [_][]const u8{"senpai"}; + const argv2 = [_][]const u8{"nvim"}; + const argv3 = [_][]const u8{"senpai"}; + // const argv = [_][]const u8{"senpai"}; + // const argv = [_][]const u8{"comlink"}; + var vt1 = try vaxis.widgets.Terminal.init( + alloc, + &argv1, + &env, + &vx.unicode, + vt_opts, + ); + defer vt1.deinit(); + try vt1.spawn(); + var vt2 = try vaxis.widgets.Terminal.init( + alloc, + &argv2, + &env, + &vx.unicode, + vt_opts, + ); + defer vt2.deinit(); + try vt2.spawn(); + var vt3 = try vaxis.widgets.Terminal.init( + alloc, + &argv3, + &env, + &vx.unicode, + vt_opts, + ); + defer vt3.deinit(); + try vt3.spawn(); + + while (true) { + std.time.sleep(8 * std.time.ns_per_ms); + while (loop.tryEvent()) |event| { + switch (event) { + .key_press => |key| if (key.matches('c', .{ .ctrl = true })) return, + .winsize => |ws| { + try vx.resize(alloc, tty.anyWriter(), ws); + }, + } + } + + const win = vx.window(); + win.clear(); + const left = win.child(.{ + .width = .{ .limit = win.width / 2 }, + .border = .{ + .where = .right, + }, + }); + + const right_top = win.child(.{ + .x_off = left.width + 1, + .height = .{ .limit = win.height / 2 }, + .border = .{ + .where = .bottom, + }, + }); + const right_bot = win.child(.{ + .x_off = left.width + 1, + .y_off = right_top.height + 1, + }); + + try vt1.resize(.{ + .rows = left.height, + .cols = left.width, + .x_pixel = 0, + .y_pixel = 0, + }); + try vt2.resize(.{ + .rows = right_top.height, + .cols = right_bot.width, + .x_pixel = 0, + .y_pixel = 0, + }); + try vt3.resize(.{ + .rows = right_bot.height, + .cols = right_bot.width, + .x_pixel = 0, + .y_pixel = 0, + }); + try vt1.draw(left); + try vt2.draw(right_top); + try vt3.draw(right_bot); + + try vx.render(tty.anyWriter()); + } +} diff --git a/src/Parser.zig b/src/Parser.zig index dc4aebb..90244a2 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -290,7 +290,6 @@ inline fn parseOsc(input: []const u8, paste_allocator: ?std.mem.Allocator) !Resu inline fn parseCsi(input: []const u8, text_buf: []u8) Result { // We start iterating at index 2 to get past te '[' const sequence = for (input[2..], 2..) |b, i| { - if (i == 2 and b == '?') continue; switch (b) { 0x40...0xFF => break input[0 .. i + 1], else => continue, diff --git a/src/main.zig b/src/main.zig index af789a4..4d8ba4f 100644 --- a/src/main.zig +++ b/src/main.zig @@ -26,6 +26,7 @@ pub const ctlseqs = @import("ctlseqs.zig"); pub const GraphemeCache = @import("GraphemeCache.zig"); pub const grapheme = @import("grapheme"); pub const Event = @import("event.zig").Event; +pub const Unicode = @import("Unicode.zig"); /// The target TTY implementation pub const Tty = switch (builtin.os.tag) { diff --git a/src/widgets.zig b/src/widgets.zig index 5c69e45..14d774e 100644 --- a/src/widgets.zig +++ b/src/widgets.zig @@ -10,6 +10,7 @@ pub const ScrollView = @import("widgets/ScrollView.zig"); pub const LineNumbers = @import("widgets/LineNumbers.zig"); pub const TextView = @import("widgets/TextView.zig"); pub const CodeView = @import("widgets/CodeView.zig"); +pub const Terminal = @import("widgets/terminal/Terminal.zig"); // Widgets with dependencies diff --git a/src/widgets/terminal/Command.zig b/src/widgets/terminal/Command.zig new file mode 100644 index 0000000..bbbfb3e --- /dev/null +++ b/src/widgets/terminal/Command.zig @@ -0,0 +1,84 @@ +const Command = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Pty = @import("Pty.zig"); + +const posix = std.posix; + +argv: []const []const u8, + +// Set after spawn() +pid: ?std.posix.pid_t = null, + +env_map: *const std.process.EnvMap, + +pty: Pty, + +pub fn spawn(self: *Command, allocator: std.mem.Allocator) !void { + var arena_allocator = std.heap.ArenaAllocator.init(allocator); + defer arena_allocator.deinit(); + + const arena = arena_allocator.allocator(); + + const argv_buf = try arena.allocSentinel(?[*:0]const u8, self.argv.len, null); + for (self.argv, 0..) |arg, i| argv_buf[i] = (try arena.dupeZ(u8, arg)).ptr; + + const envp = try createEnvironFromMap(arena, self.env_map); + + const pid = try std.posix.fork(); + if (pid == 0) { + // we are the child + _ = std.os.linux.setsid(); + + // set the controlling terminal + var u: c_uint = std.posix.STDIN_FILENO; + if (posix.system.ioctl(self.pty.tty, posix.T.IOCSCTTY, @intFromPtr(&u)) != 0) return error.IoctlError; + + // set up io + try posix.dup2(self.pty.tty, std.posix.STDIN_FILENO); + try posix.dup2(self.pty.tty, std.posix.STDOUT_FILENO); + try posix.dup2(self.pty.tty, std.posix.STDERR_FILENO); + + // posix.close(self.pty.tty); + // if (self.pty.pty > 2) posix.close(self.pty.pty); + + // exec + const err = std.posix.execvpeZ(argv_buf.ptr[0].?, argv_buf.ptr, envp); + _ = err catch {}; + } + + // we are the parent + self.pid = @intCast(pid); + return; +} + +pub fn kill(self: *Command) void { + if (self.pid) |pid| { + std.posix.kill(pid, std.posix.SIG.TERM) catch {}; + self.pid = null; + } +} + +/// Creates a null-deliminated environment variable block in the format expected by POSIX, from a +/// hash map plus options. +fn createEnvironFromMap( + arena: std.mem.Allocator, + map: *const std.process.EnvMap, +) ![:null]?[*:0]u8 { + const envp_count: usize = map.count(); + + const envp_buf = try arena.allocSentinel(?[*:0]u8, envp_count, null); + var i: usize = 0; + + { + var it = map.iterator(); + while (it.next()) |pair| { + envp_buf[i] = try std.fmt.allocPrintZ(arena, "{s}={s}", .{ pair.key_ptr.*, pair.value_ptr.* }); + i += 1; + } + } + + std.debug.assert(i == envp_count); + return envp_buf; +} diff --git a/src/widgets/terminal/Parser.zig b/src/widgets/terminal/Parser.zig new file mode 100644 index 0000000..0395fee --- /dev/null +++ b/src/widgets/terminal/Parser.zig @@ -0,0 +1,141 @@ +//! An ANSI VT Parser +const Parser = @This(); + +const std = @import("std"); +const Reader = std.io.AnyReader; + +/// A terminal event +const Event = union(enum) { + print: []const u8, + c0: u8, + escape: []const u8, + ss2: u8, + ss3: u8, + csi: []const u8, + osc: []const u8, + apc: []const u8, +}; + +buf: std.ArrayList(u8), +/// a leftover byte from a ground event +pending_byte: ?u8 = null, + +pub fn parseReader(self: *Parser, reader: Reader) !Event { + self.buf.clearRetainingCapacity(); + while (true) { + const b = if (self.pending_byte) |p| p else try reader.readByte(); + self.pending_byte = null; + switch (b) { + // Escape sequence + 0x1b => { + const next = try reader.readByte(); + switch (next) { + 0x4E => return .{ .ss2 = try reader.readByte() }, + 0x4F => return .{ .ss3 = try reader.readByte() }, + 0x50 => try skipUntilST(reader), // DCS + 0x58 => try skipUntilST(reader), // SOS + 0x5B => return self.parseCsi(reader), // CSI + 0x5D => return self.parseOsc(reader), // OSC + 0x5E => try skipUntilST(reader), // PM + 0x5F => return self.parseApc(reader), // APC + + 0x20...0x2F => { + try self.buf.append(next); + return self.parseEscape(reader); // ESC + }, + else => { + try self.buf.append(next); + return .{ .escape = self.buf.items }; + }, + } + }, + // C0 control + 0x00...0x1a, + 0x1c...0x1f, + => return .{ .c0 = b }, + else => { + try self.buf.append(b); + return self.parseGround(reader); + }, + } + } +} + +inline fn parseGround(self: *Parser, reader: Reader) !Event { + while (true) { + const b = try reader.readByte(); + switch (b) { + 0x00...0x1f => { + self.pending_byte = b; + return .{ .print = self.buf.items }; + }, + else => try self.buf.append(b), + } + } +} + +/// parse until b >= 0x30 +inline fn parseEscape(self: *Parser, reader: Reader) !Event { + while (true) { + const b = try reader.readByte(); + switch (b) { + 0x20...0x2F => continue, + else => return .{ .escape = self.buf.items }, + } + } +} + +inline fn parseApc(self: *Parser, reader: Reader) !Event { + while (true) { + const b = try reader.readByte(); + switch (b) { + 0x00...0x17, + 0x19, + 0x1c...0x1f, + => continue, + 0x1b => { + try reader.skipBytes(1, .{ .buf_size = 1 }); + return .{ .apc = self.buf.items }; + }, + else => try self.buf.append(b), + } + } +} + +/// Skips sequences until we see an ST (String Terminator, ESC \) +inline fn skipUntilST(reader: Reader) !void { + try reader.skipUntilDelimiterOrEof('\x1b'); + try reader.skipBytes(1, .{ .buf_size = 1 }); +} + +/// Parses an OSC sequence +inline fn parseOsc(self: *Parser, reader: Reader) !Event { + while (true) { + const b = try reader.readByte(); + switch (b) { + 0x00...0x06, + 0x08...0x17, + 0x19, + 0x1c...0x1f, + => continue, + 0x1b => { + try reader.skipBytes(1, .{ .buf_size = 1 }); + return .{ .osc = self.buf.items }; + }, + 0x07 => return .{ .osc = self.buf.items }, + else => try self.buf.append(b), + } + } +} + +inline fn parseCsi(self: *Parser, reader: Reader) !Event { + while (true) { + const b = try reader.readByte(); + try self.buf.append(b); + switch (b) { + // Really we should execute C0 controls, but we just ignore them + 0x40...0xFF => return .{ .csi = self.buf.items }, + else => continue, + } + } +} diff --git a/src/widgets/terminal/Pty.zig b/src/widgets/terminal/Pty.zig new file mode 100644 index 0000000..1258127 --- /dev/null +++ b/src/widgets/terminal/Pty.zig @@ -0,0 +1,59 @@ +//! A PTY pair +const Pty = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +const Winsize = @import("../../main.zig").Winsize; + +const posix = std.posix; + +pty: posix.fd_t, +tty: posix.fd_t, + +/// opens a new tty/pty pair +pub fn init() !Pty { + switch (builtin.os.tag) { + .linux => return openPtyLinux(), + else => @compileError("unsupported os"), + } +} + +/// closes the tty and pty +pub fn deinit(self: Pty) void { + posix.close(self.pty); + posix.close(self.tty); +} + +/// sets the size of the pty +pub fn setSize(self: Pty, ws: Winsize) !void { + const _ws: posix.winsize = .{ + .ws_row = @truncate(ws.rows), + .ws_col = @truncate(ws.cols), + .ws_xpixel = @truncate(ws.x_pixel), + .ws_ypixel = @truncate(ws.y_pixel), + }; + if (posix.system.ioctl(self.pty, posix.T.IOCSWINSZ, @intFromPtr(&_ws)) != 0) + return error.SetWinsizeError; +} + +fn openPtyLinux() !Pty { + const p = try posix.open("/dev/ptmx", .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0); + errdefer posix.close(p); + + // unlockpt + var n: c_uint = 0; + if (posix.system.ioctl(p, posix.T.IOCSPTLCK, @intFromPtr(&n)) != 0) return error.IoctlError; + + // ptsname + if (posix.system.ioctl(p, posix.T.IOCGPTN, @intFromPtr(&n)) != 0) return error.IoctlError; + var buf: [16]u8 = undefined; + const sname = try std.fmt.bufPrint(&buf, "/dev/pts/{d}", .{n}); + std.log.err("pts: {s}", .{sname}); + + const t = try posix.open(sname, .{ .ACCMODE = .RDWR, .NOCTTY = true }, 0); + + return .{ + .pty = p, + .tty = t, + }; +} diff --git a/src/widgets/terminal/Screen.zig b/src/widgets/terminal/Screen.zig new file mode 100644 index 0000000..026a0f3 --- /dev/null +++ b/src/widgets/terminal/Screen.zig @@ -0,0 +1,333 @@ +const std = @import("std"); +const assert = std.debug.assert; +const vaxis = @import("../../main.zig"); + +const log = std.log.scoped(.terminal); + +const Screen = @This(); + +pub const Cell = struct { + char: std.ArrayList(u8) = undefined, + style: vaxis.Style = .{}, + uri: std.ArrayList(u8) = undefined, + uri_id: std.ArrayList(u8) = undefined, + width: u8 = 0, + + wrapped: bool = false, + dirty: bool = true, +}; + +pub const Cursor = struct { + style: vaxis.Style = .{}, + uri: std.ArrayList(u8) = undefined, + uri_id: std.ArrayList(u8) = undefined, + col: usize = 0, + row: usize = 0, + pending_wrap: bool = false, + shape: vaxis.Cell.CursorShape = .default, + + pub fn isOutsideScrollingRegion(self: Cursor, sr: ScrollingRegion) bool { + return self.row < sr.top or + self.row > sr.bottom or + self.col < sr.left or + self.col > sr.right; + } + + pub fn isInsideScrollingRegion(self: Cursor, sr: ScrollingRegion) bool { + return !self.isOutsideScrollingRegion(sr); + } +}; + +pub const ScrollingRegion = struct { + top: usize, + bottom: usize, + left: usize, + right: usize, + + pub fn contains(self: ScrollingRegion, col: usize, row: usize) bool { + return col >= self.left and + col <= self.right and + row >= self.top and + row <= self.bottom; + } +}; + +width: usize = 0, +height: usize = 0, + +scrolling_region: ScrollingRegion, + +buf: []Cell = undefined, + +cursor: Cursor = .{}, + +/// sets each cell to the default cell +pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !Screen { + var screen = Screen{ + .buf = try alloc.alloc(Cell, w * h), + .scrolling_region = .{ + .top = 0, + .bottom = h - 1, + .left = 0, + .right = w - 1, + }, + .width = w, + .height = h, + }; + for (screen.buf, 0..) |_, i| { + screen.buf[i] = .{ + .char = try std.ArrayList(u8).initCapacity(alloc, 1), + .uri = std.ArrayList(u8).init(alloc), + .uri_id = std.ArrayList(u8).init(alloc), + }; + try screen.buf[i].char.append(' '); + } + return screen; +} + +pub fn deinit(self: *Screen, alloc: std.mem.Allocator) void { + for (self.buf, 0..) |_, i| { + self.buf[i].char.deinit(); + self.buf[i].uri.deinit(); + self.buf[i].uri_id.deinit(); + } + + alloc.free(self.buf); +} + +/// copies the visible area to the destination screen +pub fn copyTo(self: *Screen, dst: *Screen) !void { + for (self.buf, 0..) |cell, i| { + if (!cell.dirty) continue; + self.buf[i].dirty = false; + const grapheme = cell.char.items; + dst.buf[i].char.clearRetainingCapacity(); + try dst.buf[i].char.appendSlice(grapheme); + dst.buf[i].width = cell.width; + dst.buf[i].style = cell.style; + } +} + +pub fn readCell(self: *Screen, col: usize, row: usize) ?vaxis.Cell { + if (self.width < col) { + // column out of bounds + return null; + } + if (self.height < row) { + // height out of bounds + return null; + } + const i = (row * self.width) + col; + assert(i < self.buf.len); + const cell = self.buf[i]; + return .{ + .char = .{ .grapheme = cell.char.items, .width = cell.width }, + .style = cell.style, + }; +} + +/// writes a cell to a location. 0 indexed +pub fn print( + self: *Screen, + grapheme: []const u8, + width: u8, +) void { + + // FIXME: wrapping + // if (self.cursor.col + width >= self.width) { + // self.cursor.col = 0; + // self.cursor.row += 1; + // } + if (self.cursor.col >= self.width) return; + if (self.cursor.row >= self.height) return; + const col = self.cursor.col; + const row = self.cursor.row; + + const i = (row * self.width) + col; + assert(i < self.buf.len); + self.buf[i].char.clearRetainingCapacity(); + self.buf[i].char.appendSlice(grapheme) catch { + log.warn("couldn't write grapheme", .{}); + }; + self.buf[i].uri.clearRetainingCapacity(); + self.buf[i].uri.appendSlice(self.cursor.uri.items) catch { + log.warn("couldn't write uri", .{}); + }; + self.buf[i].uri_id.clearRetainingCapacity(); + self.buf[i].uri_id.appendSlice(self.cursor.uri_id.items) catch { + log.warn("couldn't write uri_id", .{}); + }; + self.buf[i].style = self.cursor.style; + self.buf[i].width = width; + self.buf[i].dirty = true; + + self.cursor.col += width; + // FIXME: when do we set default in this function?? + // self.buf[i].default = false; +} + +/// IND +pub fn index(self: *Screen) !void { + self.cursor.pending_wrap = false; + + if (self.cursor.isOutsideScrollingRegion(self.scrolling_region)) { + // Outside, we just move cursor down one + self.cursor.row = @min(self.height - 1, self.cursor.row + 1); + return; + } + // We are inside the scrolling region + if (self.cursor.row == self.scrolling_region.bottom) { + // Inside scrolling region *and* at bottom of screen, we scroll contents up and insert a + // blank line + // TODO: scrollback if scrolling region is entire visible screen + @panic("TODO"); + } + self.cursor.row += 1; +} + +fn Parameter(T: type) type { + return struct { + const Self = @This(); + val: T, + // indicates the next parameter is a sub-parameter + has_sub: bool = false, + is_empty: bool = false, + + const Iterator = struct { + bytes: []const u8, + idx: usize = 0, + + fn next(self: *Iterator) ?Self { + const start = self.idx; + var val: T = 0; + while (self.idx < self.bytes.len) { + defer self.idx += 1; // defer so we trigger on return as well + const b = self.bytes[self.idx]; + switch (b) { + 0x30...0x39 => { + val = (val * 10) + (b - 0x30); + if (self.idx == self.bytes.len - 1) return .{ .val = val }; + }, + ':', ';' => return .{ + .val = val, + .is_empty = self.idx == start, + .has_sub = b == ':', + }, + else => return null, + } + } + return null; + } + }; + }; +} + +pub fn sgr(self: *Screen, seq: []const u8) void { + if (seq.len == 0) { + self.cursor.style = .{}; + return; + } + switch (seq[0]) { + 0x30...0x39 => {}, + else => return, // TODO: handle private indicator sequences + } + + var iter: Parameter(u8).Iterator = .{ .bytes = seq }; + while (iter.next()) |ps| { + switch (ps.val) { + 0 => self.cursor.style = .{}, + 1 => self.cursor.style.bold = true, + 2 => self.cursor.style.dim = true, + 3 => self.cursor.style.italic = true, + 4 => { + const kind: vaxis.Style.Underline = if (ps.has_sub) blk: { + const ul = iter.next() orelse break :blk .single; + break :blk @enumFromInt(ul.val); + } else .single; + self.cursor.style.ul_style = kind; + }, + 5 => self.cursor.style.blink = true, + 7 => self.cursor.style.reverse = true, + 8 => self.cursor.style.invisible = true, + 9 => self.cursor.style.strikethrough = true, + 21 => self.cursor.style.ul_style = .double, + 22 => { + self.cursor.style.bold = false; + self.cursor.style.dim = false; + }, + 23 => self.cursor.style.italic = false, + 24 => self.cursor.style.ul_style = .off, + 25 => self.cursor.style.blink = false, + 27 => self.cursor.style.reverse = false, + 28 => self.cursor.style.invisible = false, + 29 => self.cursor.style.strikethrough = false, + 30...37 => self.cursor.style.fg = .{ .index = ps.val - 30 }, + 38 => { + // must have another parameter + const kind = iter.next() orelse return; + switch (kind.val) { + 2 => { // rgb + const r = r: { + // First param can be empty + var ps_r = iter.next() orelse return; + while (ps_r.is_empty) { + ps_r = iter.next() orelse return; + } + break :r ps_r.val; + }; + const g = g: { + const ps_g = iter.next() orelse return; + break :g ps_g.val; + }; + const b = b: { + const ps_b = iter.next() orelse return; + break :b ps_b.val; + }; + self.cursor.style.fg = .{ .rgb = .{ r, g, b } }; + }, + 5 => { + const idx = iter.next() orelse return; + self.cursor.style.fg = .{ .index = idx.val }; + }, // index + else => return, + } + }, + 39 => self.cursor.style.fg = .default, + 40...47 => self.cursor.style.bg = .{ .index = ps.val - 40 }, + 48 => { + // must have another parameter + const kind = iter.next() orelse return; + switch (kind.val) { + 2 => { // rgb + const r = r: { + // First param can be empty + var ps_r = iter.next() orelse return; + while (ps_r.is_empty) { + ps_r = iter.next() orelse return; + } + break :r ps_r.val; + }; + const g = g: { + const ps_g = iter.next() orelse return; + break :g ps_g.val; + }; + const b = b: { + const ps_b = iter.next() orelse return; + break :b ps_b.val; + }; + self.cursor.style.bg = .{ .rgb = .{ r, g, b } }; + }, + 5 => { // index + const idx = iter.next() orelse return; + self.cursor.style.bg = .{ .index = idx.val }; + }, + else => return, + } + }, + 49 => self.cursor.style.bg = .default, + 90...97 => self.cursor.style.fg = .{ .index = ps.val - 90 + 8 }, + 100...107 => self.cursor.style.bg = .{ .index = ps.val - 100 + 8 }, + else => continue, + } + } +} diff --git a/src/widgets/terminal/Terminal.zig b/src/widgets/terminal/Terminal.zig new file mode 100644 index 0000000..ee25937 --- /dev/null +++ b/src/widgets/terminal/Terminal.zig @@ -0,0 +1,232 @@ +//! A virtual terminal widget +const Terminal = @This(); + +const std = @import("std"); +const builtin = @import("builtin"); +pub const Command = @import("Command.zig"); +const Parser = @import("Parser.zig"); +const Pty = @import("Pty.zig"); +const vaxis = @import("../../main.zig"); +const Winsize = vaxis.Winsize; +const Screen = @import("Screen.zig"); +const DisplayWidth = @import("DisplayWidth"); + +const grapheme = @import("grapheme"); + +const posix = std.posix; + +const log = std.log.scoped(.terminal); + +pub const Options = struct { + scrollback_size: usize = 500, + winsize: Winsize = .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 }, +}; + +allocator: std.mem.Allocator, +scrollback_size: usize, + +pty: Pty, +cmd: Command, +thread: ?std.Thread = null, + +/// the screen we draw from +front_screen: Screen, +front_mutex: std.Thread.Mutex = .{}, + +/// the back screens +back_screen: *Screen = undefined, +back_screen_pri: Screen, +back_screen_alt: Screen, +// only applies to primary screen +scroll_offset: usize = 0, +back_mutex: std.Thread.Mutex = .{}, + +unicode: *const vaxis.Unicode, +should_quit: bool = false, + +/// initialize a Terminal. This sets the size of the underlying pty and allocates the sizes of the +/// screen +pub fn init( + allocator: std.mem.Allocator, + argv: []const []const u8, + env: *const std.process.EnvMap, + unicode: *const vaxis.Unicode, + opts: Options, +) !Terminal { + const pty = try Pty.init(); + try pty.setSize(opts.winsize); + const cmd: Command = .{ + .argv = argv, + .env_map = env, + .pty = pty, + }; + return .{ + .allocator = allocator, + .pty = pty, + .cmd = cmd, + .scrollback_size = opts.scrollback_size, + .front_screen = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows), + .back_screen_pri = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows + opts.scrollback_size), + .back_screen_alt = try Screen.init(allocator, opts.winsize.cols, opts.winsize.rows), + .unicode = unicode, + }; +} + +/// release all resources of the Terminal +pub fn deinit(self: *Terminal) void { + self.should_quit = true; + self.cmd.kill(); + if (self.thread) |thread| { + thread.join(); + self.thread = null; + } + self.pty.deinit(); + self.front_screen.deinit(self.allocator); + self.back_screen_pri.deinit(self.allocator); + self.back_screen_alt.deinit(self.allocator); +} + +pub fn spawn(self: *Terminal) !void { + if (self.thread != null) return; + self.back_screen = &self.back_screen_pri; + + try self.cmd.spawn(self.allocator); + self.thread = try std.Thread.spawn(.{}, Terminal.run, .{self}); +} + +/// resize the screen. Locks access to the back screen. Should only be called from the main thread +pub fn resize(self: *Terminal, ws: Winsize) !void { + // don't deinit with no size change + if (ws.cols == self.front_screen.width and + ws.rows == self.front_screen.height) + { + std.log.debug("resize requested but no change", .{}); + return; + } + + self.back_mutex.lock(); + defer self.back_mutex.unlock(); + + self.front_screen.deinit(self.allocator); + self.front_screen = try Screen.init(self.allocator, ws.cols, ws.rows); + + self.back_screen_pri.deinit(self.allocator); + self.back_screen_alt.deinit(self.allocator); + self.back_screen_pri = try Screen.init(self.allocator, ws.cols, ws.rows + self.scrollback_size); + self.back_screen_alt = try Screen.init(self.allocator, ws.cols, ws.rows); + + try self.pty.setSize(ws); +} + +pub fn draw(self: *Terminal, win: vaxis.Window) !void { + // TODO: check sync + if (self.back_mutex.tryLock()) { + defer self.back_mutex.unlock(); + try self.back_screen.copyTo(&self.front_screen); + } + + var row: usize = 0; + while (row < self.front_screen.height) : (row += 1) { + var col: usize = 0; + while (col < self.front_screen.width) { + const cell = self.front_screen.readCell(col, row) orelse continue; + win.writeCell(col, row, cell); + col += @max(cell.char.width, 1); + } + } +} + +fn opaqueRead(ptr: *const anyopaque, buf: []u8) !usize { + const self: *const Terminal = @ptrCast(@alignCast(ptr)); + return posix.read(self.pty.pty, buf); +} + +fn anyReader(self: *const Terminal) std.io.AnyReader { + return .{ + .context = self, + .readFn = Terminal.opaqueRead, + }; +} + +/// process the output from the command on the pty +fn run(self: *Terminal) !void { + // ridiculous buffer size so we never have to handle incomplete reads + var parser: Parser = .{ + .buf = try std.ArrayList(u8).initCapacity(self.allocator, 128), + }; + defer parser.buf.deinit(); + + // Use our anyReader to make a buffered reader, then get *that* any reader + var buffered = std.io.bufferedReader(self.anyReader()); + const reader = buffered.reader().any(); + + while (!self.should_quit) { + const event = try parser.parseReader(reader); + self.back_mutex.lock(); + defer self.back_mutex.unlock(); + switch (event) { + .print => |str| { + std.log.err("print: {s}", .{str}); + var iter = grapheme.Iterator.init(str, &self.unicode.grapheme_data); + while (iter.next()) |g| { + const bytes = g.bytes(str); + const w = try vaxis.gwidth.gwidth(bytes, .unicode, &self.unicode.width_data); + self.back_screen.print(bytes, @truncate(w)); + } + }, + .c0 => |b| try self.handleC0(b), + .csi => |seq| { + const final = seq[seq.len - 1]; + switch (final) { + 'B' => { // CUD + switch (seq.len) { + 0 => unreachable, + 1 => self.back_screen.cursor.row += 1, + else => { + const delta = parseParam(u16, seq[2 .. seq.len - 1], 1) orelse 1; + self.back_screen.cursor.row = @min(self.back_screen.height - 1, self.back_screen.cursor.row + delta); + }, + } + }, + 'H' => { // CUP + const delim = std.mem.indexOfScalar(u8, seq, ';') orelse { + switch (seq.len) { + 0 => unreachable, + 1 => { + self.back_screen.cursor.row = 0; + self.back_screen.cursor.col = 0; + }, + else => { + const row = parseParam(u16, seq[0 .. seq.len - 1], 1) orelse 1; + self.back_screen.cursor.row = row - 1; + }, + } + continue; + }; + const row = parseParam(u16, seq[0..delim], 1) orelse 1; + const col = parseParam(u16, seq[delim + 1 .. seq.len - 1], 1) orelse 1; + self.back_screen.cursor.col = col - 1; + self.back_screen.cursor.row = row - 1; + }, + 'm' => self.back_screen.sgr(seq[0 .. seq.len - 1]), + else => {}, + } + }, + else => {}, + } + } +} + +inline fn handleC0(self: *Terminal, b: u8) !void { + switch (b) { + 0x0a, 0x0b, 0x0c => try self.back_screen.index(), // line feed + 0x0d => {}, // carriage return + else => log.warn("unhandled C0: 0x{x}", .{b}), + } +} + +/// Parse a param buffer, returning a default value if the param was empty +inline fn parseParam(comptime T: type, buf: []const u8, default: ?T) ?T { + if (buf.len == 0) return default; + return std.fmt.parseUnsigned(T, buf, 10) catch return null; +}