a modern tui library written in zig
1const std = @import("std");
2const atomic = std.atomic;
3const base64 = std.base64.standard.Encoder;
4
5const Queue = @import("queue.zig").Queue;
6const ctlseqs = @import("ctlseqs.zig");
7const Tty = @import("Tty.zig");
8const Winsize = Tty.Winsize;
9const Key = @import("Key.zig");
10const Screen = @import("Screen.zig");
11const InternalScreen = @import("InternalScreen.zig");
12const Window = @import("Window.zig");
13const Options = @import("Options.zig");
14const Style = @import("cell.zig").Style;
15const Hyperlink = @import("cell.zig").Hyperlink;
16const gwidth = @import("gwidth.zig");
17const Shape = @import("Mouse.zig").Shape;
18const Image = @import("Image.zig");
19const zigimg = @import("zigimg");
20
21/// Vaxis is the entrypoint for a Vaxis application. The provided type T should
22/// be a tagged union which contains all of the events the application will
23/// handle. Vaxis will look for the following fields on the union and, if
24/// found, emit them via the "nextEvent" method
25///
26/// The following events are available:
27/// - `key_press: Key`, for key press events
28/// - `winsize: Winsize`, for resize events. Must call app.resize when receiving
29/// this event
30/// - `focus_in` and `focus_out` for focus events
31pub fn Vaxis(comptime T: type) type {
32 return struct {
33 const Self = @This();
34
35 const log = std.log.scoped(.vaxis);
36
37 pub const EventType = T;
38
39 pub const Capabilities = struct {
40 kitty_keyboard: bool = false,
41 kitty_graphics: bool = false,
42 rgb: bool = false,
43 unicode: bool = false,
44 };
45
46 /// the event queue for Vaxis
47 //
48 // TODO: is 512 ok?
49 queue: Queue(T, 512),
50
51 tty: ?Tty,
52
53 /// the screen we write to
54 screen: Screen,
55 /// The last screen we drew. We keep this so we can efficiently update on
56 /// the next render
57 screen_last: InternalScreen = undefined,
58
59 state: struct {
60 /// if we are in the alt screen
61 alt_screen: bool = false,
62 /// if we have entered kitty keyboard
63 kitty_keyboard: bool = false,
64 bracketed_paste: bool = false,
65 mouse: bool = false,
66 } = .{},
67
68 caps: Capabilities = .{},
69
70 /// if we should redraw the entire screen on the next render
71 refresh: bool = false,
72
73 /// blocks the main thread until a DA1 query has been received, or the
74 /// futex times out
75 query_futex: atomic.Value(u32) = atomic.Value(u32).init(0),
76
77 // images
78 next_img_id: u32 = 1,
79
80 // statistics
81 renders: usize = 0,
82 render_dur: i128 = 0,
83
84 /// Initialize Vaxis with runtime options
85 pub fn init(_: Options) !Self {
86 return Self{
87 .queue = .{},
88 .tty = null,
89 .screen = .{},
90 .screen_last = .{},
91 };
92 }
93
94 /// Resets the terminal to it's original state. If an allocator is
95 /// passed, this will free resources associated with Vaxis. This is left as an
96 /// optional so applications can choose to not free resources when the
97 /// application will be exiting anyways
98 pub fn deinit(self: *Self, alloc: ?std.mem.Allocator) void {
99 if (self.tty) |_| {
100 var tty = &self.tty.?;
101 if (self.state.kitty_keyboard) {
102 _ = tty.write(ctlseqs.csi_u_pop) catch {};
103 }
104 if (self.state.mouse) {
105 _ = tty.write(ctlseqs.mouse_reset) catch {};
106 }
107 if (self.state.bracketed_paste) {
108 _ = tty.write(ctlseqs.bp_reset) catch {};
109 }
110 if (self.state.alt_screen) {
111 _ = tty.write(ctlseqs.rmcup) catch {};
112 }
113 tty.flush() catch {};
114 tty.deinit();
115 }
116 if (alloc) |a| {
117 self.screen.deinit(a);
118 self.screen_last.deinit(a);
119 }
120 if (self.renders > 0) {
121 const tpr = @divTrunc(self.render_dur, self.renders);
122 log.info("total renders = {d}", .{self.renders});
123 log.info("microseconds per render = {d}", .{tpr});
124 }
125 }
126
127 /// spawns the input thread to start listening to the tty for input
128 pub fn startReadThread(self: *Self) !void {
129 self.tty = try Tty.init();
130 // run our tty read loop in it's own thread
131 const read_thread = try std.Thread.spawn(.{}, Tty.run, .{ &self.tty.?, T, self });
132 try read_thread.setName("tty");
133 }
134
135 /// stops reading from the tty
136 pub fn stopReadThread(self: *Self) void {
137 if (self.tty) |_| {
138 var tty = &self.tty.?;
139 tty.stop();
140 }
141 }
142
143 /// returns the next available event, blocking until one is available
144 pub fn nextEvent(self: *Self) T {
145 return self.queue.pop();
146 }
147
148 /// posts an event into the event queue. Will block if there is not
149 /// capacity for the event
150 pub fn postEvent(self: *Self, event: T) void {
151 self.queue.push(event);
152 }
153
154 /// resize allocates a slice of cellsequal to the number of cells
155 /// required to display the screen (ie width x height). Any previous screen is
156 /// freed when resizing
157 pub fn resize(self: *Self, alloc: std.mem.Allocator, winsize: Winsize) !void {
158 log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows });
159 self.screen.deinit(alloc);
160 self.screen = try Screen.init(alloc, winsize);
161 self.screen.unicode = self.caps.unicode;
162 // try self.screen.int(alloc, winsize.cols, winsize.rows);
163 // we only init our current screen. This has the effect of redrawing
164 // every cell
165 self.screen_last.deinit(alloc);
166 self.screen_last = try InternalScreen.init(alloc, winsize.cols, winsize.rows);
167 // try self.screen_last.resize(alloc, winsize.cols, winsize.rows);
168 }
169
170 /// returns a Window comprising of the entire terminal screen
171 pub fn window(self: *Self) Window {
172 return Window{
173 .x_off = 0,
174 .y_off = 0,
175 .width = self.screen.width,
176 .height = self.screen.height,
177 .screen = &self.screen,
178 };
179 }
180
181 /// enter the alternate screen. The alternate screen will automatically
182 /// be exited if calling deinit while in the alt screen
183 pub fn enterAltScreen(self: *Self) !void {
184 if (self.state.alt_screen) return;
185 var tty = self.tty orelse return;
186 _ = try tty.write(ctlseqs.smcup);
187 try tty.flush();
188 self.state.alt_screen = true;
189 }
190
191 /// exit the alternate screen
192 pub fn exitAltScreen(self: *Self) !void {
193 if (!self.state.alt_screen) return;
194 var tty = self.tty orelse return;
195 _ = try tty.write(ctlseqs.rmcup);
196 try tty.flush();
197 self.state.alt_screen = false;
198 }
199
200 /// write queries to the terminal to determine capabilities. Individual
201 /// capabilities will be delivered to the client and possibly intercepted by
202 /// Vaxis to enable features
203 pub fn queryTerminal(self: *Self) !void {
204 var tty = self.tty orelse return;
205
206 const colorterm = std.os.getenv("COLORTERM") orelse "";
207 if (std.mem.eql(u8, colorterm, "truecolor") or
208 std.mem.eql(u8, colorterm, "24bit"))
209 {
210 if (@hasField(EventType, "cap_rgb")) {
211 self.postEvent(.cap_rgb);
212 }
213 }
214
215 // TODO: decide if we actually want to query for focus and sync. It
216 // doesn't hurt to blindly use them
217 // _ = try tty.write(ctlseqs.decrqm_focus);
218 // _ = try tty.write(ctlseqs.decrqm_sync);
219 _ = try tty.write(ctlseqs.decrqm_unicode);
220 _ = try tty.write(ctlseqs.decrqm_color_theme);
221 // TODO: XTVERSION has a DCS response. uncomment when we can parse
222 // that
223 // _ = try tty.write(ctlseqs.xtversion);
224 _ = try tty.write(ctlseqs.csi_u_query);
225 _ = try tty.write(ctlseqs.kitty_graphics_query);
226 // TODO: sixel geometry query interferes with F4 keys.
227 // _ = try tty.write(ctlseqs.sixel_geometry_query);
228
229 // TODO: XTGETTCAP queries ("RGB", "Smulx")
230
231 _ = try tty.write(ctlseqs.primary_device_attrs);
232 try tty.flush();
233
234 // 1 second timeout
235 std.Thread.Futex.timedWait(&self.query_futex, 0, 1 * std.time.ns_per_s) catch {};
236
237 // enable detected features
238 if (self.caps.kitty_keyboard) {
239 try self.enableKittyKeyboard(.{});
240 }
241 if (self.caps.unicode) {
242 _ = try tty.write(ctlseqs.unicode_set);
243 }
244 }
245
246 // the next render call will refresh the entire screen
247 pub fn queueRefresh(self: *Self) void {
248 self.refresh = true;
249 }
250
251 /// draws the screen to the terminal
252 pub fn render(self: *Self) !void {
253 var tty = self.tty orelse return;
254 self.renders += 1;
255 const timer_start = std.time.microTimestamp();
256 defer {
257 self.render_dur += std.time.microTimestamp() - timer_start;
258 }
259
260 defer self.refresh = false;
261 defer tty.flush() catch {};
262
263 // Set up sync before we write anything
264 // TODO: optimize sync so we only sync _when we have changes_. This
265 // requires a smarter buffered writer, we'll probably have to write
266 // our own
267 _ = try tty.write(ctlseqs.sync_set);
268 defer _ = tty.write(ctlseqs.sync_reset) catch {};
269
270 // Send the cursor to 0,0
271 // TODO: this needs to move after we optimize writes. We only do
272 // this if we have an update to make. We also need to hide cursor
273 // and then reshow it if needed
274 _ = try tty.write(ctlseqs.hide_cursor);
275 _ = try tty.write(ctlseqs.home);
276 _ = try tty.write(ctlseqs.sgr_reset);
277
278 // initialize some variables
279 var reposition: bool = false;
280 var row: usize = 0;
281 var col: usize = 0;
282 var cursor: Style = .{};
283 var link: Hyperlink = .{};
284
285 // Clear all images
286 _ = try tty.write(ctlseqs.kitty_graphics_clear);
287
288 var i: usize = 0;
289 while (i < self.screen.buf.len) {
290 const cell = self.screen.buf[i];
291 defer {
292 // advance by the width of this char mod 1
293 const w = blk: {
294 if (cell.char.width != 0) break :blk cell.char.width;
295
296 const method: gwidth.Method = if (self.caps.unicode) .unicode else .wcwidth;
297 break :blk gwidth.gwidth(cell.char.grapheme, method) catch 1;
298 };
299 var j = i + 1;
300 while (j < i + w) : (j += 1) {
301 self.screen_last.buf[j].skipped = true;
302 }
303 col += w;
304 i += w;
305 }
306 if (col >= self.screen.width) {
307 row += 1;
308 col = 0;
309 }
310 // If cell is the same as our last frame, we don't need to do
311 // anything
312 const last = self.screen_last.buf[i];
313 if (!self.refresh and last.eql(cell) and !last.skipped and cell.image == null) {
314 reposition = true;
315 // Close any osc8 sequence we might be in before
316 // repositioning
317 if (link.uri.len > 0) {
318 _ = try tty.write(ctlseqs.osc8_clear);
319 }
320 continue;
321 }
322 self.screen_last.buf[i].skipped = false;
323 defer {
324 cursor = cell.style;
325 link = cell.link;
326 }
327 // Set this cell in the last frame
328 self.screen_last.writeCell(col, row, cell);
329
330 // reposition the cursor, if needed
331 if (reposition) {
332 try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 });
333 }
334
335 if (cell.image) |img| {
336 if (img.size) |size| {
337 try std.fmt.format(
338 tty.buffered_writer.writer(),
339 ctlseqs.kitty_graphics_scale,
340 .{ img.img_id, img.z_index, size.cols, size.rows },
341 );
342 } else {
343 try std.fmt.format(
344 tty.buffered_writer.writer(),
345 ctlseqs.kitty_graphics_place,
346 .{ img.img_id, img.z_index },
347 );
348 }
349 }
350
351 // something is different, so let's loop throuugh everything and
352 // find out what
353
354 // foreground
355 if (!std.meta.eql(cursor.fg, cell.style.fg)) {
356 const writer = tty.buffered_writer.writer();
357 switch (cell.style.fg) {
358 .default => _ = try tty.write(ctlseqs.fg_reset),
359 .index => |idx| {
360 switch (idx) {
361 0...7 => try std.fmt.format(writer, ctlseqs.fg_base, .{idx}),
362 8...15 => try std.fmt.format(writer, ctlseqs.fg_bright, .{idx - 8}),
363 else => try std.fmt.format(writer, ctlseqs.fg_indexed, .{idx}),
364 }
365 },
366 .rgb => |rgb| {
367 try std.fmt.format(writer, ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] });
368 },
369 }
370 }
371 // background
372 if (!std.meta.eql(cursor.bg, cell.style.bg)) {
373 const writer = tty.buffered_writer.writer();
374 switch (cell.style.bg) {
375 .default => _ = try tty.write(ctlseqs.bg_reset),
376 .index => |idx| {
377 switch (idx) {
378 0...7 => try std.fmt.format(writer, ctlseqs.bg_base, .{idx}),
379 8...15 => try std.fmt.format(writer, ctlseqs.bg_bright, .{idx - 8}),
380 else => try std.fmt.format(writer, ctlseqs.bg_indexed, .{idx}),
381 }
382 },
383 .rgb => |rgb| {
384 try std.fmt.format(writer, ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] });
385 },
386 }
387 }
388 // underline color
389 if (!std.meta.eql(cursor.ul, cell.style.ul)) {
390 const writer = tty.buffered_writer.writer();
391 switch (cell.style.bg) {
392 .default => _ = try tty.write(ctlseqs.ul_reset),
393 .index => |idx| {
394 try std.fmt.format(writer, ctlseqs.ul_indexed, .{idx});
395 },
396 .rgb => |rgb| {
397 try std.fmt.format(writer, ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] });
398 },
399 }
400 }
401 // underline style
402 if (!std.meta.eql(cursor.ul_style, cell.style.ul_style)) {
403 const seq = switch (cell.style.ul_style) {
404 .off => ctlseqs.ul_off,
405 .single => ctlseqs.ul_single,
406 .double => ctlseqs.ul_double,
407 .curly => ctlseqs.ul_curly,
408 .dotted => ctlseqs.ul_dotted,
409 .dashed => ctlseqs.ul_dashed,
410 };
411 _ = try tty.write(seq);
412 }
413 // bold
414 if (cursor.bold != cell.style.bold) {
415 const seq = switch (cell.style.bold) {
416 true => ctlseqs.bold_set,
417 false => ctlseqs.bold_dim_reset,
418 };
419 _ = try tty.write(seq);
420 if (cell.style.dim) {
421 _ = try tty.write(ctlseqs.dim_set);
422 }
423 }
424 // dim
425 if (cursor.dim != cell.style.dim) {
426 const seq = switch (cell.style.dim) {
427 true => ctlseqs.dim_set,
428 false => ctlseqs.bold_dim_reset,
429 };
430 _ = try tty.write(seq);
431 if (cell.style.bold) {
432 _ = try tty.write(ctlseqs.bold_set);
433 }
434 }
435 // dim
436 if (cursor.italic != cell.style.italic) {
437 const seq = switch (cell.style.italic) {
438 true => ctlseqs.italic_set,
439 false => ctlseqs.italic_reset,
440 };
441 _ = try tty.write(seq);
442 }
443 // dim
444 if (cursor.blink != cell.style.blink) {
445 const seq = switch (cell.style.blink) {
446 true => ctlseqs.blink_set,
447 false => ctlseqs.blink_reset,
448 };
449 _ = try tty.write(seq);
450 }
451 // reverse
452 if (cursor.reverse != cell.style.reverse) {
453 const seq = switch (cell.style.reverse) {
454 true => ctlseqs.reverse_set,
455 false => ctlseqs.reverse_reset,
456 };
457 _ = try tty.write(seq);
458 }
459 // invisible
460 if (cursor.invisible != cell.style.invisible) {
461 const seq = switch (cell.style.invisible) {
462 true => ctlseqs.invisible_set,
463 false => ctlseqs.invisible_reset,
464 };
465 _ = try tty.write(seq);
466 }
467 // strikethrough
468 if (cursor.strikethrough != cell.style.strikethrough) {
469 const seq = switch (cell.style.strikethrough) {
470 true => ctlseqs.strikethrough_set,
471 false => ctlseqs.strikethrough_reset,
472 };
473 _ = try tty.write(seq);
474 }
475
476 // url
477 if (!std.meta.eql(link.uri, cell.link.uri)) {
478 var ps = cell.link.params;
479 if (cell.link.uri.len == 0) {
480 // Empty out the params no matter what if we don't have
481 // a url
482 ps = "";
483 }
484 const writer = tty.buffered_writer.writer();
485 try std.fmt.format(writer, ctlseqs.osc8, .{ ps, cell.link.uri });
486 }
487 _ = try tty.write(cell.char.grapheme);
488 }
489 if (self.screen.cursor_vis) {
490 try std.fmt.format(
491 tty.buffered_writer.writer(),
492 ctlseqs.cup,
493 .{
494 self.screen.cursor_row + 1,
495 self.screen.cursor_col + 1,
496 },
497 );
498 _ = try tty.write(ctlseqs.show_cursor);
499 }
500 if (self.screen.mouse_shape != self.screen_last.mouse_shape) {
501 try std.fmt.format(
502 tty.buffered_writer.writer(),
503 ctlseqs.osc22_mouse_shape,
504 .{@tagName(self.screen.mouse_shape)},
505 );
506 }
507 }
508
509 fn enableKittyKeyboard(self: *Self, flags: Key.KittyFlags) !void {
510 self.state.kitty_keyboard = true;
511 const flag_int: u5 = @bitCast(flags);
512 try std.fmt.format(
513 self.tty.?.buffered_writer.writer(),
514 ctlseqs.csi_u_push,
515 .{
516 flag_int,
517 },
518 );
519 try self.tty.?.flush();
520 }
521
522 /// send a system notification
523 pub fn notify(self: *Self, title: ?[]const u8, body: []const u8) !void {
524 if (self.tty == null) return;
525 if (title) |t| {
526 try std.fmt.format(
527 self.tty.?.buffered_writer.writer(),
528 ctlseqs.osc777_notify,
529 .{ t, body },
530 );
531 } else {
532 try std.fmt.format(
533 self.tty.?.buffered_writer.writer(),
534 ctlseqs.osc9_notify,
535 .{body},
536 );
537 }
538 try self.tty.?.flush();
539 }
540
541 /// sets the window title
542 pub fn setTitle(self: *Self, title: []const u8) !void {
543 if (self.tty == null) return;
544 try std.fmt.format(
545 self.tty.?.buffered_writer.writer(),
546 ctlseqs.osc2_set_title,
547 .{title},
548 );
549 try self.tty.?.flush();
550 }
551
552 // turn bracketed paste on or off. An event will be sent at the
553 // beginning and end of a detected paste. All keystrokes between these
554 // events were pasted
555 pub fn setBracketedPaste(self: *Self, enable: bool) !void {
556 if (self.tty == null) return;
557 self.state.bracketed_paste = enable;
558 const seq = if (enable) {
559 self.state.bracketed_paste = true;
560 ctlseqs.bp_set;
561 } else {
562 self.state.bracketed_paste = true;
563 ctlseqs.bp_reset;
564 };
565 _ = try self.tty.?.write(seq);
566 try self.tty.?.flush();
567 }
568
569 /// set the mouse shape
570 pub fn setMouseShape(self: *Self, shape: Shape) void {
571 self.screen.mouse_shape = shape;
572 }
573
574 /// turn mouse reporting on or off
575 pub fn setMouseMode(self: *Self, enable: bool) !void {
576 var tty = self.tty orelse return;
577 self.state.mouse = enable;
578 if (enable) {
579 _ = try tty.write(ctlseqs.mouse_set);
580 try tty.flush();
581 } else {
582 _ = try tty.write(ctlseqs.mouse_reset);
583 try tty.flush();
584 }
585 }
586
587 pub fn loadImage(
588 self: *Self,
589 alloc: std.mem.Allocator,
590 src: Image.Source,
591 ) !Image {
592 if (!self.caps.kitty_graphics) return error.NoGraphicsCapability;
593 var tty = self.tty orelse return error.NoTTY;
594 defer self.next_img_id += 1;
595
596 const writer = tty.buffered_writer.writer();
597
598 var img = switch (src) {
599 .path => |path| try zigimg.Image.fromFilePath(alloc, path),
600 .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes),
601 };
602 defer img.deinit();
603 const png_buf = try alloc.alloc(u8, img.imageByteSize());
604 defer alloc.free(png_buf);
605 const png = try img.writeToMemory(png_buf, .{ .png = .{} });
606 const b64_buf = try alloc.alloc(u8, base64.calcSize(png.len));
607 const encoded = base64.encode(b64_buf, png);
608 defer alloc.free(b64_buf);
609
610 const id = self.next_img_id;
611
612 log.debug("transmitting kitty image: id={d}, len={d}", .{ id, encoded.len });
613
614 if (encoded.len < 4096) {
615 try std.fmt.format(
616 writer,
617 "\x1b_Gf=100,i={d};{s}\x1b\\",
618 .{
619 id,
620 encoded,
621 },
622 );
623 } else {
624 var n: usize = 4096;
625
626 try std.fmt.format(
627 writer,
628 "\x1b_Gf=100,i={d},m=1;{s}\x1b\\",
629 .{ id, encoded[0..n] },
630 );
631 while (n < encoded.len) : (n += 4096) {
632 const end: usize = @min(n + 4096, encoded.len);
633 const m: u2 = if (end == encoded.len) 0 else 1;
634 try std.fmt.format(
635 writer,
636 "\x1b_Gm={d};{s}\x1b\\",
637 .{
638 m,
639 encoded[n..end],
640 },
641 );
642 }
643 }
644 try tty.buffered_writer.flush();
645 return Image{
646 .id = id,
647 .width = img.width,
648 .height = img.height,
649 };
650 }
651
652 /// deletes an image from the terminal's memory
653 pub fn freeImage(self: Self, id: u32) void {
654 var tty = self.tty orelse return;
655 const writer = tty.buffered_writer.writer();
656 std.fmt.format(writer, "\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| {
657 log.err("couldn't delete image {d}: {}", .{ id, err });
658 return;
659 };
660 tty.buffered_writer.flush() catch |err| {
661 log.err("couldn't flush writer: {}", .{err});
662 };
663 }
664 };
665}
666
667test "Vaxis: event queueing" {
668 const Event = union(enum) {
669 key,
670 };
671 var vx: Vaxis(Event) = try Vaxis(Event).init(.{});
672 defer vx.deinit(null);
673 vx.postEvent(.key);
674 const event = vx.nextEvent();
675 try std.testing.expect(event == .key);
676}