const std = @import("std"); const builtin = @import("builtin"); const atomic = std.atomic; const base64Encoder = std.base64.standard.Encoder; const Cell = @import("Cell.zig"); const Image = @import("Image.zig"); const InternalScreen = @import("InternalScreen.zig"); const Key = @import("Key.zig"); const Mouse = @import("Mouse.zig"); const Screen = @import("Screen.zig"); const Cursor = Screen.Cursor; const unicode = @import("unicode.zig"); const Window = @import("Window.zig"); const Hyperlink = Cell.Hyperlink; const KittyFlags = Key.KittyFlags; const Shape = Mouse.Shape; const Style = Cell.Style; const Winsize = @import("main.zig").Winsize; const ctlseqs = @import("ctlseqs.zig"); const gwidth = @import("gwidth.zig"); const assert = std.debug.assert; const Vaxis = @This(); const log = std.log.scoped(.vaxis); pub const Capabilities = struct { kitty_keyboard: bool = false, kitty_graphics: bool = false, no_color: bool = false, rgb: bool = false, unicode: gwidth.Method = .wcwidth, sgr_pixels: bool = false, color_scheme_updates: bool = false, explicit_width: bool = false, scaled_text: bool = false, multi_cursor: bool = false, /// The terminal answered the Program Status Protocol (OSC 7501) query program_status: bool = false, }; pub const Options = struct { kitty_keyboard_flags: KittyFlags = .{}, /// When supplied, this allocator will be used for system clipboard /// requests. If not supplied, it won't be possible to request the system /// clipboard system_clipboard_allocator: ?std.mem.Allocator = null, }; io: std.Io, env_map: *std.process.Environ.Map, /// the screen we write to screen: Screen, /// The last screen we drew. We keep this so we can efficiently update on /// the next render screen_last: InternalScreen, caps: Capabilities = .{}, opts: Options = .{}, /// if we should redraw the entire screen on the next render refresh: bool = false, /// blocks the main thread until a DA1 query has been received, or the /// futex times out query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), /// If Queries were sent, we set this to false. We reset to true when all queries are complete. This /// is used because we do explicit cursor position reports in the queries, which interfere with F3 /// key encoding. This can be used as a flag to determine how we should evaluate this sequence queries_done: atomic.Value(bool) = atomic.Value(bool).init(true), /// Outstanding application cursor position queries and their idle deadline. /// Shared with the input parser. cursor_position_requests: @import("Parser.zig").CursorPositionRequests, // images next_img_id: u32 = 1, sgr: enum { standard, legacy, } = .standard, /// Enable workarounds for escape sequence handling issues/bugs in terminals /// So far this just enables a UL escape sequence workaround for conpty enable_workarounds: bool = true, state: struct { /// if we are in the alt screen alt_screen: bool = false, /// if we have entered kitty keyboard kitty_keyboard: bool = false, bracketed_paste: bool = false, mouse: bool = false, pixel_mouse: bool = false, color_scheme_updates: bool = false, in_band_resize: bool = false, changed_default_fg: bool = false, changed_default_bg: bool = false, changed_cursor_color: bool = false, cursor: Cursor = .{}, cursor_secondary: []Cursor = &.{}, prev_cursor_secondary: []const Cursor = &.{}, } = .{}, /// Initialize Vaxis with runtime options pub fn init(io: std.Io, alloc: std.mem.Allocator, env_map: *std.process.Environ.Map, opts: Options) !Vaxis { return .{ .io = io, .env_map = env_map, .opts = opts, .screen = .{}, .screen_last = try .init(alloc, 0, 0), .cursor_position_requests = .{ .io = io }, }; } /// Resets the terminal to it's original state. If an allocator is /// passed, this will free resources associated with Vaxis. This is left as an /// optional so applications can choose to not free resources when the /// application will be exiting anyways pub fn deinit(self: *Vaxis, alloc: ?std.mem.Allocator, tty: *std.Io.Writer) void { self.resetState(tty) catch {}; if (alloc) |a| { if (self.state.prev_cursor_secondary.ptr != self.screen.cursor_secondary.ptr) a.free(self.state.prev_cursor_secondary); a.free(self.screen.cursor_secondary); self.screen.deinit(a); self.screen_last.deinit(a); } } /// resets enabled features, sends cursor to home and clears below cursor pub fn resetState(self: *Vaxis, tty: *std.Io.Writer) !void { // always show the cursor on state reset tty.writeAll(ctlseqs.show_cursor) catch {}; tty.writeAll(ctlseqs.sgr_reset) catch {}; if (self.screen.cursor_shape != .default) { // In many terminals, `.default` will set to the configured cursor shape. Others, it will // change to a blinking block. tty.print(ctlseqs.cursor_shape, .{@backingInt(Cell.CursorShape.default)}) catch {}; } if (self.state.kitty_keyboard) { try tty.writeAll(ctlseqs.csi_u_pop); self.state.kitty_keyboard = false; } if (self.state.mouse) { try self.setMouseMode(tty, false); } if (self.state.bracketed_paste) { try self.setBracketedPaste(tty, false); } if (self.state.alt_screen) { try tty.writeAll(ctlseqs.home); try tty.writeAll(ctlseqs.erase_below_cursor); try self.exitAltScreen(tty); } else { try tty.writeByte('\r'); var i: u16 = 0; while (i < self.state.cursor.row) : (i += 1) { try tty.writeAll(ctlseqs.ri); } try tty.writeAll(ctlseqs.erase_below_cursor); } if (self.state.color_scheme_updates) { try tty.writeAll(ctlseqs.color_scheme_reset); self.state.color_scheme_updates = false; } if (self.state.in_band_resize) { try tty.writeAll(ctlseqs.in_band_resize_reset); self.state.in_band_resize = false; } if (self.state.changed_default_fg) { try tty.writeAll(ctlseqs.osc10_reset); self.state.changed_default_fg = false; } if (self.state.changed_default_bg) { try tty.writeAll(ctlseqs.osc11_reset); self.state.changed_default_bg = false; } if (self.state.changed_cursor_color) { try tty.writeAll(ctlseqs.osc12_reset); self.state.changed_cursor_color = false; } try tty.flush(); } /// 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. The cursor will be sent to it's home position, a /// hardware clear-below-cursor will be sent, and the cursor will be hidden pub fn resize( self: *Vaxis, alloc: std.mem.Allocator, tty: *std.Io.Writer, winsize: Winsize, ) !void { log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); const replacements = blk: { var screen = try Screen.init(alloc, winsize); errdefer screen.deinit(alloc); screen.width_method = self.caps.unicode; var screen_last = try InternalScreen.init(alloc, winsize.cols, winsize.rows); errdefer screen_last.deinit(alloc); break :blk .{ .screen = screen, .screen_last = screen_last }; }; self.screen.deinit(alloc); self.screen_last.deinit(alloc); self.screen = replacements.screen; self.screen_last = replacements.screen_last; // try self.screen.int(alloc, winsize.cols, winsize.rows); // we only init our current screen. This has the effect of redrawing // every cell if (self.state.alt_screen) try tty.writeAll(ctlseqs.home) else { for (0..self.state.cursor.row) |_| { try tty.writeAll(ctlseqs.ri); } try tty.writeByte('\r'); } self.state.cursor.row = 0; self.state.cursor.col = 0; try tty.writeAll(ctlseqs.sgr_reset ++ ctlseqs.erase_below_cursor ++ ctlseqs.hide_cursor); try tty.flush(); } /// returns a Window comprising of the entire terminal screen pub fn window(self: *Vaxis) Window { return .{ .x_off = 0, .y_off = 0, .parent_x_off = 0, .parent_y_off = 0, .width = self.screen.width, .height = self.screen.height, .screen = &self.screen, }; } /// enter the alternate screen. The alternate screen will automatically /// be exited if calling deinit while in the alt screen. pub fn enterAltScreen(self: *Vaxis, tty: *std.Io.Writer) !void { try tty.writeAll(ctlseqs.smcup); try tty.flush(); self.state.alt_screen = true; } /// exit the alternate screen. Does not flush the writer. pub fn exitAltScreen(self: *Vaxis, tty: *std.Io.Writer) !void { try tty.writeAll(ctlseqs.rmcup); try tty.flush(); self.state.alt_screen = false; } /// write queries to the terminal to determine capabilities. Individual /// capabilities will be delivered to the client and possibly intercepted by /// Vaxis to enable features. /// /// This call will block until Vaxis.query_futex is woken up, or the timeout. /// Event loops can wake up this futex when cap_da1 is received pub fn queryTerminal(self: *Vaxis, tty: *std.Io.Writer, timeout: std.Io.Duration) !void { try self.queryTerminalSend(tty); try std.Io.futexWaitTimeout( self.io, u32, &self.query_futex.raw, 0, .{ .duration = .{ .clock = .real, .raw = timeout, }, }, ); self.queries_done.store(true, .unordered); try self.enableDetectedFeatures(tty); } /// write queries to the terminal to determine capabilities. This function /// is only for use with a custom main loop. Call Vaxis.queryTerminal() if /// you are using Loop.run() pub fn queryTerminalSend(vx: *Vaxis, tty: *std.Io.Writer) !void { // Capability probes use CPR too, so they must not overlap application queries. if (vx.cursor_position_requests.pending() != 0) return error.CursorPositionQueriesPending; vx.queries_done.store(false, .unordered); // TODO: re-enable this // const colorterm = std.posix.getenv("COLORTERM") orelse ""; // if (std.mem.eql(u8, colorterm, "truecolor") or // std.mem.eql(u8, colorterm, "24bit")) // { // if (@hasField(Event, "cap_rgb")) { // self.postEvent(.cap_rgb); // } // } // TODO: XTGETTCAP queries ("RGB", "Smulx") // TODO: decide if we actually want to query for focus and sync. It // doesn't hurt to blindly use them // _ = try tty.write(ctlseqs.decrqm_focus); // _ = try tty.write(ctlseqs.decrqm_sync); try tty.writeAll(ctlseqs.decrqm_sgr_pixels ++ ctlseqs.decrqm_unicode ++ ctlseqs.decrqm_color_scheme ++ ctlseqs.in_band_resize_set ++ // Explicit width query. We send the cursor home, then do an explicit width command, then // query the position. If the parsed value is an F3 with shift, we support explicit width. // The returned response will be something like \x1b[1;2R...which when parsed as a Key is a // shift + F3 (the row is ignored). We only care if the column has moved from 1->2, which is // why we see a Shift modifier ctlseqs.home ++ ctlseqs.explicit_width_query ++ ctlseqs.cursor_position_request ++ // Explicit width query. We send the cursor home, then do an scaled text command, then // query the position. If the parsed value is an F3 with al, we support scaled text. // The returned response will be something like \x1b[1;3R...which when parsed as a Key is a // alt + F3 (the row is ignored). We only care if the column has moved from 1->3, which is // why we see a Shift modifier ctlseqs.home ++ ctlseqs.scaled_text_query ++ ctlseqs.multi_cursor_query ++ ctlseqs.cursor_position_request ++ ctlseqs.xtversion ++ ctlseqs.csi_u_query ++ ctlseqs.kitty_graphics_query ++ // Must precede DA1: a DA1 reply arriving first means no support ctlseqs.program_status_query ++ ctlseqs.primary_device_attrs); try tty.flush(); } /// Enable features detected by responses to queryTerminal. This function /// is only for use with a custom main loop. Call Vaxis.queryTerminal() if /// you are using Loop.run() pub fn enableDetectedFeatures(self: *Vaxis, tty: *std.Io.Writer) !void { // Apply NO_COLOR before OS-specific feature handling so it works on Windows too. if (self.env_map.get("NO_COLOR")) |nc| { if (nc.len != 0) self.caps.no_color = true; } switch (builtin.os.tag) { .windows => { // No feature detection on windows. We just hard enable some knowns for ConPTY self.sgr = .legacy; }, else => { // Apply any environment variables if (self.env_map.get("TERMUX_VERSION")) |_| self.sgr = .legacy; if (self.env_map.get("VHS_RECORD")) |_| { self.caps.unicode = .wcwidth; self.caps.kitty_keyboard = false; self.sgr = .legacy; } if (self.env_map.get("TERM_PROGRAM")) |prg| { if (std.mem.eql(u8, prg, "vscode")) self.sgr = .legacy; } if (self.env_map.get("VAXIS_FORCE_LEGACY_SGR")) |_| self.sgr = .legacy; if (self.env_map.get("VAXIS_FORCE_WCWIDTH")) |_| self.caps.unicode = .wcwidth; if (self.env_map.get("VAXIS_FORCE_UNICODE")) |_| self.caps.unicode = .unicode; // enable detected features if (self.caps.kitty_keyboard) { try self.enableKittyKeyboard(tty, self.opts.kitty_keyboard_flags); } // Only enable mode 2027 if we don't have explicit width if (self.caps.unicode == .unicode and !self.caps.explicit_width) { try tty.writeAll(ctlseqs.unicode_set); } }, } try tty.flush(); } // the next render call will refresh the entire screen pub fn queueRefresh(self: *Vaxis) void { self.refresh = true; } /// draws the screen to the terminal pub fn render(self: *Vaxis, tty: *std.Io.Writer) !void { defer self.refresh = false; assert(self.screen.buf.len == @as(usize, @intCast(self.screen.width)) * self.screen.height); // correct size assert(self.screen.buf.len == self.screen_last.buf.len); // same size var started: bool = false; var sync_active: bool = false; errdefer if (sync_active) tty.writeAll(ctlseqs.sync_reset) catch {}; const cursor_vis_changed = self.screen.cursor_vis != self.screen_last.cursor_vis; const cursor_shape_changed = self.screen.cursor_shape != self.screen_last.cursor_shape; const mouse_shape_changed = self.screen.mouse_shape != self.screen_last.mouse_shape; const cursor_pos_changed = self.screen.cursor_vis and (self.screen.cursor.row != self.state.cursor.row or self.screen.cursor.col != self.state.cursor.col); const cursor_secondary_changed = self.screen.cursor_vis and std.meta.eql(self.screen.cursor_secondary, self.state.cursor_secondary); const needs_render = self.refresh or cursor_vis_changed or cursor_shape_changed or mouse_shape_changed or cursor_pos_changed or cursor_secondary_changed; // initialize some variables var reposition: bool = false; var row: u16 = 0; var col: u16 = 0; var cursor: Style = .{}; var link: Hyperlink = .{}; const CursorPos = struct { row: u16 = 0, col: u16 = 0, }; var cursor_pos: CursorPos = .{}; const startRender = struct { fn run( vx: *Vaxis, io: *std.Io.Writer, cursor_pos_ptr: *CursorPos, reposition_ptr: *bool, started_ptr: *bool, sync_active_ptr: *bool, ) !void { if (started_ptr.*) return; started_ptr.* = true; sync_active_ptr.* = true; // Set up sync before we write anything try io.writeAll(ctlseqs.sync_set); // Send the cursor to 0,0 try io.writeAll(ctlseqs.hide_cursor); if (vx.state.alt_screen) try io.writeAll(ctlseqs.home) else { try io.writeByte('\r'); for (0..vx.state.cursor.row) |_| { try io.writeAll(ctlseqs.ri); } } try io.writeAll(ctlseqs.sgr_reset); cursor_pos_ptr.* = .{}; reposition_ptr.* = true; // Clear all images if (vx.caps.kitty_graphics) try io.writeAll(ctlseqs.kitty_graphics_clear); } }; // Reset skip flag on all last_screen cells for (self.screen_last.buf) |*last_cell| { last_cell.skip = false; } if (needs_render) { try startRender.run(self, tty, &cursor_pos, &reposition, &started, &sync_active); } var i: usize = 0; while (i < self.screen.buf.len) { const cell = self.screen.buf[i]; const w: u16 = blk: { if (cell.char.width != 0) break :blk cell.char.width; const method: gwidth.Method = self.caps.unicode; const width: u16 = @intCast(gwidth.gwidth(cell.char.grapheme, method)); break :blk @max(1, width); }; defer { // advance by the width of this char mod 1 std.debug.assert(w > 0); var j = i + 1; while (j < i + w) : (j += 1) { if (j >= self.screen_last.buf.len) break; self.screen_last.buf[j].skipped = true; } col += w; i += w; } if (col >= self.screen.width) { row += 1; col = 0; // Rely on terminal wrapping to reposition into next row instead of forcing it if (!cell.wrapped) reposition = true; } // If cell is the same as our last frame, we don't need to do // anything const last = self.screen_last.buf[i]; if ((!self.refresh and last.eql(cell) and !last.skipped and cell.image == null) or last.skip) { reposition = true; // Close any osc8 sequence we might be in before // repositioning if (link.uri.len > 0) { try tty.writeAll(ctlseqs.osc8_clear); } continue; } if (!started) { try startRender.run(self, tty, &cursor_pos, &reposition, &started, &sync_active); } self.screen_last.buf[i].skipped = false; defer { cursor = cell.style; link = cell.link; } // Set this cell in the last frame self.screen_last.writeCell(col, row, cell); // If we support scaled text, we set the flags now if (self.caps.scaled_text and cell.scale.scale > 1) { // The cell is scaled. Set appropriate skips. We only need to do this if the scale factor is // > 1 assert(cell.char.width > 0); const cols = cell.scale.scale * cell.char.width; const rows = cell.scale.scale; for (0..rows) |skipped_row| { for (0..cols) |skipped_col| { if (skipped_row == 0 and skipped_col == 0) { continue; } const skipped_i = (@as(usize, @intCast(skipped_row + row)) * self.screen_last.width) + (skipped_col + col); self.screen_last.buf[skipped_i].skip = true; } } } // reposition the cursor, if needed if (reposition) { reposition = false; link = .{}; if (self.state.alt_screen) try tty.print(ctlseqs.cup, .{ row + 1, col + 1 }) else { if (cursor_pos.row == row) { const n = col - cursor_pos.col; if (n > 0) try tty.print(ctlseqs.cuf, .{n}); } else { const n = row - cursor_pos.row; for (0..n) |_| { try tty.writeByte('\n'); } try tty.writeByte('\r'); if (col > 0) try tty.print(ctlseqs.cuf, .{col}); } } } if (cell.image) |img| { try tty.print( ctlseqs.kitty_graphics_preamble, .{img.img_id}, ); if (img.options.pixel_offset) |offset| { try tty.print( ",X={d},Y={d}", .{ offset.x, offset.y }, ); } if (img.options.clip_region) |clip| { if (clip.x) |x| try tty.print(",x={d}", .{x}); if (clip.y) |y| try tty.print(",y={d}", .{y}); if (clip.width) |width| try tty.print(",w={d}", .{width}); if (clip.height) |height| try tty.print(",h={d}", .{height}); } if (img.options.size) |size| { if (size.rows) |rows| try tty.print(",r={d}", .{rows}); if (size.cols) |cols| try tty.print(",c={d}", .{cols}); } if (img.options.z_index) |z| { try tty.print(",z={d}", .{z}); } try tty.writeAll(ctlseqs.kitty_graphics_closing); } // something is different, so let's loop through everything and // find out what // foreground if (!self.caps.no_color and !Cell.Color.eql(cursor.fg, cell.style.fg)) { switch (cell.style.fg) { .default => try tty.writeAll(ctlseqs.fg_reset), .index => |idx| { switch (idx) { 0...7 => try tty.print(ctlseqs.fg_base, .{idx}), 8...15 => try tty.print(ctlseqs.fg_bright, .{idx - 8}), else => { switch (self.sgr) { .standard => try tty.print(ctlseqs.fg_indexed, .{idx}), .legacy => try tty.print(ctlseqs.fg_indexed_legacy, .{idx}), } }, } }, .rgb => |rgb| { switch (self.sgr) { .standard => try tty.print(ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }), .legacy => try tty.print(ctlseqs.fg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), } }, } } // background if (!self.caps.no_color and !Cell.Color.eql(cursor.bg, cell.style.bg)) { switch (cell.style.bg) { .default => try tty.writeAll(ctlseqs.bg_reset), .index => |idx| { switch (idx) { 0...7 => try tty.print(ctlseqs.bg_base, .{idx}), 8...15 => try tty.print(ctlseqs.bg_bright, .{idx - 8}), else => { switch (self.sgr) { .standard => try tty.print(ctlseqs.bg_indexed, .{idx}), .legacy => try tty.print(ctlseqs.bg_indexed_legacy, .{idx}), } }, } }, .rgb => |rgb| { switch (self.sgr) { .standard => try tty.print(ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }), .legacy => try tty.print(ctlseqs.bg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), } }, } } // underline color if (!self.caps.no_color and !Cell.Color.eql(cursor.ul, cell.style.ul)) { switch (cell.style.ul) { .default => try tty.writeAll(ctlseqs.ul_reset), .index => |idx| { switch (self.sgr) { .standard => try tty.print(ctlseqs.ul_indexed, .{idx}), .legacy => try tty.print(ctlseqs.ul_indexed_legacy, .{idx}), } }, .rgb => |rgb| { if (self.enable_workarounds) try tty.print(ctlseqs.ul_rgb_conpty, .{ rgb[0], rgb[1], rgb[2] }) else switch (self.sgr) { .standard => try tty.print(ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }), .legacy => try tty.print(ctlseqs.ul_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), } }, } } // underline style if (cursor.ul_style != cell.style.ul_style) { const seq = switch (cell.style.ul_style) { .off => ctlseqs.ul_off, .single => ctlseqs.ul_single, .double => ctlseqs.ul_double, .curly => ctlseqs.ul_curly, .dotted => ctlseqs.ul_dotted, .dashed => ctlseqs.ul_dashed, }; try tty.writeAll(seq); } // bold if (cursor.bold != cell.style.bold) { const seq = switch (cell.style.bold) { true => ctlseqs.bold_set, false => ctlseqs.bold_dim_reset, }; try tty.writeAll(seq); if (cell.style.dim) { try tty.writeAll(ctlseqs.dim_set); } } // dim if (cursor.dim != cell.style.dim) { const seq = switch (cell.style.dim) { true => ctlseqs.dim_set, false => ctlseqs.bold_dim_reset, }; try tty.writeAll(seq); if (cell.style.bold) { try tty.writeAll(ctlseqs.bold_set); } } // dim if (cursor.italic != cell.style.italic) { const seq = switch (cell.style.italic) { true => ctlseqs.italic_set, false => ctlseqs.italic_reset, }; try tty.writeAll(seq); } // dim if (cursor.blink != cell.style.blink) { const seq = switch (cell.style.blink) { true => ctlseqs.blink_set, false => ctlseqs.blink_reset, }; try tty.writeAll(seq); } // reverse if (cursor.reverse != cell.style.reverse) { const seq = switch (cell.style.reverse) { true => ctlseqs.reverse_set, false => ctlseqs.reverse_reset, }; try tty.writeAll(seq); } // invisible if (cursor.invisible != cell.style.invisible) { const seq = switch (cell.style.invisible) { true => ctlseqs.invisible_set, false => ctlseqs.invisible_reset, }; try tty.writeAll(seq); } // strikethrough if (cursor.strikethrough != cell.style.strikethrough) { const seq = switch (cell.style.strikethrough) { true => ctlseqs.strikethrough_set, false => ctlseqs.strikethrough_reset, }; try tty.writeAll(seq); } // url if (!std.mem.eql(u8, link.uri, cell.link.uri)) { var ps = cell.link.params; if (cell.link.uri.len == 0) { // Empty out the params no matter what if we don't have // a url ps = ""; } try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri }); } // scale if (self.caps.scaled_text and !cell.scale.eql(.{})) { const scale = cell.scale; // We have a scaled cell. switch (cell.scale.denominator) { // Denominator cannot be 0 0 => unreachable, 1 => { // no fractional scaling, just a straight scale factor try tty.print( ctlseqs.scaled_text, .{ scale.scale, w, cell.char.grapheme }, ); }, else => { // fractional scaling // no fractional scaling, just a straight scale factor try tty.print( ctlseqs.scaled_text_with_fractions, .{ scale.scale, w, scale.numerator, scale.denominator, @backingInt(scale.vertical_alignment), cell.char.grapheme, }, ); }, } cursor_pos.col = col + (w * scale.scale); cursor_pos.row = row; continue; } // If we have explicit width and our width is greater than 1, let's use it if (self.caps.explicit_width and w > 1) { try tty.print(ctlseqs.explicit_width, .{ w, cell.char.grapheme }); } else { try tty.writeAll(cell.char.grapheme); } cursor_pos.col = col + w; cursor_pos.row = row; } if (!started) return; if (self.screen.cursor_vis) { if (self.state.alt_screen) { try tty.print( ctlseqs.cup, .{ self.screen.cursor.row + 1, self.screen.cursor.col + 1, }, ); } else { // TODO: position cursor relative to current location try tty.writeByte('\r'); if (self.screen.cursor.row >= cursor_pos.row) { for (0..(self.screen.cursor.row - cursor_pos.row)) |_| { try tty.writeByte('\n'); } } else { for (0..(cursor_pos.row - self.screen.cursor.row)) |_| { try tty.writeAll(ctlseqs.ri); } } if (self.screen.cursor.col > 0) try tty.print(ctlseqs.cuf, .{self.screen.cursor.col}); } self.state.cursor.row = self.screen.cursor.row; self.state.cursor.col = self.screen.cursor.col; try tty.writeAll(ctlseqs.show_cursor); } else { self.state.cursor.row = cursor_pos.row; self.state.cursor.col = cursor_pos.col; } if (self.screen.cursor_vis and self.caps.multi_cursor) { try tty.print(ctlseqs.reset_secondary_cursors, .{}); for (self.screen.cursor_secondary) |cur| try tty.print(ctlseqs.show_secondary_cursor, .{ cur.row + 1, cur.col + 1 }); if (cursor_secondary_changed) { self.state.prev_cursor_secondary = self.state.cursor_secondary; self.state.cursor_secondary = self.screen.cursor_secondary; } } self.screen_last.cursor_vis = self.screen.cursor_vis; if (self.screen.mouse_shape != self.screen_last.mouse_shape) { try tty.print( ctlseqs.osc22_mouse_shape, .{@tagName(self.screen.mouse_shape)}, ); self.screen_last.mouse_shape = self.screen.mouse_shape; } if (self.screen.cursor_shape != self.screen_last.cursor_shape) { try tty.print( ctlseqs.cursor_shape, .{@backingInt(self.screen.cursor_shape)}, ); self.screen_last.cursor_shape = self.screen.cursor_shape; } try tty.writeAll(ctlseqs.sync_reset); try tty.flush(); } fn enableKittyKeyboard(self: *Vaxis, tty: *std.Io.Writer, flags: Key.KittyFlags) !void { const flag_int: u5 = @bitCast(flags); try tty.print(ctlseqs.csi_u_push, .{flag_int}); try tty.flush(); self.state.kitty_keyboard = true; } /// send a system notification pub fn notify(_: *Vaxis, tty: *std.Io.Writer, title: ?[]const u8, body: []const u8) !void { if (title) |t| try tty.print(ctlseqs.osc777_notify, .{ t, body }) else try tty.print(ctlseqs.osc9_notify, .{body}); try tty.flush(); } /// sets the window title pub fn setTitle(_: *Vaxis, tty: *std.Io.Writer, title: []const u8) !void { try tty.print(ctlseqs.osc2_set_title, .{title}); try tty.flush(); } /// A Program Status Protocol (OSC 7501) report, telling the terminal what /// the program is doing. Each report replaces its record completely, so send /// every field the record should keep (such as `app` and `title`) each time. /// See https://www.superlogical.com/rex/docs/build/program-status pub const ProgramStatus = struct { pub const State = enum { idle, working, done, blocked, @"error" }; pub const Kind = enum { permission, question, auth }; state: State, /// Record id: `/`-separated segments of `[A-Za-z0-9_.+-]{1,32}`, at most /// 8 levels deep and 128 bytes long. Null addresses the root record. id: ?[]const u8 = null, /// What the user must do. Only sent with `.blocked`. kind: ?Kind = null, /// Percent complete, clamped to 100. Only sent with `.working` and /// `.blocked`. Null means unknown. progress: ?u8 = null, /// Stable machine-readable program name, `[A-Za-z0-9_.+-]{1,32}`. app: ?[]const u8 = null, /// Short human-readable label. Control characters are removed and the /// text is truncated to 192 bytes. title: ?[]const u8 = null, /// One human-readable line. Control characters are removed and the text /// is truncated to 2048 bytes. msg: ?[]const u8 = null, const max_id_len = 128; const max_id_depth = 8; const max_name_len = 32; const max_title_len = 192; const max_msg_len = 2048; /// Matches `[A-Za-z0-9_.+-]{1,32}`, the grammar of id segments and app fn isName(name: []const u8) bool { if (name.len == 0 or name.len > max_name_len) return false; for (name) |b| switch (b) { 'A'...'Z', 'a'...'z', '0'...'9', '_', '.', '+', '-' => {}, else => return false, }; return true; } fn validateId(id: []const u8) error{InvalidProgramStatusId}!void { if (id.len > max_id_len) return error.InvalidProgramStatusId; var depth: usize = 0; var segments = std.mem.splitScalar(u8, id, '/'); while (segments.next()) |segment| { depth += 1; if (depth > max_id_depth or !isName(segment)) return error.InvalidProgramStatusId; } } /// Copies text into buf as valid UTF-8 without control characters, /// stopping at the last whole codepoint that fits. Invalid UTF-8 becomes /// U+FFFD. fn sanitizeText(buf: []u8, text: []const u8) []const u8 { const replacement = &std.unicode.replacement_character_utf8; var n: usize = 0; var i: usize = 0; while (i < text.len) { var bytes: []const u8 = replacement; var cp_len: usize = 1; if (std.unicode.utf8ByteSequenceLength(text[i])) |len| { if (i + len <= text.len) { if (std.unicode.utf8Decode(text[i..][0..len])) |cp| { cp_len = len; bytes = text[i..][0..len]; if (cp < 0x20 or (cp >= 0x7f and cp <= 0x9f)) bytes = ""; } else |_| {} } } else |_| {} i += cp_len; if (n + bytes.len > buf.len) break; @memcpy(buf[n..][0..bytes.len], bytes); n += bytes.len; } return buf[0..n]; } fn writeText(tty: *std.Io.Writer, comptime key: []const u8, comptime max_len: usize, text: []const u8) !void { var buf: [max_len]u8 = undefined; const clean = sanitizeText(&buf, text); if (clean.len == 0) return; try tty.writeAll(":" ++ key ++ "="); try base64Encoder.encodeWriter(tty, clean); } }; /// Sends a Program Status Protocol (OSC 7501) report. Terminals that support /// the protocol set caps.program_status in response to queryTerminal. /// Returns an error, without writing anything, if `id` or `app` is invalid. pub fn reportProgramStatus(_: *Vaxis, tty: *std.Io.Writer, status: ProgramStatus) !void { if (status.id) |id| try ProgramStatus.validateId(id); if (status.app) |app| if (!ProgramStatus.isName(app)) return error.InvalidProgramStatusApp; try tty.print(ctlseqs.osc7501_program_status, .{@tagName(status.state)}); if (status.id) |id| try tty.print(":id={s}", .{id}); if (status.state == .blocked) if (status.kind) |kind| try tty.print(":kind={s}", .{@tagName(kind)}); if (status.state == .working or status.state == .blocked) if (status.progress) |progress| try tty.print(":progress={d}", .{@min(progress, 100)}); if (status.app) |app| try tty.print(":app={s}", .{app}); if (status.title) |title| try ProgramStatus.writeText(tty, "title", ProgramStatus.max_title_len, title); if (status.msg) |msg| try ProgramStatus.writeText(tty, "msg", ProgramStatus.max_msg_len, msg); try tty.writeAll("\x1b\\"); try tty.flush(); } /// Removes a Program Status Protocol (OSC 7501) record and every record /// beneath it. A null id removes every record on the terminal. pub fn clearProgramStatus(_: *Vaxis, tty: *std.Io.Writer, id: ?[]const u8) !void { if (id) |i| try ProgramStatus.validateId(i); try tty.print(ctlseqs.osc7501_program_status, .{"clear"}); if (id) |i| try tty.print(":id={s}", .{i}); try tty.writeAll("\x1b\\"); try tty.flush(); } // turn bracketed paste on or off. An event will be sent at the // beginning and end of a detected paste. All keystrokes between these // events were pasted pub fn setBracketedPaste(self: *Vaxis, tty: *std.Io.Writer, enable: bool) !void { const seq = if (enable) ctlseqs.bp_set else ctlseqs.bp_reset; try tty.writeAll(seq); try tty.flush(); self.state.bracketed_paste = enable; } /// set the mouse shape pub fn setMouseShape(self: *Vaxis, shape: Shape) void { self.screen.mouse_shape = shape; } /// Change the mouse reporting mode pub fn setMouseMode(self: *Vaxis, tty: *std.Io.Writer, enable: bool) !void { if (enable) { self.state.mouse = true; if (self.caps.sgr_pixels) { log.debug("enabling mouse mode: pixel coordinates", .{}); self.state.pixel_mouse = true; try tty.writeAll(ctlseqs.mouse_set_pixels); } else { log.debug("enabling mouse mode: cell coordinates", .{}); try tty.writeAll(ctlseqs.mouse_set); } } else { try tty.writeAll(ctlseqs.mouse_reset); } try tty.flush(); } /// Translate pixel mouse coordinates to cell + offset pub fn translateMouse(self: Vaxis, mouse: Mouse) Mouse { if (self.screen.width == 0 or self.screen.height == 0) return mouse; var result = mouse; if (self.state.pixel_mouse) { std.debug.assert(mouse.xoffset == 0); std.debug.assert(mouse.yoffset == 0); const xpos = mouse.col; const ypos = mouse.row; const xextra = self.screen.width_pix % self.screen.width; const yextra = self.screen.height_pix % self.screen.height; const xcell: i16 = @intCast((self.screen.width_pix - xextra) / self.screen.width); const ycell: i16 = @intCast((self.screen.height_pix - yextra) / self.screen.height); if (xcell == 0 or ycell == 0) return mouse; result.col = @divFloor(xpos, xcell); result.row = @divFloor(ypos, ycell); result.xoffset = @intCast(@mod(xpos, xcell)); result.yoffset = @intCast(@mod(ypos, ycell)); } return result; } /// Transmit an image using the local filesystem. Allocates only for base64 encoding pub fn transmitLocalImagePath( self: *Vaxis, allocator: std.mem.Allocator, tty: *std.Io.Writer, payload: []const u8, width: u16, height: u16, medium: Image.TransmitMedium, format: Image.TransmitFormat, ) !Image { if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; defer self.next_img_id += 1; const id = self.next_img_id; const size = base64Encoder.calcSize(payload.len); if (size >= 4096) return error.PathTooLong; const buf = try allocator.alloc(u8, size); const encoded = base64Encoder.encode(buf, payload); defer allocator.free(buf); const medium_char: u8 = switch (medium) { .file => 'f', .temp_file => 't', .shared_mem => 's', }; switch (format) { .rgb => { try tty.print( "\x1b_Gf=24,s={d},v={d},i={d},t={c};{s}\x1b\\", .{ width, height, id, medium_char, encoded }, ); }, .rgba => { try tty.print( "\x1b_Gf=32,s={d},v={d},i={d},t={c};{s}\x1b\\", .{ width, height, id, medium_char, encoded }, ); }, .png => { try tty.print( "\x1b_Gf=100,i={d},t={c};{s}\x1b\\", .{ id, medium_char, encoded }, ); }, } try tty.flush(); return .{ .id = id, .width = width, .height = height, }; } /// Transmit an image which has been pre-base64 encoded pub fn transmitPreEncodedImage( self: *Vaxis, tty: *std.Io.Writer, bytes: []const u8, width: u16, height: u16, format: Image.TransmitFormat, ) !Image { if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; defer self.next_img_id += 1; const id = self.next_img_id; const fmt: u8 = switch (format) { .rgb => 24, .rgba => 32, .png => 100, }; if (bytes.len < 4096) { try tty.print( "\x1b_Gf={d},s={d},v={d},i={d};{s}\x1b\\", .{ fmt, width, height, id, bytes, }, ); } else { var n: usize = 4096; try tty.print( "\x1b_Gf={d},s={d},v={d},i={d},m=1;{s}\x1b\\", .{ fmt, width, height, id, bytes[0..n] }, ); while (n < bytes.len) : (n += 4096) { const end: usize = @min(n + 4096, bytes.len); const m: u2 = if (end == bytes.len) 0 else 1; try tty.print( "\x1b_Gm={d};{s}\x1b\\", .{ m, bytes[n..end], }, ); } } try tty.flush(); return .{ .id = id, .width = width, .height = height, }; } /// Transmit an image: for `.png`, `bytes` is a PNG file, and for `.rgb` and /// `.rgba` it is `width` by `height` pixels with no padding between rows. /// The terminal decodes the image; vaxis does not. pub fn transmitImage( self: *Vaxis, alloc: std.mem.Allocator, tty: *std.Io.Writer, bytes: []const u8, width: u16, height: u16, format: Image.TransmitFormat, ) !Image { if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; switch (format) { .rgb, .rgba => { const bytes_per_pixel: usize = if (format == .rgb) 3 else 4; if (bytes.len != @as(usize, width) * height * bytes_per_pixel) return error.InvalidImageSize; }, .png => {}, } const encoded = try alloc.alloc(u8, base64Encoder.calcSize(bytes.len)); defer alloc.free(encoded); _ = base64Encoder.encode(encoded, bytes); return self.transmitPreEncodedImage(tty, encoded, width, height, format); } /// Transmit a PNG file. The terminal decodes it; vaxis reads only its size /// from the header. Other formats are `error.UnsupportedImageFormat`: decode /// them with an image library and transmit the pixels with `transmitImage`. pub fn loadImage( self: *Vaxis, alloc: std.mem.Allocator, tty: *std.Io.Writer, src: Image.Source, ) !Image { if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; const bytes = switch (src) { .path => |path| try std.Io.Dir.cwd().readFileAlloc(self.io, path, alloc, .unlimited), .mem => |bytes| bytes, }; defer if (src == .path) alloc.free(bytes); // A PNG starts with its signature and then the IHDR chunk, whose data // begins with the width and height as big-endian u32s. if (bytes.len < 24 or !std.mem.eql(u8, bytes[0..8], "\x89PNG\r\n\x1a\n") or !std.mem.eql(u8, bytes[12..16], "IHDR")) return error.UnsupportedImageFormat; const width = std.mem.readInt(u32, bytes[16..20], .big); const height = std.mem.readInt(u32, bytes[20..24], .big); if (width > std.math.maxInt(u16) or height > std.math.maxInt(u16)) return error.ImageTooLarge; return self.transmitImage(alloc, tty, bytes, @intCast(width), @intCast(height), .png); } /// deletes an image from the terminal's memory pub fn freeImage(_: Vaxis, tty: *std.Io.Writer, id: u32) void { tty.print("\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| { log.err("couldn't delete image {d}: {}", .{ id, err }); return; }; tty.flush() catch {}; } pub fn copyToSystemClipboard(_: Vaxis, tty: *std.Io.Writer, text: []const u8, encode_allocator: std.mem.Allocator) !void { const encoder = std.base64.standard.Encoder; const size = encoder.calcSize(text.len); const buf = try encode_allocator.alloc(u8, size); const b64 = encoder.encode(buf, text); defer encode_allocator.free(buf); try tty.print( ctlseqs.osc52_clipboard_copy, .{b64}, ); try tty.flush(); } pub fn requestSystemClipboard(self: Vaxis, tty: *std.Io.Writer) !void { if (self.opts.system_clipboard_allocator == null) return error.NoClipboardAllocator; try tty.print( ctlseqs.osc52_clipboard_request, .{}, ); try tty.flush(); } /// Set the default terminal foreground color pub fn setTerminalForegroundColor(self: *Vaxis, tty: *std.Io.Writer, rgb: [3]u8) !void { try tty.print(ctlseqs.osc10_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] }); try tty.flush(); self.state.changed_default_fg = true; } /// Set the default terminal background color pub fn setTerminalBackgroundColor(self: *Vaxis, tty: *std.Io.Writer, rgb: [3]u8) !void { try tty.print(ctlseqs.osc11_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] }); try tty.flush(); self.state.changed_default_bg = true; } /// Set the terminal cursor color pub fn setTerminalCursorColor(self: *Vaxis, tty: *std.Io.Writer, rgb: [3]u8) !void { try tty.print(ctlseqs.osc12_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] }); try tty.flush(); self.state.changed_cursor_color = true; } /// Set the terminal secondary cursor color pub fn setTerminalCursorSecondaryColor(self: *Vaxis, tty: *std.Io.Writer, rgb: [3]u8) error{WriteFailed}!void { if (self.caps.multi_cursor) { try tty.print(ctlseqs.secondary_cursors_rgb, .{ rgb[0], rgb[1], rgb[2] }); try tty.flush(); self.state.changed_cursor_color = true; } } pub fn resetAllTerminalSecondaryCursors(self: *Vaxis, alloc: std.mem.Allocator) error{OutOfMemory}!void { if (self.state.prev_cursor_secondary.ptr != self.state.cursor_secondary.ptr) { alloc.free(self.state.prev_cursor_secondary); self.state.prev_cursor_secondary = &.{}; } if (self.screen.cursor_secondary.ptr != self.state.cursor_secondary.ptr) alloc.free(self.screen.cursor_secondary); self.screen.cursor_secondary = &.{}; } pub fn addTerminalSecondaryCursor(self: *Vaxis, alloc: std.mem.Allocator, y: u16, x: u16) error{OutOfMemory}!void { if (self.state.prev_cursor_secondary.ptr != self.state.cursor_secondary.ptr) { alloc.free(self.state.prev_cursor_secondary); self.state.prev_cursor_secondary = &.{}; } var cursors: std.ArrayList(Screen.Cursor) = if (self.screen.cursor_secondary.ptr == self.state.cursor_secondary.ptr) .fromOwnedSlice(try alloc.dupe(Cursor, self.screen.cursor_secondary)) else .fromOwnedSlice(self.screen.cursor_secondary); (try cursors.addOne(alloc)).* = .{ .row = y, .col = x }; self.screen.cursor_secondary = try cursors.toOwnedSlice(alloc); } /// Request the terminal cursor position. Add `cursor_position: vaxis.Screen.Cursor` /// to your Event union to receive the zero-based row and column asynchronously. /// Multiple requests may be outstanding. Complete terminal capability queries first. /// Pending requests expire lazily one second after the last request. No timer is /// started. While a request is pending, legacy F3 sequences that look like reports /// are ambiguous; late replies cannot be distinguished from keys or newer replies. pub fn queryCursorPosition(self: *Vaxis, tty: *std.Io.Writer) !void { if (!self.queries_done.load(.unordered)) return error.TerminalQueriesPending; self.cursor_position_requests.request(); tty.writeAll(ctlseqs.cursor_position_request) catch |err| { _ = self.cursor_position_requests.consume(); return err; }; // Once buffered, the request may reach the terminal even if flushing fails. try tty.flush(); } /// Request a color report from the terminal. Note: not all terminals support /// reporting colors. It is always safe to try, but you may not receive a /// response. pub fn queryColor(_: Vaxis, tty: *std.Io.Writer, kind: Cell.Color.Kind) !void { switch (kind) { .fg => try tty.writeAll(ctlseqs.osc10_query), .bg => try tty.writeAll(ctlseqs.osc11_query), .cursor => try tty.writeAll(ctlseqs.osc12_query), .index => |idx| try tty.print(ctlseqs.osc4_query, .{idx}), } try tty.flush(); } /// Subscribe to color theme updates. A `color_scheme: Color.Scheme` tag must /// exist on your Event type to receive the response. This is a queried /// capability. Support can be detected by checking the value of /// vaxis.caps.color_scheme_updates. The initial scheme will be reported when /// subscribing. pub fn subscribeToColorSchemeUpdates(self: *Vaxis, tty: *std.Io.Writer) !void { try tty.writeAll(ctlseqs.color_scheme_request); try tty.writeAll(ctlseqs.color_scheme_set); try tty.flush(); self.state.color_scheme_updates = true; } pub fn deviceStatusReport(_: Vaxis, tty: *std.Io.Writer) !void { try tty.writeAll(ctlseqs.device_status_report); try tty.flush(); } /// prettyPrint is used to print the contents of the Screen to the tty. The state is not stored, and /// the cursor will be put on the next line after the last line is printed. This is useful to /// sequentially print data in a styled format to eg. stdout. This function returns an error if you /// are not in the alt screen. The cursor is always hidden, and mouse shapes are not available pub fn prettyPrint(self: *Vaxis, tty: *std.Io.Writer) !void { if (self.state.alt_screen) return error.NotInPrimaryScreen; try tty.writeAll(ctlseqs.hide_cursor); try tty.writeAll(ctlseqs.sync_set); defer tty.writeAll(ctlseqs.sync_reset) catch {}; try tty.writeAll(ctlseqs.sgr_reset); defer tty.writeAll(ctlseqs.sgr_reset) catch {}; var reposition: bool = false; var row: u16 = 0; var col: u16 = 0; var cursor: Style = .{}; var link: Hyperlink = .{}; var cursor_pos: struct { row: u16 = 0, col: u16 = 0, } = .{}; var i: u16 = 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); break :blk @max(1, width); }; defer { // advance by the width of this char mod 1 std.debug.assert(w > 0); var j = i + 1; while (j < i + w) : (j += 1) { if (j >= self.screen_last.buf.len) break; self.screen_last.buf[j].skipped = true; } col += w; i += w; } if (col >= self.screen.width) { row += 1; col = 0; // Rely on terminal wrapping to reposition into next row instead of forcing it if (!cell.wrapped) reposition = true; } if (cell.default) { reposition = true; continue; } defer { cursor = cell.style; link = cell.link; } // reposition the cursor, if needed if (reposition) { reposition = false; link = .{}; if (cursor_pos.row == row) { const n = col - cursor_pos.col; if (n > 0) try tty.print(ctlseqs.cuf, .{n}); } else { const n = row - cursor_pos.row; for (0..n) |_| { try tty.writeByte('\n'); } try tty.writeByte('\r'); if (col > 0) try tty.print(ctlseqs.cuf, .{col}); } } if (cell.image) |img| { try tty.print( ctlseqs.kitty_graphics_preamble, .{img.img_id}, ); if (img.options.pixel_offset) |offset| { try tty.print( ",X={d},Y={d}", .{ offset.x, offset.y }, ); } if (img.options.clip_region) |clip| { if (clip.x) |x| try tty.print(",x={d}", .{x}); if (clip.y) |y| try tty.print(",y={d}", .{y}); if (clip.width) |width| try tty.print(",w={d}", .{width}); if (clip.height) |height| try tty.print(",h={d}", .{height}); } if (img.options.size) |size| { if (size.rows) |rows| try tty.print(",r={d}", .{rows}); if (size.cols) |cols| try tty.print(",c={d}", .{cols}); } if (img.options.z_index) |z| { try tty.print(",z={d}", .{z}); } try tty.writeAll(ctlseqs.kitty_graphics_closing); } // something is different, so let's loop through everything and // find out what // foreground if (!self.caps.no_color and !Cell.Color.eql(cursor.fg, cell.style.fg)) { switch (cell.style.fg) { .default => try tty.writeAll(ctlseqs.fg_reset), .index => |idx| { switch (idx) { 0...7 => try tty.print(ctlseqs.fg_base, .{idx}), 8...15 => try tty.print(ctlseqs.fg_bright, .{idx - 8}), else => { switch (self.sgr) { .standard => try tty.print(ctlseqs.fg_indexed, .{idx}), .legacy => try tty.print(ctlseqs.fg_indexed_legacy, .{idx}), } }, } }, .rgb => |rgb| { switch (self.sgr) { .standard => try tty.print(ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }), .legacy => try tty.print(ctlseqs.fg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), } }, } } // background if (!self.caps.no_color and !Cell.Color.eql(cursor.bg, cell.style.bg)) { switch (cell.style.bg) { .default => try tty.writeAll(ctlseqs.bg_reset), .index => |idx| { switch (idx) { 0...7 => try tty.print(ctlseqs.bg_base, .{idx}), 8...15 => try tty.print(ctlseqs.bg_bright, .{idx - 8}), else => { switch (self.sgr) { .standard => try tty.print(ctlseqs.bg_indexed, .{idx}), .legacy => try tty.print(ctlseqs.bg_indexed_legacy, .{idx}), } }, } }, .rgb => |rgb| { switch (self.sgr) { .standard => try tty.print(ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }), .legacy => try tty.print(ctlseqs.bg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }), } }, } } // underline color if (!self.caps.no_color and !Cell.Color.eql(cursor.ul, cell.style.ul)) { switch (cell.style.ul) { .default => try tty.writeAll(ctlseqs.ul_reset), .index => |idx| { switch (self.sgr) { .standard => try tty.print(ctlseqs.ul_indexed, .{idx}), .legacy => try tty.print(ctlseqs.ul_indexed_legacy, .{idx}), } }, .rgb => |rgb| { if (self.enable_workarounds) try tty.print(ctlseqs.ul_rgb_conpty, .{ rgb[0], rgb[1], rgb[2] }) else switch (self.sgr) { .standard => try tty.print(ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }), .legacy => { try tty.print(ctlseqs.ul_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }); }, } }, } } // underline style if (cursor.ul_style != cell.style.ul_style) { const seq = switch (cell.style.ul_style) { .off => ctlseqs.ul_off, .single => ctlseqs.ul_single, .double => ctlseqs.ul_double, .curly => ctlseqs.ul_curly, .dotted => ctlseqs.ul_dotted, .dashed => ctlseqs.ul_dashed, }; try tty.writeAll(seq); } // bold if (cursor.bold != cell.style.bold) { const seq = switch (cell.style.bold) { true => ctlseqs.bold_set, false => ctlseqs.bold_dim_reset, }; try tty.writeAll(seq); if (cell.style.dim) { try tty.writeAll(ctlseqs.dim_set); } } // dim if (cursor.dim != cell.style.dim) { const seq = switch (cell.style.dim) { true => ctlseqs.dim_set, false => ctlseqs.bold_dim_reset, }; try tty.writeAll(seq); if (cell.style.bold) { try tty.writeAll(ctlseqs.bold_set); } } // dim if (cursor.italic != cell.style.italic) { const seq = switch (cell.style.italic) { true => ctlseqs.italic_set, false => ctlseqs.italic_reset, }; try tty.writeAll(seq); } // dim if (cursor.blink != cell.style.blink) { const seq = switch (cell.style.blink) { true => ctlseqs.blink_set, false => ctlseqs.blink_reset, }; try tty.writeAll(seq); } // reverse if (cursor.reverse != cell.style.reverse) { const seq = switch (cell.style.reverse) { true => ctlseqs.reverse_set, false => ctlseqs.reverse_reset, }; try tty.writeAll(seq); } // invisible if (cursor.invisible != cell.style.invisible) { const seq = switch (cell.style.invisible) { true => ctlseqs.invisible_set, false => ctlseqs.invisible_reset, }; try tty.writeAll(seq); } // strikethrough if (cursor.strikethrough != cell.style.strikethrough) { const seq = switch (cell.style.strikethrough) { true => ctlseqs.strikethrough_set, false => ctlseqs.strikethrough_reset, }; try tty.writeAll(seq); } // url if (!std.mem.eql(u8, link.uri, cell.link.uri)) { var ps = cell.link.params; if (cell.link.uri.len == 0) { // Empty out the params no matter what if we don't have // a url ps = ""; } try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri }); } try tty.writeAll(cell.char.grapheme); cursor_pos.col = col + w; cursor_pos.row = row; } try tty.writeAll("\r\n"); try tty.flush(); } /// Set the terminal's current working directory pub fn setTerminalWorkingDirectory(_: *Vaxis, tty: *std.Io.Writer, path: []const u8) !void { if (path.len == 0 or path[0] != '/') return error.InvalidAbsolutePath; const hostname = switch (builtin.os.tag) { .windows => null, else => std.posix.getenv("HOSTNAME"), } orelse "localhost"; const uri: std.Uri = .{ .scheme = "file", .host = .{ .raw = hostname }, .path = .{ .raw = path }, }; try tty.print(ctlseqs.osc7, .{uri.fmt(.{ .scheme = true, .authority = true, .path = true })}); try tty.flush(); } test "queryCursorPosition: failed write does not leave a pending request" { var env = try std.testing.environ.createMap(std.testing.allocator); defer env.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); var failing: std.Io.Writer = .failing; try std.testing.expectError(error.WriteFailed, vx.queryCursorPosition(&failing)); try std.testing.expectEqual(@as(usize, 0), vx.cursor_position_requests.pending()); try vx.queryCursorPosition(&writer.writer); try std.testing.expectError(error.WriteFailed, vx.queryCursorPosition(&failing)); try std.testing.expectEqual(@as(usize, 1), vx.cursor_position_requests.pending()); } test "render: no output when no changes" { const io = std.testing.io; var env_map = try std.testing.environ.createMap(std.testing.allocator); defer env_map.deinit(); var vx = try Vaxis.init(io, std.testing.allocator, &env_map, .{}); var deinit_writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer deinit_writer.deinit(); defer vx.deinit(std.testing.allocator, &deinit_writer.writer); var render_writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer render_writer.deinit(); try vx.render(&render_writer.writer); const output = try render_writer.toOwnedSlice(); defer std.testing.allocator.free(output); try std.testing.expectEqual(@as(usize, 0), output.len); } test "resize: hides the cursor when the frame is left empty" { var env_map = try std.testing.environ.createMap(std.testing.allocator); defer env_map.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env_map, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); try vx.enterAltScreen(&writer.writer); try vx.resize(std.testing.allocator, &writer.writer, .{ .rows = 3, .cols = 3, .x_pixel = 0, .y_pixel = 0 }); const win = vx.window(); win.clear(); win.hideCursor(); try vx.render(&writer.writer); const output = writer.written(); const hide = std.mem.lastIndexOf(u8, output, ctlseqs.hide_cursor) orelse return error.TestUnexpectedResult; if (std.mem.lastIndexOf(u8, output, ctlseqs.show_cursor)) |show| try std.testing.expect(show < hide); } test "loadImage: sends a PNG as it is, sized from its header" { const alloc = std.testing.allocator; var env_map = try std.testing.environ.createMap(alloc); defer env_map.deinit(); var vx = try Vaxis.init(std.testing.io, alloc, &env_map, .{}); var writer: std.Io.Writer.Allocating = .init(alloc); defer writer.deinit(); defer vx.deinit(alloc, &writer.writer); vx.caps.kitty_graphics = true; // A 3 by 2 RGBA PNG. const png = "\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d\x49\x48\x44\x52\x00\x00\x00\x03\x00\x00\x00\x02" ++ "\x08\x06\x00\x00\x00\x9d\x74\x66\x1a\x00\x00\x00\x22\x49\x44\x41\x54\x78\x9c\x63\x60\x60\x64\x62\x66" ++ "\x61\x65\x63\xe7\xe0\xe4\xe2\x66\xe0\xe1\xe5\xe3\x17\x10\x14\x12\x16\x11\x15\x13\x07\x00\x09\x58\x01" ++ "\x15\x31\xeb\x63\x99\x00\x00\x00\x00\x49\x45\x4e\x44\xae\x42\x60\x82"; const img = try vx.loadImage(alloc, &writer.writer, .{ .mem = png }); try std.testing.expectEqual(3, img.width); try std.testing.expectEqual(2, img.height); const expected = comptime blk: { var b64: [base64Encoder.calcSize(png.len)]u8 = undefined; _ = base64Encoder.encode(&b64, png); const encoded = b64; break :blk "\x1b_Gf=100,s=3,v=2,i=1;" ++ encoded ++ "\x1b\\"; }; try std.testing.expectEqualStrings(expected, writer.written()); try std.testing.expectError(error.UnsupportedImageFormat, vx.loadImage(alloc, &writer.writer, .{ .mem = "GIF89a" ++ @as([18]u8, @splat(0)) })); } fn testResizeAllocationFailures(allocator: std.mem.Allocator) !void { var env_map = try std.testing.environ.createMap(allocator); defer env_map.deinit(); var vx = try Vaxis.init(std.testing.io, allocator, &env_map, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(allocator, &writer.writer); try vx.resize(allocator, &writer.writer, .{ .rows = 2, .cols = 2, .x_pixel = 0, .y_pixel = 0 }); try vx.resize(allocator, &writer.writer, .{ .rows = 3, .cols = 3, .x_pixel = 0, .y_pixel = 0 }); } test "resize preserves valid state on allocation failure" { try std.testing.checkAllAllocationFailures( std.testing.allocator, testResizeAllocationFailures, .{}, ); } test "queryTerminalSend: program status query precedes DA1" { var env = try std.testing.environ.createMap(std.testing.allocator); defer env.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); try vx.queryTerminalSend(&writer.writer); try std.testing.expect(std.mem.endsWith( u8, writer.written(), ctlseqs.program_status_query ++ ctlseqs.primary_device_attrs, )); } /// Decodes the base64 value of `key` from a single OSC 7501 report fn testProgramStatusText(buf: []u8, report: []const u8, comptime key: []const u8) ![]const u8 { const start = (std.mem.indexOf(u8, report, ":" ++ key ++ "=") orelse return error.MissingKey) + key.len + 2; const end = std.mem.indexOfAnyPos(u8, report, start, ":\x1b").?; const decoder = std.base64.standard.Decoder; const len = try decoder.calcSizeForSlice(report[start..end]); try decoder.decode(buf[0..len], report[start..end]); return buf[0..len]; } /// `s` repeated `n` times, in place of the `s ** n` removed in zig 0.17 inline fn testRepeat(comptime s: []const u8, comptime n: usize) *const [s.len * n]u8 { const repeated: [n][s.len]u8 = @splat(s[0..s.len].*); return comptime std.mem.asBytes(&repeated); } test "reportProgramStatus: writes reports" { var env = try std.testing.environ.createMap(std.testing.allocator); defer env.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); const cases = [_]struct { ProgramStatus, []const u8 }{ // The example from the specification .{ .{ .state = .blocked, .kind = .permission, .app = "terraform", .msg = "Apply 3 to add, 1 to change, 0 to destroy?" }, "\x1b]7501;state=blocked:kind=permission:app=terraform:msg=QXBwbHkgMyB0byBhZGQsIDEgdG8gY2hhbmdlLCAwIHRvIGRlc3Ryb3k/\x1b\\", }, .{ .{ .state = .idle }, "\x1b]7501;state=idle\x1b\\" }, .{ .{ .state = .@"error" }, "\x1b]7501;state=error\x1b\\" }, .{ .{ .state = .working, .id = "us-east", .title = "US East", .progress = 40, .msg = "Pushing image" }, "\x1b]7501;state=working:id=us-east:progress=40:title=VVMgRWFzdA==:msg=UHVzaGluZyBpbWFnZQ==\x1b\\", }, .{ .{ .state = .blocked, .id = "build/test", .kind = .auth, .progress = 0, .app = "cargo" }, "\x1b]7501;state=blocked:id=build/test:kind=auth:progress=0:app=cargo\x1b\\", }, // progress is clamped to 100 .{ .{ .state = .working, .progress = 255 }, "\x1b]7501;state=working:progress=100\x1b\\" }, // kind is only sent with blocked, progress only with working and blocked .{ .{ .state = .working, .kind = .question, .progress = 100 }, "\x1b]7501;state=working:progress=100\x1b\\" }, .{ .{ .state = .done, .kind = .question, .progress = 50 }, "\x1b]7501;state=done\x1b\\" }, .{ .{ .state = .idle, .progress = 50 }, "\x1b]7501;state=idle\x1b\\" }, // empty text, or text that is empty without control characters, is omitted .{ .{ .state = .done, .title = "", .msg = "\x1b\r\n\x7f\u{9b}" }, "\x1b]7501;state=done\x1b\\" }, }; for (cases) |case| { writer.clearRetainingCapacity(); try vx.reportProgramStatus(&writer.writer, case[0]); try std.testing.expectEqualStrings(case[1], writer.written()); } } test "reportProgramStatus: sanitizes and truncates text" { var env = try std.testing.environ.createMap(std.testing.allocator); defer env.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); var buf: [4096]u8 = undefined; // Control characters (C0, DEL, C1) are removed and invalid UTF-8 is replaced try vx.reportProgramStatus(&writer.writer, .{ .state = .done, .title = "a\x00b\tc\x1b[31m\u{85}d", .msg = "ok \xff caf\xc3 \u{1F600}\x7f!", }); try std.testing.expectEqualStrings("abc[31md", try testProgramStatusText(&buf, writer.written(), "title")); try std.testing.expectEqualStrings("ok \u{FFFD} caf\u{FFFD} \u{1F600}!", try testProgramStatusText(&buf, writer.written(), "msg")); // Truncation happens on a codepoint boundary: a two byte codepoint that // would end at byte 193 of the title, and a four byte codepoint that // would end at byte 2050 of the message, are dropped const title = testRepeat("a", 191) ++ "\u{e9}"; const msg = testRepeat("a", 2046) ++ "\u{1F600}"; writer.clearRetainingCapacity(); try vx.reportProgramStatus(&writer.writer, .{ .state = .done, .title = title, .msg = msg }); try std.testing.expectEqualStrings(title[0..191], try testProgramStatusText(&buf, writer.written(), "title")); try std.testing.expectEqualStrings(msg[0..2046], try testProgramStatusText(&buf, writer.written(), "msg")); // Text that fits exactly is kept whole, and removed control characters // do not count against the limit const exact_title = testRepeat("\u{e9}", 96); const exact_msg = testRepeat("\u{1F600}", 512) ++ "\n"; writer.clearRetainingCapacity(); try vx.reportProgramStatus(&writer.writer, .{ .state = .done, .title = exact_title, .msg = exact_msg }); try std.testing.expectEqualStrings(exact_title, try testProgramStatusText(&buf, writer.written(), "title")); try std.testing.expectEqualStrings(exact_msg[0 .. exact_msg.len - 1], try testProgramStatusText(&buf, writer.written(), "msg")); } test "reportProgramStatus: validates id and app" { var env = try std.testing.environ.createMap(std.testing.allocator); defer env.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); const segment32 = testRepeat("A", 32); const valid_ids = [_][]const u8{ "a", "Az09_.+-", segment32, "a/b/c/d/e/f/g/h", // 128 bytes segment32 ++ "/" ++ segment32 ++ "/" ++ segment32 ++ "/" ++ testRepeat("A", 29), }; for (valid_ids) |id| { writer.clearRetainingCapacity(); try vx.reportProgramStatus(&writer.writer, .{ .state = .idle, .id = id }); try vx.clearProgramStatus(&writer.writer, id); } const invalid_ids = [_][]const u8{ "", "/", "/a", "a/", "a//b", "a b", "a:b", "a;b", "a=b", "caf\u{e9}", segment32 ++ "A", "a/b/c/d/e/f/g/h/i", // 129 bytes segment32 ++ "/" ++ segment32 ++ "/" ++ segment32 ++ "/" ++ testRepeat("A", 30), }; for (invalid_ids) |id| { writer.clearRetainingCapacity(); try std.testing.expectError(error.InvalidProgramStatusId, vx.reportProgramStatus(&writer.writer, .{ .state = .idle, .id = id })); try std.testing.expectError(error.InvalidProgramStatusId, vx.clearProgramStatus(&writer.writer, id)); try std.testing.expectEqual(@as(usize, 0), writer.written().len); } for ([_][]const u8{ "a", "claude-code", "Az09_.+-", segment32 }) |app| { try vx.reportProgramStatus(&writer.writer, .{ .state = .idle, .app = app }); } for ([_][]const u8{ "", "a/b", "a b", "a:b", segment32 ++ "A" }) |app| { writer.clearRetainingCapacity(); try std.testing.expectError(error.InvalidProgramStatusApp, vx.reportProgramStatus(&writer.writer, .{ .state = .idle, .app = app })); try std.testing.expectEqual(@as(usize, 0), writer.written().len); } } test "clearProgramStatus: clears one record tree or every record" { var env = try std.testing.environ.createMap(std.testing.allocator); defer env.deinit(); var vx = try Vaxis.init(std.testing.io, std.testing.allocator, &env, .{}); var writer: std.Io.Writer.Allocating = .init(std.testing.allocator); defer writer.deinit(); defer vx.deinit(std.testing.allocator, &writer.writer); try vx.clearProgramStatus(&writer.writer, null); try std.testing.expectEqualStrings("\x1b]7501;state=clear\x1b\\", writer.written()); writer.clearRetainingCapacity(); try vx.clearProgramStatus(&writer.writer, "deploy/us-east"); try std.testing.expectEqualStrings("\x1b]7501;state=clear:id=deploy/us-east\x1b\\", writer.written()); }