a modern tui library written in zig
at v0.1.0 676 lines 26 kB view raw
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}