Something went wrong. Try again.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926const std = @import("std");const builtin = @import("builtin");const atomic = std.atomic;const base64Encoder = std.base64.standard.Encoder;const zigimg = @import("zigimg");
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 toscreen: Screen,/// The last screen we drew. We keep this so we can efficiently update on/// the next renderscreen_last: InternalScreen,
caps: Capabilities = .{},
opts: Options = .{},
/// if we should redraw the entire screen on the next renderrefresh: bool = false,
/// blocks the main thread until a DA1 query has been received, or the/// futex times outquery_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 sequencequeries_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,
// imagesnext_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 conptyenable_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 optionspub 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 anywayspub 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 cursorpub 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 hiddenpub 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 screenpub 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 receivedpub 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 screenpub fn queueRefresh(self: *Vaxis) void { self.refresh = true;}
/// draws the screen to the terminalpub 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 notificationpub 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 titlepub 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-statuspub 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 pastedpub 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 shapepub fn setMouseShape(self: *Vaxis, shape: Shape) void { self.screen.mouse_shape = shape;}
/// Change the mouse reporting modepub 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 + offsetpub 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 encodingpub 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 encodedpub 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, };}
pub fn transmitImage( self: *Vaxis, alloc: std.mem.Allocator, tty: *std.Io.Writer, img: *const zigimg.Image, format: Image.TransmitFormat,) !Image { if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;
var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit();
var img_modifiable = img.*;
const buf = switch (format) { .png => png: { const png_buf = try arena.allocator().alloc(u8, img.imageByteSize()); const png = try img.writeToMemory(arena.allocator(), png_buf, .{ .png = .{} }); break :png png; }, .rgb => rgb: { try img_modifiable.convertNoFree(arena.allocator(), .rgb24); break :rgb img_modifiable.rawBytes(); }, .rgba => rgba: { try img_modifiable.convertNoFree(arena.allocator(), .rgba32); break :rgba img_modifiable.rawBytes(); }, };
const b64_buf = try arena.allocator().alloc(u8, base64Encoder.calcSize(buf.len)); const encoded = base64Encoder.encode(b64_buf, buf);
return self.transmitPreEncodedImage(tty, encoded, @intCast(img.width), @intCast(img.height), format);}
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;
var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer var img = switch (src) { .path => |path| try zigimg.Image.fromFilePath(alloc, self.io, path, &read_buffer), .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes), }; defer img.deinit(alloc); return self.transmitImage(alloc, tty, &img, .png);}
/// deletes an image from the terminal's memorypub 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 colorpub 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 colorpub 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 colorpub 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 colorpub 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 availablepub 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 directorypub 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);}
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 reportfn 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.17inline 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());}