a modern tui library written in zig
1const std = @import("std");
2const builtin = @import("builtin");
3const atomic = std.atomic;
4const base64Encoder = std.base64.standard.Encoder;
5const zigimg = @import("zigimg");
6const IoWriter = std.io.Writer;
7
8const Cell = @import("Cell.zig");
9const Image = @import("Image.zig");
10const InternalScreen = @import("InternalScreen.zig");
11const Key = @import("Key.zig");
12const Mouse = @import("Mouse.zig");
13const Screen = @import("Screen.zig");
14const unicode = @import("unicode.zig");
15const Window = @import("Window.zig");
16
17const Hyperlink = Cell.Hyperlink;
18const KittyFlags = Key.KittyFlags;
19const Shape = Mouse.Shape;
20const Style = Cell.Style;
21const Winsize = @import("main.zig").Winsize;
22
23const ctlseqs = @import("ctlseqs.zig");
24const gwidth = @import("gwidth.zig");
25
26const assert = std.debug.assert;
27
28const Vaxis = @This();
29
30const log = std.log.scoped(.vaxis);
31
32pub const Capabilities = struct {
33 kitty_keyboard: bool = false,
34 kitty_graphics: bool = false,
35 rgb: bool = false,
36 unicode: gwidth.Method = .wcwidth,
37 sgr_pixels: bool = false,
38 color_scheme_updates: bool = false,
39 explicit_width: bool = false,
40 scaled_text: bool = false,
41 multi_cursor: bool = false,
42};
43
44pub const Options = struct {
45 kitty_keyboard_flags: KittyFlags = .{},
46 /// When supplied, this allocator will be used for system clipboard
47 /// requests. If not supplied, it won't be possible to request the system
48 /// clipboard
49 system_clipboard_allocator: ?std.mem.Allocator = null,
50};
51
52/// the screen we write to
53screen: Screen,
54/// The last screen we drew. We keep this so we can efficiently update on
55/// the next render
56screen_last: InternalScreen,
57
58caps: Capabilities = .{},
59
60opts: Options = .{},
61
62/// if we should redraw the entire screen on the next render
63refresh: bool = false,
64
65/// blocks the main thread until a DA1 query has been received, or the
66/// futex times out
67query_futex: atomic.Value(u32) = atomic.Value(u32).init(0),
68
69/// If Queries were sent, we set this to false. We reset to true when all queries are complete. This
70/// is used because we do explicit cursor position reports in the queries, which interfere with F3
71/// key encoding. This can be used as a flag to determine how we should evaluate this sequence
72queries_done: atomic.Value(bool) = atomic.Value(bool).init(true),
73
74// images
75next_img_id: u32 = 1,
76
77sgr: enum {
78 standard,
79 legacy,
80} = .standard,
81
82/// Enable workarounds for escape sequence handling issues/bugs in terminals
83/// So far this just enables a UL escape sequence workaround for conpty
84enable_workarounds: bool = true,
85
86state: struct {
87 /// if we are in the alt screen
88 alt_screen: bool = false,
89 /// if we have entered kitty keyboard
90 kitty_keyboard: bool = false,
91 bracketed_paste: bool = false,
92 mouse: bool = false,
93 pixel_mouse: bool = false,
94 color_scheme_updates: bool = false,
95 in_band_resize: bool = false,
96 changed_default_fg: bool = false,
97 changed_default_bg: bool = false,
98 changed_cursor_color: bool = false,
99 cursor: struct {
100 row: u16 = 0,
101 col: u16 = 0,
102 } = .{},
103} = .{},
104
105/// Initialize Vaxis with runtime options
106pub fn init(alloc: std.mem.Allocator, opts: Options) !Vaxis {
107 return .{
108 .opts = opts,
109 .screen = .{},
110 .screen_last = try .init(alloc, 0, 0),
111 };
112}
113
114/// Resets the terminal to it's original state. If an allocator is
115/// passed, this will free resources associated with Vaxis. This is left as an
116/// optional so applications can choose to not free resources when the
117/// application will be exiting anyways
118pub fn deinit(self: *Vaxis, alloc: ?std.mem.Allocator, tty: *IoWriter) void {
119 self.resetState(tty) catch {};
120
121 if (alloc) |a| {
122 self.screen.deinit(a);
123 self.screen_last.deinit(a);
124 }
125}
126
127/// resets enabled features, sends cursor to home and clears below cursor
128pub fn resetState(self: *Vaxis, tty: *IoWriter) !void {
129 // always show the cursor on state reset
130 tty.writeAll(ctlseqs.show_cursor) catch {};
131 tty.writeAll(ctlseqs.sgr_reset) catch {};
132 if (self.screen.cursor_shape != .default) {
133 // In many terminals, `.default` will set to the configured cursor shape. Others, it will
134 // change to a blinking block.
135 tty.print(ctlseqs.cursor_shape, .{@intFromEnum(Cell.CursorShape.default)}) catch {};
136 }
137 if (self.state.kitty_keyboard) {
138 try tty.writeAll(ctlseqs.csi_u_pop);
139 self.state.kitty_keyboard = false;
140 }
141 if (self.state.mouse) {
142 try self.setMouseMode(tty, false);
143 }
144 if (self.state.bracketed_paste) {
145 try self.setBracketedPaste(tty, false);
146 }
147 if (self.state.alt_screen) {
148 try tty.writeAll(ctlseqs.home);
149 try tty.writeAll(ctlseqs.erase_below_cursor);
150 try self.exitAltScreen(tty);
151 } else {
152 try tty.writeByte('\r');
153 var i: u16 = 0;
154 while (i < self.state.cursor.row) : (i += 1) {
155 try tty.writeAll(ctlseqs.ri);
156 }
157 try tty.writeAll(ctlseqs.erase_below_cursor);
158 }
159 if (self.state.color_scheme_updates) {
160 try tty.writeAll(ctlseqs.color_scheme_reset);
161 self.state.color_scheme_updates = false;
162 }
163 if (self.state.in_band_resize) {
164 try tty.writeAll(ctlseqs.in_band_resize_reset);
165 self.state.in_band_resize = false;
166 }
167 if (self.state.changed_default_fg) {
168 try tty.writeAll(ctlseqs.osc10_reset);
169 self.state.changed_default_fg = false;
170 }
171 if (self.state.changed_default_bg) {
172 try tty.writeAll(ctlseqs.osc11_reset);
173 self.state.changed_default_bg = false;
174 }
175 if (self.state.changed_cursor_color) {
176 try tty.writeAll(ctlseqs.osc12_reset);
177 self.state.changed_cursor_color = false;
178 }
179
180 try tty.flush();
181}
182
183/// resize allocates a slice of cells equal to the number of cells
184/// required to display the screen (ie width x height). Any previous screen is
185/// freed when resizing. The cursor will be sent to it's home position and a
186/// hardware clear-below-cursor will be sent
187pub fn resize(
188 self: *Vaxis,
189 alloc: std.mem.Allocator,
190 tty: *IoWriter,
191 winsize: Winsize,
192) !void {
193 log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows });
194 self.screen.deinit(alloc);
195 self.screen = try Screen.init(alloc, winsize);
196 self.screen.width_method = self.caps.unicode;
197 // try self.screen.int(alloc, winsize.cols, winsize.rows);
198 // we only init our current screen. This has the effect of redrawing
199 // every cell
200 self.screen_last.deinit(alloc);
201 self.screen_last = try InternalScreen.init(alloc, winsize.cols, winsize.rows);
202 if (self.state.alt_screen)
203 try tty.writeAll(ctlseqs.home)
204 else {
205 for (0..self.state.cursor.row) |_| {
206 try tty.writeAll(ctlseqs.ri);
207 }
208 try tty.writeByte('\r');
209 }
210 self.state.cursor.row = 0;
211 self.state.cursor.col = 0;
212 try tty.writeAll(ctlseqs.sgr_reset ++ ctlseqs.erase_below_cursor);
213 try tty.flush();
214}
215
216/// returns a Window comprising of the entire terminal screen
217pub fn window(self: *Vaxis) Window {
218 return .{
219 .x_off = 0,
220 .y_off = 0,
221 .parent_x_off = 0,
222 .parent_y_off = 0,
223 .width = self.screen.width,
224 .height = self.screen.height,
225 .screen = &self.screen,
226 };
227}
228
229/// enter the alternate screen. The alternate screen will automatically
230/// be exited if calling deinit while in the alt screen.
231pub fn enterAltScreen(self: *Vaxis, tty: *IoWriter) !void {
232 try tty.writeAll(ctlseqs.smcup);
233 try tty.flush();
234 self.state.alt_screen = true;
235}
236
237/// exit the alternate screen. Does not flush the writer.
238pub fn exitAltScreen(self: *Vaxis, tty: *IoWriter) !void {
239 try tty.writeAll(ctlseqs.rmcup);
240 try tty.flush();
241 self.state.alt_screen = false;
242}
243
244/// write queries to the terminal to determine capabilities. Individual
245/// capabilities will be delivered to the client and possibly intercepted by
246/// Vaxis to enable features.
247///
248/// This call will block until Vaxis.query_futex is woken up, or the timeout.
249/// Event loops can wake up this futex when cap_da1 is received
250pub fn queryTerminal(self: *Vaxis, tty: *IoWriter, timeout_ns: u64) !void {
251 try self.queryTerminalSend(tty);
252 // 1 second timeout
253 std.Thread.Futex.timedWait(&self.query_futex, 0, timeout_ns) catch {};
254 self.queries_done.store(true, .unordered);
255 try self.enableDetectedFeatures(tty);
256}
257
258/// write queries to the terminal to determine capabilities. This function
259/// is only for use with a custom main loop. Call Vaxis.queryTerminal() if
260/// you are using Loop.run()
261pub fn queryTerminalSend(vx: *Vaxis, tty: *IoWriter) !void {
262 vx.queries_done.store(false, .unordered);
263
264 // TODO: re-enable this
265 // const colorterm = std.posix.getenv("COLORTERM") orelse "";
266 // if (std.mem.eql(u8, colorterm, "truecolor") or
267 // std.mem.eql(u8, colorterm, "24bit"))
268 // {
269 // if (@hasField(Event, "cap_rgb")) {
270 // self.postEvent(.cap_rgb);
271 // }
272 // }
273
274 // TODO: XTGETTCAP queries ("RGB", "Smulx")
275 // TODO: decide if we actually want to query for focus and sync. It
276 // doesn't hurt to blindly use them
277 // _ = try tty.write(ctlseqs.decrqm_focus);
278 // _ = try tty.write(ctlseqs.decrqm_sync);
279 try tty.writeAll(ctlseqs.decrqm_sgr_pixels ++
280 ctlseqs.decrqm_unicode ++
281 ctlseqs.decrqm_color_scheme ++
282 ctlseqs.in_band_resize_set ++
283
284 // Explicit width query. We send the cursor home, then do an explicit width command, then
285 // query the position. If the parsed value is an F3 with shift, we support explicit width.
286 // The returned response will be something like \x1b[1;2R...which when parsed as a Key is a
287 // shift + F3 (the row is ignored). We only care if the column has moved from 1->2, which is
288 // why we see a Shift modifier
289 ctlseqs.home ++
290 ctlseqs.explicit_width_query ++
291 ctlseqs.cursor_position_request ++
292 // Explicit width query. We send the cursor home, then do an scaled text command, then
293 // query the position. If the parsed value is an F3 with al, we support scaled text.
294 // The returned response will be something like \x1b[1;3R...which when parsed as a Key is a
295 // alt + F3 (the row is ignored). We only care if the column has moved from 1->3, which is
296 // why we see a Shift modifier
297 ctlseqs.home ++
298 ctlseqs.scaled_text_query ++
299 ctlseqs.multi_cursor_query ++
300 ctlseqs.cursor_position_request ++
301 ctlseqs.xtversion ++
302 ctlseqs.csi_u_query ++
303 ctlseqs.kitty_graphics_query ++
304 ctlseqs.primary_device_attrs);
305
306 try tty.flush();
307}
308
309/// Enable features detected by responses to queryTerminal. This function
310/// is only for use with a custom main loop. Call Vaxis.queryTerminal() if
311/// you are using Loop.run()
312pub fn enableDetectedFeatures(self: *Vaxis, tty: *IoWriter) !void {
313 switch (builtin.os.tag) {
314 .windows => {
315 // No feature detection on windows. We just hard enable some knowns for ConPTY
316 self.sgr = .legacy;
317 },
318 else => {
319 // Apply any environment variables
320 if (std.posix.getenv("TERMUX_VERSION")) |_|
321 self.sgr = .legacy;
322 if (std.posix.getenv("VHS_RECORD")) |_| {
323 self.caps.unicode = .wcwidth;
324 self.caps.kitty_keyboard = false;
325 self.sgr = .legacy;
326 }
327 if (std.posix.getenv("TERM_PROGRAM")) |prg| {
328 if (std.mem.eql(u8, prg, "vscode"))
329 self.sgr = .legacy;
330 }
331 if (std.posix.getenv("VAXIS_FORCE_LEGACY_SGR")) |_|
332 self.sgr = .legacy;
333 if (std.posix.getenv("VAXIS_FORCE_WCWIDTH")) |_|
334 self.caps.unicode = .wcwidth;
335 if (std.posix.getenv("VAXIS_FORCE_UNICODE")) |_|
336 self.caps.unicode = .unicode;
337
338 // enable detected features
339 if (self.caps.kitty_keyboard) {
340 try self.enableKittyKeyboard(tty, self.opts.kitty_keyboard_flags);
341 }
342 // Only enable mode 2027 if we don't have explicit width
343 if (self.caps.unicode == .unicode and !self.caps.explicit_width) {
344 try tty.writeAll(ctlseqs.unicode_set);
345 }
346 },
347 }
348
349 try tty.flush();
350}
351
352// the next render call will refresh the entire screen
353pub fn queueRefresh(self: *Vaxis) void {
354 self.refresh = true;
355}
356
357/// draws the screen to the terminal
358pub fn render(self: *Vaxis, tty: *IoWriter) !void {
359 defer self.refresh = false;
360 assert(self.screen.buf.len == @as(usize, @intCast(self.screen.width)) * self.screen.height); // correct size
361 assert(self.screen.buf.len == self.screen_last.buf.len); // same size
362
363 var started: bool = false;
364 var sync_active: bool = false;
365 errdefer if (sync_active) tty.writeAll(ctlseqs.sync_reset) catch {};
366
367 const cursor_vis_changed = self.screen.cursor_vis != self.screen_last.cursor_vis;
368 const cursor_shape_changed = self.screen.cursor_shape != self.screen_last.cursor_shape;
369 const mouse_shape_changed = self.screen.mouse_shape != self.screen_last.mouse_shape;
370 const cursor_pos_changed = self.screen.cursor_vis and
371 (self.screen.cursor_row != self.state.cursor.row or
372 self.screen.cursor_col != self.state.cursor.col);
373 const needs_render = self.refresh or cursor_vis_changed or cursor_shape_changed or mouse_shape_changed or cursor_pos_changed;
374
375 // initialize some variables
376 var reposition: bool = false;
377 var row: u16 = 0;
378 var col: u16 = 0;
379 var cursor: Style = .{};
380 var link: Hyperlink = .{};
381 const CursorPos = struct {
382 row: u16 = 0,
383 col: u16 = 0,
384 };
385 var cursor_pos: CursorPos = .{};
386
387 const startRender = struct {
388 fn run(
389 vx: *Vaxis,
390 io: *IoWriter,
391 cursor_pos_ptr: *CursorPos,
392 reposition_ptr: *bool,
393 started_ptr: *bool,
394 sync_active_ptr: *bool,
395 ) !void {
396 if (started_ptr.*) return;
397 started_ptr.* = true;
398 sync_active_ptr.* = true;
399 // Set up sync before we write anything
400 try io.writeAll(ctlseqs.sync_set);
401 // Send the cursor to 0,0
402 try io.writeAll(ctlseqs.hide_cursor);
403 if (vx.state.alt_screen)
404 try io.writeAll(ctlseqs.home)
405 else {
406 try io.writeByte('\r');
407 for (0..vx.state.cursor.row) |_| {
408 try io.writeAll(ctlseqs.ri);
409 }
410 }
411 try io.writeAll(ctlseqs.sgr_reset);
412 cursor_pos_ptr.* = .{};
413 reposition_ptr.* = true;
414 // Clear all images
415 if (vx.caps.kitty_graphics)
416 try io.writeAll(ctlseqs.kitty_graphics_clear);
417 }
418 };
419
420 // Reset skip flag on all last_screen cells
421 for (self.screen_last.buf) |*last_cell| {
422 last_cell.skip = false;
423 }
424
425 if (needs_render) {
426 try startRender.run(self, tty, &cursor_pos, &reposition, &started, &sync_active);
427 }
428
429 var i: usize = 0;
430 while (i < self.screen.buf.len) {
431 const cell = self.screen.buf[i];
432 const w: u16 = blk: {
433 if (cell.char.width != 0) break :blk cell.char.width;
434
435 const method: gwidth.Method = self.caps.unicode;
436 const width: u16 = @intCast(gwidth.gwidth(cell.char.grapheme, method));
437 break :blk @max(1, width);
438 };
439 defer {
440 // advance by the width of this char mod 1
441 std.debug.assert(w > 0);
442 var j = i + 1;
443 while (j < i + w) : (j += 1) {
444 if (j >= self.screen_last.buf.len) break;
445 self.screen_last.buf[j].skipped = true;
446 }
447 col += w;
448 i += w;
449 }
450 if (col >= self.screen.width) {
451 row += 1;
452 col = 0;
453 // Rely on terminal wrapping to reposition into next row instead of forcing it
454 if (!cell.wrapped)
455 reposition = true;
456 }
457 // If cell is the same as our last frame, we don't need to do
458 // anything
459 const last = self.screen_last.buf[i];
460 if ((!self.refresh and
461 last.eql(cell) and
462 !last.skipped and
463 cell.image == null) or
464 last.skip)
465 {
466 reposition = true;
467 // Close any osc8 sequence we might be in before
468 // repositioning
469 if (link.uri.len > 0) {
470 try tty.writeAll(ctlseqs.osc8_clear);
471 }
472 continue;
473 }
474 if (!started) {
475 try startRender.run(self, tty, &cursor_pos, &reposition, &started, &sync_active);
476 }
477 self.screen_last.buf[i].skipped = false;
478 defer {
479 cursor = cell.style;
480 link = cell.link;
481 }
482 // Set this cell in the last frame
483 self.screen_last.writeCell(col, row, cell);
484
485 // If we support scaled text, we set the flags now
486 if (self.caps.scaled_text and cell.scale.scale > 1) {
487 // The cell is scaled. Set appropriate skips. We only need to do this if the scale factor is
488 // > 1
489 assert(cell.char.width > 0);
490 const cols = cell.scale.scale * cell.char.width;
491 const rows = cell.scale.scale;
492 for (0..rows) |skipped_row| {
493 for (0..cols) |skipped_col| {
494 if (skipped_row == 0 and skipped_col == 0) {
495 continue;
496 }
497 const skipped_i = (@as(usize, @intCast(skipped_row + row)) * self.screen_last.width) + (skipped_col + col);
498 self.screen_last.buf[skipped_i].skip = true;
499 }
500 }
501 }
502
503 // reposition the cursor, if needed
504 if (reposition) {
505 reposition = false;
506 link = .{};
507 if (self.state.alt_screen)
508 try tty.print(ctlseqs.cup, .{ row + 1, col + 1 })
509 else {
510 if (cursor_pos.row == row) {
511 const n = col - cursor_pos.col;
512 if (n > 0)
513 try tty.print(ctlseqs.cuf, .{n});
514 } else {
515 const n = row - cursor_pos.row;
516 for (0..n) |_| {
517 try tty.writeByte('\n');
518 }
519 try tty.writeByte('\r');
520 if (col > 0)
521 try tty.print(ctlseqs.cuf, .{col});
522 }
523 }
524 }
525
526 if (cell.image) |img| {
527 try tty.print(
528 ctlseqs.kitty_graphics_preamble,
529 .{img.img_id},
530 );
531 if (img.options.pixel_offset) |offset| {
532 try tty.print(
533 ",X={d},Y={d}",
534 .{ offset.x, offset.y },
535 );
536 }
537 if (img.options.clip_region) |clip| {
538 if (clip.x) |x|
539 try tty.print(",x={d}", .{x});
540 if (clip.y) |y|
541 try tty.print(",y={d}", .{y});
542 if (clip.width) |width|
543 try tty.print(",w={d}", .{width});
544 if (clip.height) |height|
545 try tty.print(",h={d}", .{height});
546 }
547 if (img.options.size) |size| {
548 if (size.rows) |rows|
549 try tty.print(",r={d}", .{rows});
550 if (size.cols) |cols|
551 try tty.print(",c={d}", .{cols});
552 }
553 if (img.options.z_index) |z| {
554 try tty.print(",z={d}", .{z});
555 }
556 try tty.writeAll(ctlseqs.kitty_graphics_closing);
557 }
558
559 // something is different, so let's loop through everything and
560 // find out what
561
562 // foreground
563 if (!Cell.Color.eql(cursor.fg, cell.style.fg)) {
564 switch (cell.style.fg) {
565 .default => try tty.writeAll(ctlseqs.fg_reset),
566 .index => |idx| {
567 switch (idx) {
568 0...7 => try tty.print(ctlseqs.fg_base, .{idx}),
569 8...15 => try tty.print(ctlseqs.fg_bright, .{idx - 8}),
570 else => {
571 switch (self.sgr) {
572 .standard => try tty.print(ctlseqs.fg_indexed, .{idx}),
573 .legacy => try tty.print(ctlseqs.fg_indexed_legacy, .{idx}),
574 }
575 },
576 }
577 },
578 .rgb => |rgb| {
579 switch (self.sgr) {
580 .standard => try tty.print(ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }),
581 .legacy => try tty.print(ctlseqs.fg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }),
582 }
583 },
584 }
585 }
586 // background
587 if (!Cell.Color.eql(cursor.bg, cell.style.bg)) {
588 switch (cell.style.bg) {
589 .default => try tty.writeAll(ctlseqs.bg_reset),
590 .index => |idx| {
591 switch (idx) {
592 0...7 => try tty.print(ctlseqs.bg_base, .{idx}),
593 8...15 => try tty.print(ctlseqs.bg_bright, .{idx - 8}),
594 else => {
595 switch (self.sgr) {
596 .standard => try tty.print(ctlseqs.bg_indexed, .{idx}),
597 .legacy => try tty.print(ctlseqs.bg_indexed_legacy, .{idx}),
598 }
599 },
600 }
601 },
602 .rgb => |rgb| {
603 switch (self.sgr) {
604 .standard => try tty.print(ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }),
605 .legacy => try tty.print(ctlseqs.bg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }),
606 }
607 },
608 }
609 }
610 // underline color
611 if (!Cell.Color.eql(cursor.ul, cell.style.ul)) {
612 switch (cell.style.ul) {
613 .default => try tty.writeAll(ctlseqs.ul_reset),
614 .index => |idx| {
615 switch (self.sgr) {
616 .standard => try tty.print(ctlseqs.ul_indexed, .{idx}),
617 .legacy => try tty.print(ctlseqs.ul_indexed_legacy, .{idx}),
618 }
619 },
620 .rgb => |rgb| {
621 if (self.enable_workarounds)
622 try tty.print(ctlseqs.ul_rgb_conpty, .{ rgb[0], rgb[1], rgb[2] })
623 else switch (self.sgr) {
624 .standard => try tty.print(ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }),
625 .legacy => try tty.print(ctlseqs.ul_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }),
626 }
627 },
628 }
629 }
630 // underline style
631 if (cursor.ul_style != cell.style.ul_style) {
632 const seq = switch (cell.style.ul_style) {
633 .off => ctlseqs.ul_off,
634 .single => ctlseqs.ul_single,
635 .double => ctlseqs.ul_double,
636 .curly => ctlseqs.ul_curly,
637 .dotted => ctlseqs.ul_dotted,
638 .dashed => ctlseqs.ul_dashed,
639 };
640 try tty.writeAll(seq);
641 }
642 // bold
643 if (cursor.bold != cell.style.bold) {
644 const seq = switch (cell.style.bold) {
645 true => ctlseqs.bold_set,
646 false => ctlseqs.bold_dim_reset,
647 };
648 try tty.writeAll(seq);
649 if (cell.style.dim) {
650 try tty.writeAll(ctlseqs.dim_set);
651 }
652 }
653 // dim
654 if (cursor.dim != cell.style.dim) {
655 const seq = switch (cell.style.dim) {
656 true => ctlseqs.dim_set,
657 false => ctlseqs.bold_dim_reset,
658 };
659 try tty.writeAll(seq);
660 if (cell.style.bold) {
661 try tty.writeAll(ctlseqs.bold_set);
662 }
663 }
664 // dim
665 if (cursor.italic != cell.style.italic) {
666 const seq = switch (cell.style.italic) {
667 true => ctlseqs.italic_set,
668 false => ctlseqs.italic_reset,
669 };
670 try tty.writeAll(seq);
671 }
672 // dim
673 if (cursor.blink != cell.style.blink) {
674 const seq = switch (cell.style.blink) {
675 true => ctlseqs.blink_set,
676 false => ctlseqs.blink_reset,
677 };
678 try tty.writeAll(seq);
679 }
680 // reverse
681 if (cursor.reverse != cell.style.reverse) {
682 const seq = switch (cell.style.reverse) {
683 true => ctlseqs.reverse_set,
684 false => ctlseqs.reverse_reset,
685 };
686 try tty.writeAll(seq);
687 }
688 // invisible
689 if (cursor.invisible != cell.style.invisible) {
690 const seq = switch (cell.style.invisible) {
691 true => ctlseqs.invisible_set,
692 false => ctlseqs.invisible_reset,
693 };
694 try tty.writeAll(seq);
695 }
696 // strikethrough
697 if (cursor.strikethrough != cell.style.strikethrough) {
698 const seq = switch (cell.style.strikethrough) {
699 true => ctlseqs.strikethrough_set,
700 false => ctlseqs.strikethrough_reset,
701 };
702 try tty.writeAll(seq);
703 }
704
705 // url
706 if (!std.mem.eql(u8, link.uri, cell.link.uri)) {
707 var ps = cell.link.params;
708 if (cell.link.uri.len == 0) {
709 // Empty out the params no matter what if we don't have
710 // a url
711 ps = "";
712 }
713 try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri });
714 }
715
716 // scale
717 if (self.caps.scaled_text and !cell.scale.eql(.{})) {
718 const scale = cell.scale;
719 // We have a scaled cell.
720 switch (cell.scale.denominator) {
721 // Denominator cannot be 0
722 0 => unreachable,
723 1 => {
724 // no fractional scaling, just a straight scale factor
725 try tty.print(
726 ctlseqs.scaled_text,
727 .{ scale.scale, w, cell.char.grapheme },
728 );
729 },
730 else => {
731 // fractional scaling
732 // no fractional scaling, just a straight scale factor
733 try tty.print(
734 ctlseqs.scaled_text_with_fractions,
735 .{
736 scale.scale,
737 w,
738 scale.numerator,
739 scale.denominator,
740 @intFromEnum(scale.vertical_alignment),
741 cell.char.grapheme,
742 },
743 );
744 },
745 }
746 cursor_pos.col = col + (w * scale.scale);
747 cursor_pos.row = row;
748 continue;
749 }
750
751 // If we have explicit width and our width is greater than 1, let's use it
752 if (self.caps.explicit_width and w > 1) {
753 try tty.print(ctlseqs.explicit_width, .{ w, cell.char.grapheme });
754 } else {
755 try tty.writeAll(cell.char.grapheme);
756 }
757 cursor_pos.col = col + w;
758 cursor_pos.row = row;
759 }
760 if (!started) return;
761 if (self.screen.cursor_vis) {
762 if (self.state.alt_screen) {
763 try tty.print(
764 ctlseqs.cup,
765 .{
766 self.screen.cursor_row + 1,
767 self.screen.cursor_col + 1,
768 },
769 );
770 } else {
771 // TODO: position cursor relative to current location
772 try tty.writeByte('\r');
773 if (self.screen.cursor_row >= cursor_pos.row) {
774 for (0..(self.screen.cursor_row - cursor_pos.row)) |_| {
775 try tty.writeByte('\n');
776 }
777 } else {
778 for (0..(cursor_pos.row - self.screen.cursor_row)) |_| {
779 try tty.writeAll(ctlseqs.ri);
780 }
781 }
782 if (self.screen.cursor_col > 0)
783 try tty.print(ctlseqs.cuf, .{self.screen.cursor_col});
784 }
785 self.state.cursor.row = self.screen.cursor_row;
786 self.state.cursor.col = self.screen.cursor_col;
787 try tty.writeAll(ctlseqs.show_cursor);
788 } else {
789 self.state.cursor.row = cursor_pos.row;
790 self.state.cursor.col = cursor_pos.col;
791 }
792 self.screen_last.cursor_vis = self.screen.cursor_vis;
793 if (self.screen.mouse_shape != self.screen_last.mouse_shape) {
794 try tty.print(
795 ctlseqs.osc22_mouse_shape,
796 .{@tagName(self.screen.mouse_shape)},
797 );
798 self.screen_last.mouse_shape = self.screen.mouse_shape;
799 }
800 if (self.screen.cursor_shape != self.screen_last.cursor_shape) {
801 try tty.print(
802 ctlseqs.cursor_shape,
803 .{@intFromEnum(self.screen.cursor_shape)},
804 );
805 self.screen_last.cursor_shape = self.screen.cursor_shape;
806 }
807
808 try tty.writeAll(ctlseqs.sync_reset);
809 try tty.flush();
810}
811
812fn enableKittyKeyboard(self: *Vaxis, tty: *IoWriter, flags: Key.KittyFlags) !void {
813 const flag_int: u5 = @bitCast(flags);
814 try tty.print(ctlseqs.csi_u_push, .{flag_int});
815 try tty.flush();
816 self.state.kitty_keyboard = true;
817}
818
819/// send a system notification
820pub fn notify(_: *Vaxis, tty: *IoWriter, title: ?[]const u8, body: []const u8) !void {
821 if (title) |t|
822 try tty.print(ctlseqs.osc777_notify, .{ t, body })
823 else
824 try tty.print(ctlseqs.osc9_notify, .{body});
825
826 try tty.flush();
827}
828
829/// sets the window title
830pub fn setTitle(_: *Vaxis, tty: *IoWriter, title: []const u8) !void {
831 try tty.print(ctlseqs.osc2_set_title, .{title});
832 try tty.flush();
833}
834
835// turn bracketed paste on or off. An event will be sent at the
836// beginning and end of a detected paste. All keystrokes between these
837// events were pasted
838pub fn setBracketedPaste(self: *Vaxis, tty: *IoWriter, enable: bool) !void {
839 const seq = if (enable)
840 ctlseqs.bp_set
841 else
842 ctlseqs.bp_reset;
843 try tty.writeAll(seq);
844 try tty.flush();
845 self.state.bracketed_paste = enable;
846}
847
848/// set the mouse shape
849pub fn setMouseShape(self: *Vaxis, shape: Shape) void {
850 self.screen.mouse_shape = shape;
851}
852
853/// Change the mouse reporting mode
854pub fn setMouseMode(self: *Vaxis, tty: *IoWriter, enable: bool) !void {
855 if (enable) {
856 self.state.mouse = true;
857 if (self.caps.sgr_pixels) {
858 log.debug("enabling mouse mode: pixel coordinates", .{});
859 self.state.pixel_mouse = true;
860 try tty.writeAll(ctlseqs.mouse_set_pixels);
861 } else {
862 log.debug("enabling mouse mode: cell coordinates", .{});
863 try tty.writeAll(ctlseqs.mouse_set);
864 }
865 } else {
866 try tty.writeAll(ctlseqs.mouse_reset);
867 }
868
869 try tty.flush();
870}
871
872/// Translate pixel mouse coordinates to cell + offset
873pub fn translateMouse(self: Vaxis, mouse: Mouse) Mouse {
874 if (self.screen.width == 0 or self.screen.height == 0) return mouse;
875 var result = mouse;
876 if (self.state.pixel_mouse) {
877 std.debug.assert(mouse.xoffset == 0);
878 std.debug.assert(mouse.yoffset == 0);
879 const xpos = mouse.col;
880 const ypos = mouse.row;
881 const xextra = self.screen.width_pix % self.screen.width;
882 const yextra = self.screen.height_pix % self.screen.height;
883 const xcell: i16 = @intCast((self.screen.width_pix - xextra) / self.screen.width);
884 const ycell: i16 = @intCast((self.screen.height_pix - yextra) / self.screen.height);
885 if (xcell == 0 or ycell == 0) return mouse;
886 result.col = @divFloor(xpos, xcell);
887 result.row = @divFloor(ypos, ycell);
888 result.xoffset = @intCast(@mod(xpos, xcell));
889 result.yoffset = @intCast(@mod(ypos, ycell));
890 }
891 return result;
892}
893
894/// Transmit an image using the local filesystem. Allocates only for base64 encoding
895pub fn transmitLocalImagePath(
896 self: *Vaxis,
897 allocator: std.mem.Allocator,
898 tty: *IoWriter,
899 payload: []const u8,
900 width: u16,
901 height: u16,
902 medium: Image.TransmitMedium,
903 format: Image.TransmitFormat,
904) !Image {
905 if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;
906
907 defer self.next_img_id += 1;
908
909 const id = self.next_img_id;
910
911 const size = base64Encoder.calcSize(payload.len);
912 if (size >= 4096) return error.PathTooLong;
913
914 const buf = try allocator.alloc(u8, size);
915 const encoded = base64Encoder.encode(buf, payload);
916 defer allocator.free(buf);
917
918 const medium_char: u8 = switch (medium) {
919 .file => 'f',
920 .temp_file => 't',
921 .shared_mem => 's',
922 };
923
924 switch (format) {
925 .rgb => {
926 try tty.print(
927 "\x1b_Gf=24,s={d},v={d},i={d},t={c};{s}\x1b\\",
928 .{ width, height, id, medium_char, encoded },
929 );
930 },
931 .rgba => {
932 try tty.print(
933 "\x1b_Gf=32,s={d},v={d},i={d},t={c};{s}\x1b\\",
934 .{ width, height, id, medium_char, encoded },
935 );
936 },
937 .png => {
938 try tty.print(
939 "\x1b_Gf=100,i={d},t={c};{s}\x1b\\",
940 .{ id, medium_char, encoded },
941 );
942 },
943 }
944
945 try tty.flush();
946 return .{
947 .id = id,
948 .width = width,
949 .height = height,
950 };
951}
952
953/// Transmit an image which has been pre-base64 encoded
954pub fn transmitPreEncodedImage(
955 self: *Vaxis,
956 tty: *IoWriter,
957 bytes: []const u8,
958 width: u16,
959 height: u16,
960 format: Image.TransmitFormat,
961) !Image {
962 if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;
963
964 defer self.next_img_id += 1;
965 const id = self.next_img_id;
966
967 const fmt: u8 = switch (format) {
968 .rgb => 24,
969 .rgba => 32,
970 .png => 100,
971 };
972
973 if (bytes.len < 4096) {
974 try tty.print(
975 "\x1b_Gf={d},s={d},v={d},i={d};{s}\x1b\\",
976 .{
977 fmt,
978 width,
979 height,
980 id,
981 bytes,
982 },
983 );
984 } else {
985 var n: usize = 4096;
986
987 try tty.print(
988 "\x1b_Gf={d},s={d},v={d},i={d},m=1;{s}\x1b\\",
989 .{ fmt, width, height, id, bytes[0..n] },
990 );
991 while (n < bytes.len) : (n += 4096) {
992 const end: usize = @min(n + 4096, bytes.len);
993 const m: u2 = if (end == bytes.len) 0 else 1;
994 try tty.print(
995 "\x1b_Gm={d};{s}\x1b\\",
996 .{
997 m,
998 bytes[n..end],
999 },
1000 );
1001 }
1002 }
1003
1004 try tty.flush();
1005 return .{
1006 .id = id,
1007 .width = width,
1008 .height = height,
1009 };
1010}
1011
1012pub fn transmitImage(
1013 self: *Vaxis,
1014 alloc: std.mem.Allocator,
1015 tty: *IoWriter,
1016 img: *zigimg.Image,
1017 format: Image.TransmitFormat,
1018) !Image {
1019 if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;
1020
1021 var arena = std.heap.ArenaAllocator.init(alloc);
1022 defer arena.deinit();
1023
1024 const buf = switch (format) {
1025 .png => png: {
1026 const png_buf = try arena.allocator().alloc(u8, img.imageByteSize());
1027 const png = try img.writeToMemory(arena.allocator(), png_buf, .{ .png = .{} });
1028 break :png png;
1029 },
1030 .rgb => rgb: {
1031 try img.convert(arena.allocator(), .rgb24);
1032 break :rgb img.rawBytes();
1033 },
1034 .rgba => rgba: {
1035 try img.convert(arena.allocator(), .rgba32);
1036 break :rgba img.rawBytes();
1037 },
1038 };
1039
1040 const b64_buf = try arena.allocator().alloc(u8, base64Encoder.calcSize(buf.len));
1041 const encoded = base64Encoder.encode(b64_buf, buf);
1042
1043 return self.transmitPreEncodedImage(tty, encoded, @intCast(img.width), @intCast(img.height), format);
1044}
1045
1046pub fn loadImage(
1047 self: *Vaxis,
1048 alloc: std.mem.Allocator,
1049 tty: *IoWriter,
1050 src: Image.Source,
1051) !Image {
1052 if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;
1053
1054 var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer
1055 var img = switch (src) {
1056 .path => |path| try zigimg.Image.fromFilePath(alloc, path, &read_buffer),
1057 .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes),
1058 };
1059 defer img.deinit(alloc);
1060 return self.transmitImage(alloc, tty, &img, .png);
1061}
1062
1063/// deletes an image from the terminal's memory
1064pub fn freeImage(_: Vaxis, tty: *IoWriter, id: u32) void {
1065 tty.print("\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| {
1066 log.err("couldn't delete image {d}: {}", .{ id, err });
1067 return;
1068 };
1069 tty.flush() catch {};
1070}
1071
1072pub fn copyToSystemClipboard(_: Vaxis, tty: *IoWriter, text: []const u8, encode_allocator: std.mem.Allocator) !void {
1073 const encoder = std.base64.standard.Encoder;
1074 const size = encoder.calcSize(text.len);
1075 const buf = try encode_allocator.alloc(u8, size);
1076 const b64 = encoder.encode(buf, text);
1077 defer encode_allocator.free(buf);
1078 try tty.print(
1079 ctlseqs.osc52_clipboard_copy,
1080 .{b64},
1081 );
1082
1083 try tty.flush();
1084}
1085
1086pub fn requestSystemClipboard(self: Vaxis, tty: *IoWriter) !void {
1087 if (self.opts.system_clipboard_allocator == null) return error.NoClipboardAllocator;
1088 try tty.print(
1089 ctlseqs.osc52_clipboard_request,
1090 .{},
1091 );
1092 try tty.flush();
1093}
1094
1095/// Set the default terminal foreground color
1096pub fn setTerminalForegroundColor(self: *Vaxis, tty: *IoWriter, rgb: [3]u8) !void {
1097 try tty.print(ctlseqs.osc10_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] });
1098 try tty.flush();
1099 self.state.changed_default_fg = true;
1100}
1101
1102/// Set the default terminal background color
1103pub fn setTerminalBackgroundColor(self: *Vaxis, tty: *IoWriter, rgb: [3]u8) !void {
1104 try tty.print(ctlseqs.osc11_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] });
1105 try tty.flush();
1106 self.state.changed_default_bg = true;
1107}
1108
1109/// Set the terminal cursor color
1110pub fn setTerminalCursorColor(self: *Vaxis, tty: *IoWriter, rgb: [3]u8) !void {
1111 try tty.print(ctlseqs.osc12_set, .{ rgb[0], rgb[0], rgb[1], rgb[1], rgb[2], rgb[2] });
1112 try tty.flush();
1113 self.state.changed_cursor_color = true;
1114}
1115
1116/// Request a color report from the terminal. Note: not all terminals support
1117/// reporting colors. It is always safe to try, but you may not receive a
1118/// response.
1119pub fn queryColor(_: Vaxis, tty: *IoWriter, kind: Cell.Color.Kind) !void {
1120 switch (kind) {
1121 .fg => try tty.writeAll(ctlseqs.osc10_query),
1122 .bg => try tty.writeAll(ctlseqs.osc11_query),
1123 .cursor => try tty.writeAll(ctlseqs.osc12_query),
1124 .index => |idx| try tty.print(ctlseqs.osc4_query, .{idx}),
1125 }
1126 try tty.flush();
1127}
1128
1129/// Subscribe to color theme updates. A `color_scheme: Color.Scheme` tag must
1130/// exist on your Event type to receive the response. This is a queried
1131/// capability. Support can be detected by checking the value of
1132/// vaxis.caps.color_scheme_updates. The initial scheme will be reported when
1133/// subscribing.
1134pub fn subscribeToColorSchemeUpdates(self: *Vaxis, tty: *IoWriter) !void {
1135 try tty.writeAll(ctlseqs.color_scheme_request);
1136 try tty.writeAll(ctlseqs.color_scheme_set);
1137 try tty.flush();
1138 self.state.color_scheme_updates = true;
1139}
1140
1141pub fn deviceStatusReport(_: Vaxis, tty: *IoWriter) !void {
1142 try tty.writeAll(ctlseqs.device_status_report);
1143 try tty.flush();
1144}
1145
1146/// prettyPrint is used to print the contents of the Screen to the tty. The state is not stored, and
1147/// the cursor will be put on the next line after the last line is printed. This is useful to
1148/// sequentially print data in a styled format to eg. stdout. This function returns an error if you
1149/// are not in the alt screen. The cursor is always hidden, and mouse shapes are not available
1150pub fn prettyPrint(self: *Vaxis, tty: *IoWriter) !void {
1151 if (self.state.alt_screen) return error.NotInPrimaryScreen;
1152
1153 try tty.writeAll(ctlseqs.hide_cursor);
1154 try tty.writeAll(ctlseqs.sync_set);
1155 defer tty.writeAll(ctlseqs.sync_reset) catch {};
1156 try tty.writeAll(ctlseqs.sgr_reset);
1157 defer tty.writeAll(ctlseqs.sgr_reset) catch {};
1158
1159 var reposition: bool = false;
1160 var row: u16 = 0;
1161 var col: u16 = 0;
1162 var cursor: Style = .{};
1163 var link: Hyperlink = .{};
1164 var cursor_pos: struct {
1165 row: u16 = 0,
1166 col: u16 = 0,
1167 } = .{};
1168
1169 var i: u16 = 0;
1170 while (i < self.screen.buf.len) {
1171 const cell = self.screen.buf[i];
1172 const w = blk: {
1173 if (cell.char.width != 0) break :blk cell.char.width;
1174
1175 const method: gwidth.Method = self.caps.unicode;
1176 const width = gwidth.gwidth(cell.char.grapheme, method);
1177 break :blk @max(1, width);
1178 };
1179 defer {
1180 // advance by the width of this char mod 1
1181 std.debug.assert(w > 0);
1182 var j = i + 1;
1183 while (j < i + w) : (j += 1) {
1184 if (j >= self.screen_last.buf.len) break;
1185 self.screen_last.buf[j].skipped = true;
1186 }
1187 col += w;
1188 i += w;
1189 }
1190 if (col >= self.screen.width) {
1191 row += 1;
1192 col = 0;
1193 // Rely on terminal wrapping to reposition into next row instead of forcing it
1194 if (!cell.wrapped)
1195 reposition = true;
1196 }
1197 if (cell.default) {
1198 reposition = true;
1199 continue;
1200 }
1201 defer {
1202 cursor = cell.style;
1203 link = cell.link;
1204 }
1205
1206 // reposition the cursor, if needed
1207 if (reposition) {
1208 reposition = false;
1209 link = .{};
1210 if (cursor_pos.row == row) {
1211 const n = col - cursor_pos.col;
1212 if (n > 0)
1213 try tty.print(ctlseqs.cuf, .{n});
1214 } else {
1215 const n = row - cursor_pos.row;
1216 for (0..n) |_| {
1217 try tty.writeByte('\n');
1218 }
1219 try tty.writeByte('\r');
1220 if (col > 0)
1221 try tty.print(ctlseqs.cuf, .{col});
1222 }
1223 }
1224
1225 if (cell.image) |img| {
1226 try tty.print(
1227 ctlseqs.kitty_graphics_preamble,
1228 .{img.img_id},
1229 );
1230 if (img.options.pixel_offset) |offset| {
1231 try tty.print(
1232 ",X={d},Y={d}",
1233 .{ offset.x, offset.y },
1234 );
1235 }
1236 if (img.options.clip_region) |clip| {
1237 if (clip.x) |x|
1238 try tty.print(",x={d}", .{x});
1239 if (clip.y) |y|
1240 try tty.print(",y={d}", .{y});
1241 if (clip.width) |width|
1242 try tty.print(",w={d}", .{width});
1243 if (clip.height) |height|
1244 try tty.print(",h={d}", .{height});
1245 }
1246 if (img.options.size) |size| {
1247 if (size.rows) |rows|
1248 try tty.print(",r={d}", .{rows});
1249 if (size.cols) |cols|
1250 try tty.print(",c={d}", .{cols});
1251 }
1252 if (img.options.z_index) |z| {
1253 try tty.print(",z={d}", .{z});
1254 }
1255 try tty.writeAll(ctlseqs.kitty_graphics_closing);
1256 }
1257
1258 // something is different, so let's loop through everything and
1259 // find out what
1260
1261 // foreground
1262 if (!Cell.Color.eql(cursor.fg, cell.style.fg)) {
1263 switch (cell.style.fg) {
1264 .default => try tty.writeAll(ctlseqs.fg_reset),
1265 .index => |idx| {
1266 switch (idx) {
1267 0...7 => try tty.print(ctlseqs.fg_base, .{idx}),
1268 8...15 => try tty.print(ctlseqs.fg_bright, .{idx - 8}),
1269 else => {
1270 switch (self.sgr) {
1271 .standard => try tty.print(ctlseqs.fg_indexed, .{idx}),
1272 .legacy => try tty.print(ctlseqs.fg_indexed_legacy, .{idx}),
1273 }
1274 },
1275 }
1276 },
1277 .rgb => |rgb| {
1278 switch (self.sgr) {
1279 .standard => try tty.print(ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }),
1280 .legacy => try tty.print(ctlseqs.fg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }),
1281 }
1282 },
1283 }
1284 }
1285 // background
1286 if (!Cell.Color.eql(cursor.bg, cell.style.bg)) {
1287 switch (cell.style.bg) {
1288 .default => try tty.writeAll(ctlseqs.bg_reset),
1289 .index => |idx| {
1290 switch (idx) {
1291 0...7 => try tty.print(ctlseqs.bg_base, .{idx}),
1292 8...15 => try tty.print(ctlseqs.bg_bright, .{idx - 8}),
1293 else => {
1294 switch (self.sgr) {
1295 .standard => try tty.print(ctlseqs.bg_indexed, .{idx}),
1296 .legacy => try tty.print(ctlseqs.bg_indexed_legacy, .{idx}),
1297 }
1298 },
1299 }
1300 },
1301 .rgb => |rgb| {
1302 switch (self.sgr) {
1303 .standard => try tty.print(ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }),
1304 .legacy => try tty.print(ctlseqs.bg_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] }),
1305 }
1306 },
1307 }
1308 }
1309 // underline color
1310 if (!Cell.Color.eql(cursor.ul, cell.style.ul)) {
1311 switch (cell.style.ul) {
1312 .default => try tty.writeAll(ctlseqs.ul_reset),
1313 .index => |idx| {
1314 switch (self.sgr) {
1315 .standard => try tty.print(ctlseqs.ul_indexed, .{idx}),
1316 .legacy => try tty.print(ctlseqs.ul_indexed_legacy, .{idx}),
1317 }
1318 },
1319 .rgb => |rgb| {
1320 if (self.enable_workarounds)
1321 try tty.print(ctlseqs.ul_rgb_conpty, .{ rgb[0], rgb[1], rgb[2] })
1322 else switch (self.sgr) {
1323 .standard => try tty.print(ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }),
1324 .legacy => {
1325 try tty.print(ctlseqs.ul_rgb_legacy, .{ rgb[0], rgb[1], rgb[2] });
1326 },
1327 }
1328 },
1329 }
1330 }
1331 // underline style
1332 if (cursor.ul_style != cell.style.ul_style) {
1333 const seq = switch (cell.style.ul_style) {
1334 .off => ctlseqs.ul_off,
1335 .single => ctlseqs.ul_single,
1336 .double => ctlseqs.ul_double,
1337 .curly => ctlseqs.ul_curly,
1338 .dotted => ctlseqs.ul_dotted,
1339 .dashed => ctlseqs.ul_dashed,
1340 };
1341 try tty.writeAll(seq);
1342 }
1343 // bold
1344 if (cursor.bold != cell.style.bold) {
1345 const seq = switch (cell.style.bold) {
1346 true => ctlseqs.bold_set,
1347 false => ctlseqs.bold_dim_reset,
1348 };
1349 try tty.writeAll(seq);
1350 if (cell.style.dim) {
1351 try tty.writeAll(ctlseqs.dim_set);
1352 }
1353 }
1354 // dim
1355 if (cursor.dim != cell.style.dim) {
1356 const seq = switch (cell.style.dim) {
1357 true => ctlseqs.dim_set,
1358 false => ctlseqs.bold_dim_reset,
1359 };
1360 try tty.writeAll(seq);
1361 if (cell.style.bold) {
1362 try tty.writeAll(ctlseqs.bold_set);
1363 }
1364 }
1365 // dim
1366 if (cursor.italic != cell.style.italic) {
1367 const seq = switch (cell.style.italic) {
1368 true => ctlseqs.italic_set,
1369 false => ctlseqs.italic_reset,
1370 };
1371 try tty.writeAll(seq);
1372 }
1373 // dim
1374 if (cursor.blink != cell.style.blink) {
1375 const seq = switch (cell.style.blink) {
1376 true => ctlseqs.blink_set,
1377 false => ctlseqs.blink_reset,
1378 };
1379 try tty.writeAll(seq);
1380 }
1381 // reverse
1382 if (cursor.reverse != cell.style.reverse) {
1383 const seq = switch (cell.style.reverse) {
1384 true => ctlseqs.reverse_set,
1385 false => ctlseqs.reverse_reset,
1386 };
1387 try tty.writeAll(seq);
1388 }
1389 // invisible
1390 if (cursor.invisible != cell.style.invisible) {
1391 const seq = switch (cell.style.invisible) {
1392 true => ctlseqs.invisible_set,
1393 false => ctlseqs.invisible_reset,
1394 };
1395 try tty.writeAll(seq);
1396 }
1397 // strikethrough
1398 if (cursor.strikethrough != cell.style.strikethrough) {
1399 const seq = switch (cell.style.strikethrough) {
1400 true => ctlseqs.strikethrough_set,
1401 false => ctlseqs.strikethrough_reset,
1402 };
1403 try tty.writeAll(seq);
1404 }
1405
1406 // url
1407 if (!std.mem.eql(u8, link.uri, cell.link.uri)) {
1408 var ps = cell.link.params;
1409 if (cell.link.uri.len == 0) {
1410 // Empty out the params no matter what if we don't have
1411 // a url
1412 ps = "";
1413 }
1414 try tty.print(ctlseqs.osc8, .{ ps, cell.link.uri });
1415 }
1416 try tty.writeAll(cell.char.grapheme);
1417 cursor_pos.col = col + w;
1418 cursor_pos.row = row;
1419 }
1420 try tty.writeAll("\r\n");
1421 try tty.flush();
1422}
1423
1424/// Set the terminal's current working directory
1425pub fn setTerminalWorkingDirectory(_: *Vaxis, tty: *IoWriter, path: []const u8) !void {
1426 if (path.len == 0 or path[0] != '/')
1427 return error.InvalidAbsolutePath;
1428 const hostname = switch (builtin.os.tag) {
1429 .windows => null,
1430 else => std.posix.getenv("HOSTNAME"),
1431 } orelse "localhost";
1432
1433 const uri: std.Uri = .{
1434 .scheme = "file",
1435 .host = .{ .raw = hostname },
1436 .path = .{ .raw = path },
1437 };
1438 try tty.print(ctlseqs.osc7, .{uri.fmt(.{ .scheme = true, .authority = true, .path = true })});
1439 try tty.flush();
1440}
1441
1442test "render: no output when no changes" {
1443 var vx = try Vaxis.init(std.testing.allocator, .{});
1444 var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator);
1445 defer deinit_writer.deinit();
1446 defer vx.deinit(std.testing.allocator, &deinit_writer.writer);
1447
1448 var render_writer = std.io.Writer.Allocating.init(std.testing.allocator);
1449 defer render_writer.deinit();
1450 try vx.render(&render_writer.writer);
1451 const output = try render_writer.toOwnedSlice();
1452 defer std.testing.allocator.free(output);
1453 try std.testing.expectEqual(@as(usize, 0), output.len);
1454}