diff --git a/examples/text_input.zig b/examples/text_input.zig index 2bde6ed..54722cc 100644 --- a/examples/text_input.zig +++ b/examples/text_input.zig @@ -29,16 +29,21 @@ pub fn main() !void { } const alloc = gpa.allocator(); - // Initialize Vaxis with our event type - var vx = try vaxis.init(Event, .{}); + // Initialize Vaxis + var vx = try vaxis.init(.{}); // deinit takes an optional allocator. If your program is exiting, you can // choose to pass a null allocator to save some exit time. defer vx.deinit(alloc); + // create our event loop + var loop: vaxis.Loop(Event) = .{ + .vaxis = &vx, + }; + // Start the read loop. This puts the terminal in raw mode and begins // reading user input - try vx.startReadThread(); - defer vx.stopReadThread(); + try loop.run(); + defer loop.stop(); // Optionally enter the alternate screen try vx.enterAltScreen(); @@ -61,7 +66,7 @@ pub fn main() !void { // queue which can serve as the primary event queue for an application while (true) { // nextEvent blocks until an event is in the queue - const event = vx.nextEvent(); + const event = loop.nextEvent(); log.debug("event: {}", .{event}); // exhaustive switching ftw. Vaxis will send events if your Event // enum has the fields for those events (ie "key_press", "winsize") diff --git a/src/Loop.zig b/src/Loop.zig new file mode 100644 index 0000000..38009d0 --- /dev/null +++ b/src/Loop.zig @@ -0,0 +1,61 @@ +const std = @import("std"); + +const Queue = @import("queue.zig").Queue; +const Tty = @import("Tty.zig"); +const Vaxis = @import("Vaxis.zig"); + +pub fn Loop(comptime T: type) type { + return struct { + const Self = @This(); + + const log = std.log.scoped(.loop); + + queue: Queue(T, 512) = .{}, + + thread: ?std.Thread = null, + + vaxis: *Vaxis, + + /// spawns the input thread to read input from the tty + pub fn run(self: *Self) !void { + if (self.thread) |_| return; + if (self.vaxis.tty == null) self.vaxis.tty = try Tty.init(); + self.thread = try std.Thread.spawn(.{}, Tty.run, .{ &self.vaxis.tty.?, T, self }); + } + + /// stops reading from the tty and returns it to it's initial state + pub fn stop(self: *Self) void { + if (self.vaxis.tty) |*tty| tty.stop(); + if (self.thread) |thread| { + thread.join(); + self.thread = null; + } + } + + /// returns the next available event, blocking until one is available + pub fn nextEvent(self: *Self) T { + return self.queue.pop(); + } + + /// blocks until an event is available. Useful when your application is + /// operating on a poll + drain architecture (see tryEvent) + pub fn pollEvent(self: *Self) void { + self.queue.poll(); + } + + /// returns an event if one is available, otherwise null. Non-blocking. + pub fn tryEvent(self: *Self) ?T { + return self.queue.tryPop(); + } + + /// posts an event into the event queue. Will block if there is not + /// capacity for the event + pub fn postEvent(self: *Self, event: T) void { + self.queue.push(event); + } + + pub fn tryPostEvent(self: *Self, event: T) bool { + return self.queue.tryPush(event); + } + }; +} diff --git a/src/Options.zig b/src/Options.zig deleted file mode 100644 index 301afb7..0000000 --- a/src/Options.zig +++ /dev/null @@ -1,2 +0,0 @@ -/// Runtime options -const Options = @This(); diff --git a/src/Tty.zig b/src/Tty.zig index 13da20d..d8684c4 100644 --- a/src/Tty.zig +++ b/src/Tty.zig @@ -1,7 +1,7 @@ const std = @import("std"); const builtin = @import("builtin"); const posix = std.posix; -const Vaxis = @import("vaxis.zig").Vaxis; +const Loop = @import("Loop.zig").Loop; const Parser = @import("Parser.zig"); const GraphemeCache = @import("GraphemeCache.zig"); const ctlseqs = @import("ctlseqs.zig"); @@ -57,13 +57,13 @@ pub fn stop(self: *Tty) void { pub fn run( self: *Tty, comptime Event: type, - vx: *Vaxis(Event), + loop: *Loop(Event), ) !void { // get our initial winsize const winsize = try getWinsize(self.fd); if (@hasField(Event, "winsize")) { - vx.postEvent(.{ .winsize = winsize }); + loop.postEvent(.{ .winsize = winsize }); } // Build a winch handler. We need build this struct to get an anonymous @@ -72,10 +72,10 @@ pub fn run( const WinchHandler = struct { const Self = @This(); - var vx_winch: *Vaxis(Event) = undefined; + var vx_winch: *Loop(Event) = undefined; var fd: posix.fd_t = undefined; - fn init(vx_arg: *Vaxis(Event), fd_arg: posix.fd_t) !void { + fn init(vx_arg: *Loop(Event), fd_arg: posix.fd_t) !void { vx_winch = vx_arg; fd = fd_arg; var act = posix.Sigaction{ @@ -100,7 +100,7 @@ pub fn run( } } }; - try WinchHandler.init(vx, self.fd); + try WinchHandler.init(loop, self.fd); // initialize a grapheme cache var cache: GraphemeCache = .{}; @@ -131,55 +131,55 @@ pub fn run( if (key.text) |text| { mut_key.text = cache.put(text); } - vx.postEvent(.{ .key_press = mut_key }); + loop.postEvent(.{ .key_press = mut_key }); } }, .mouse => |mouse| { if (@hasField(Event, "mouse")) { - vx.postEvent(.{ .mouse = mouse }); + loop.postEvent(.{ .mouse = mouse }); } }, .focus_in => { if (@hasField(Event, "focus_in")) { - vx.postEvent(.focus_in); + loop.postEvent(.focus_in); } }, .focus_out => { if (@hasField(Event, "focus_out")) { - vx.postEvent(.focus_out); + loop.postEvent(.focus_out); } }, .paste_start => { if (@hasField(Event, "paste_start")) { - vx.postEvent(.paste_start); + loop.postEvent(.paste_start); } }, .paste_end => { if (@hasField(Event, "paste_end")) { - vx.postEvent(.paste_end); + loop.postEvent(.paste_end); } }, .cap_kitty_keyboard => { log.info("kitty keyboard capability detected", .{}); - vx.caps.kitty_keyboard = true; + loop.vaxis.caps.kitty_keyboard = true; }, .cap_kitty_graphics => { - if (!vx.caps.kitty_graphics) { + if (!loop.vaxis.caps.kitty_graphics) { log.info("kitty graphics capability detected", .{}); - vx.caps.kitty_graphics = true; + loop.vaxis.caps.kitty_graphics = true; } }, .cap_rgb => { log.info("rgb capability detected", .{}); - vx.caps.rgb = true; + loop.vaxis.caps.rgb = true; }, .cap_unicode => { log.info("unicode capability detected", .{}); - vx.caps.unicode = true; - vx.screen.unicode = true; + loop.vaxis.caps.unicode = true; + loop.vaxis.screen.unicode = true; }, .cap_da1 => { - std.Thread.Futex.wake(&vx.query_futex, 10); + std.Thread.Futex.wake(&loop.vaxis.query_futex, 10); }, } } diff --git a/src/Vaxis.zig b/src/Vaxis.zig new file mode 100644 index 0000000..c88dca2 --- /dev/null +++ b/src/Vaxis.zig @@ -0,0 +1,636 @@ +const std = @import("std"); +const atomic = std.atomic; +const base64 = std.base64; +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 Tty = @import("Tty.zig"); +const Unicode = @import("Unicode.zig"); +const Window = @import("Window.zig"); + +const Hyperlink = Cell.Hyperlink; +const Shape = Mouse.Shape; +const Style = Cell.Style; +const Winsize = Tty.Winsize; + +const ctlseqs = @import("ctlseqs.zig"); +const gwidth = @import("gwidth.zig"); + +const Vaxis = @This(); + +const log = std.log.scoped(.vaxis); + +pub const Capabilities = struct { + kitty_keyboard: bool = false, + kitty_graphics: bool = false, + rgb: bool = false, + unicode: bool = false, +}; + +pub const Options = struct {}; + +tty: ?Tty, + +/// the screen we write to +screen: Screen, +/// The last screen we drew. We keep this so we can efficiently update on +/// the next render +screen_last: InternalScreen = undefined, + +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, +} = .{}, + +caps: Capabilities = .{}, + +/// if we should redraw the entire screen on the next render +refresh: bool = false, + +/// blocks the main thread until a DA1 query has been received, or the +/// futex times out +query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), + +// images +next_img_id: u32 = 1, + +// statistics +renders: usize = 0, +render_dur: i128 = 0, +render_timer: std.time.Timer, + +/// Initialize Vaxis with runtime options +pub fn init(_: Options) !Vaxis { + return .{ + .tty = null, + .screen = .{}, + .screen_last = .{}, + .render_timer = try std.time.Timer.start(), + }; +} + +/// Resets the terminal to it's original state. If an allocator is +/// passed, this will free resources associated with Vaxis. This is left as an +/// optional so applications can choose to not free resources when the +/// application will be exiting anyways +pub fn deinit(self: *Vaxis, alloc: ?std.mem.Allocator) void { + if (self.tty) |_| { + var tty = &self.tty.?; + if (self.state.kitty_keyboard) { + _ = tty.write(ctlseqs.csi_u_pop) catch {}; + } + if (self.state.mouse) { + _ = tty.write(ctlseqs.mouse_reset) catch {}; + } + if (self.state.bracketed_paste) { + _ = tty.write(ctlseqs.bp_reset) catch {}; + } + if (self.state.alt_screen) { + _ = tty.write(ctlseqs.rmcup) catch {}; + } + // always show the cursor on exit + _ = tty.write(ctlseqs.show_cursor) catch {}; + tty.flush() catch {}; + tty.deinit(); + } + if (alloc) |a| { + self.screen.deinit(a); + self.screen_last.deinit(a); + } + if (self.renders > 0) { + const tpr = @divTrunc(self.render_dur, self.renders); + log.debug("total renders = {d}", .{self.renders}); + log.debug("microseconds per render = {d}", .{tpr}); + } +} + +/// 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 +pub fn resize(self: *Vaxis, alloc: std.mem.Allocator, winsize: Winsize) !void { + log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); + self.screen.deinit(alloc); + self.screen = try Screen.init(alloc, winsize); + self.screen.unicode = self.caps.unicode; + // try self.screen.int(alloc, winsize.cols, winsize.rows); + // we only init our current screen. This has the effect of redrawing + // every cell + self.screen_last.deinit(alloc); + self.screen_last = try InternalScreen.init(alloc, winsize.cols, winsize.rows); + // try self.screen_last.resize(alloc, winsize.cols, winsize.rows); +} + +/// returns a Window comprising of the entire terminal screen +pub fn window(self: *Vaxis) Window { + return .{ + .x_off = 0, + .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) !void { + if (self.state.alt_screen) return; + var tty = self.tty orelse return; + _ = try tty.write(ctlseqs.smcup); + try tty.flush(); + self.state.alt_screen = true; +} + +/// exit the alternate screen +pub fn exitAltScreen(self: *Vaxis) !void { + if (!self.state.alt_screen) return; + var tty = self.tty orelse return; + _ = try tty.write(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 +pub fn queryTerminal(self: *Vaxis) !void { + var tty = self.tty orelse return; + + // 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: 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.write(ctlseqs.decrqm_unicode); + _ = try tty.write(ctlseqs.decrqm_color_theme); + // TODO: XTVERSION has a DCS response. uncomment when we can parse + // that + // _ = try tty.write(ctlseqs.xtversion); + _ = try tty.write(ctlseqs.csi_u_query); + _ = try tty.write(ctlseqs.kitty_graphics_query); + // TODO: sixel geometry query interferes with F4 keys. + // _ = try tty.write(ctlseqs.sixel_geometry_query); + + // TODO: XTGETTCAP queries ("RGB", "Smulx") + + _ = try tty.write(ctlseqs.primary_device_attrs); + try tty.flush(); + + // 1 second timeout + std.Thread.Futex.timedWait(&self.query_futex, 0, 1 * std.time.ns_per_s) catch {}; + + // enable detected features + if (self.caps.kitty_keyboard) { + try self.enableKittyKeyboard(.{}); + } + if (self.caps.unicode) { + _ = try tty.write(ctlseqs.unicode_set); + } +} + +// the next render call will refresh the entire screen +pub fn queueRefresh(self: *Vaxis) void { + self.refresh = true; +} + +/// draws the screen to the terminal +pub fn render(self: *Vaxis) !void { + var tty = self.tty orelse return; + self.renders += 1; + self.render_timer.reset(); + defer { + self.render_dur += self.render_timer.read() / std.time.ns_per_us; + } + + defer self.refresh = false; + defer tty.flush() catch {}; + + // Set up sync before we write anything + // TODO: optimize sync so we only sync _when we have changes_. This + // requires a smarter buffered writer, we'll probably have to write + // our own + _ = try tty.write(ctlseqs.sync_set); + defer _ = tty.write(ctlseqs.sync_reset) catch {}; + + // Send the cursor to 0,0 + // TODO: this needs to move after we optimize writes. We only do + // this if we have an update to make. We also need to hide cursor + // and then reshow it if needed + _ = try tty.write(ctlseqs.hide_cursor); + _ = try tty.write(ctlseqs.home); + _ = try tty.write(ctlseqs.sgr_reset); + + // initialize some variables + var reposition: bool = false; + var row: usize = 0; + var col: usize = 0; + var cursor: Style = .{}; + var link: Hyperlink = .{}; + + // Clear all images + _ = try tty.write(ctlseqs.kitty_graphics_clear); + + var i: usize = 0; + while (i < self.screen.buf.len) { + const cell = self.screen.buf[i]; + defer { + // advance by the width of this char mod 1 + const w = blk: { + if (cell.char.width != 0) break :blk cell.char.width; + + const method: gwidth.Method = if (self.caps.unicode) .unicode else .wcwidth; + const width = gwidth.gwidth(cell.char.grapheme, method) catch 1; + break :blk @max(1, width); + }; + 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; + 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) { + reposition = true; + // Close any osc8 sequence we might be in before + // repositioning + if (link.uri.len > 0) { + _ = try tty.write(ctlseqs.osc8_clear); + } + continue; + } + 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); + + // reposition the cursor, if needed + if (reposition) { + try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 }); + } + + if (cell.image) |img| { + if (img.size) |size| { + try std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.kitty_graphics_scale, + .{ img.img_id, img.z_index, size.cols, size.rows }, + ); + } else { + try std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.kitty_graphics_place, + .{ img.img_id, img.z_index }, + ); + } + } + + // something is different, so let's loop through everything and + // find out what + + // foreground + if (!std.meta.eql(cursor.fg, cell.style.fg)) { + const writer = tty.buffered_writer.writer(); + switch (cell.style.fg) { + .default => _ = try tty.write(ctlseqs.fg_reset), + .index => |idx| { + switch (idx) { + 0...7 => try std.fmt.format(writer, ctlseqs.fg_base, .{idx}), + 8...15 => try std.fmt.format(writer, ctlseqs.fg_bright, .{idx - 8}), + else => try std.fmt.format(writer, ctlseqs.fg_indexed, .{idx}), + } + }, + .rgb => |rgb| { + try std.fmt.format(writer, ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }); + }, + } + } + // background + if (!std.meta.eql(cursor.bg, cell.style.bg)) { + const writer = tty.buffered_writer.writer(); + switch (cell.style.bg) { + .default => _ = try tty.write(ctlseqs.bg_reset), + .index => |idx| { + switch (idx) { + 0...7 => try std.fmt.format(writer, ctlseqs.bg_base, .{idx}), + 8...15 => try std.fmt.format(writer, ctlseqs.bg_bright, .{idx - 8}), + else => try std.fmt.format(writer, ctlseqs.bg_indexed, .{idx}), + } + }, + .rgb => |rgb| { + try std.fmt.format(writer, ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }); + }, + } + } + // underline color + if (!std.meta.eql(cursor.ul, cell.style.ul)) { + const writer = tty.buffered_writer.writer(); + switch (cell.style.bg) { + .default => _ = try tty.write(ctlseqs.ul_reset), + .index => |idx| { + try std.fmt.format(writer, ctlseqs.ul_indexed, .{idx}); + }, + .rgb => |rgb| { + try std.fmt.format(writer, ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }); + }, + } + } + // underline style + if (!std.meta.eql(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.write(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.write(seq); + if (cell.style.dim) { + _ = try tty.write(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.write(seq); + if (cell.style.bold) { + _ = try tty.write(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.write(seq); + } + // dim + if (cursor.blink != cell.style.blink) { + const seq = switch (cell.style.blink) { + true => ctlseqs.blink_set, + false => ctlseqs.blink_reset, + }; + _ = try tty.write(seq); + } + // reverse + if (cursor.reverse != cell.style.reverse) { + const seq = switch (cell.style.reverse) { + true => ctlseqs.reverse_set, + false => ctlseqs.reverse_reset, + }; + _ = try tty.write(seq); + } + // invisible + if (cursor.invisible != cell.style.invisible) { + const seq = switch (cell.style.invisible) { + true => ctlseqs.invisible_set, + false => ctlseqs.invisible_reset, + }; + _ = try tty.write(seq); + } + // strikethrough + if (cursor.strikethrough != cell.style.strikethrough) { + const seq = switch (cell.style.strikethrough) { + true => ctlseqs.strikethrough_set, + false => ctlseqs.strikethrough_reset, + }; + _ = try tty.write(seq); + } + + // url + if (!std.meta.eql(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 = ""; + } + const writer = tty.buffered_writer.writer(); + try std.fmt.format(writer, ctlseqs.osc8, .{ ps, cell.link.uri }); + } + _ = try tty.write(cell.char.grapheme); + } + if (self.screen.cursor_vis) { + try std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.cup, + .{ + self.screen.cursor_row + 1, + self.screen.cursor_col + 1, + }, + ); + _ = try tty.write(ctlseqs.show_cursor); + } + if (self.screen.mouse_shape != self.screen_last.mouse_shape) { + try std.fmt.format( + tty.buffered_writer.writer(), + 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 std.fmt.format( + tty.buffered_writer.writer(), + ctlseqs.cursor_shape, + .{@intFromEnum(self.screen.cursor_shape)}, + ); + self.screen_last.cursor_shape = self.screen.cursor_shape; + } +} + +fn enableKittyKeyboard(self: *Vaxis, flags: Key.KittyFlags) !void { + self.state.kitty_keyboard = true; + const flag_int: u5 = @bitCast(flags); + try std.fmt.format( + self.tty.?.buffered_writer.writer(), + ctlseqs.csi_u_push, + .{ + flag_int, + }, + ); + try self.tty.?.flush(); +} + +/// send a system notification +pub fn notify(self: *Vaxis, title: ?[]const u8, body: []const u8) !void { + if (self.tty == null) return; + if (title) |t| { + try std.fmt.format( + self.tty.?.buffered_writer.writer(), + ctlseqs.osc777_notify, + .{ t, body }, + ); + } else { + try std.fmt.format( + self.tty.?.buffered_writer.writer(), + ctlseqs.osc9_notify, + .{body}, + ); + } + try self.tty.?.flush(); +} + +/// sets the window title +pub fn setTitle(self: *Vaxis, title: []const u8) !void { + if (self.tty == null) return; + try std.fmt.format( + self.tty.?.buffered_writer.writer(), + ctlseqs.osc2_set_title, + .{title}, + ); + try self.tty.?.flush(); +} + +// turn bracketed paste on or off. An event will be sent at the +// beginning and end of a detected paste. All keystrokes between these +// events were pasted +pub fn setBracketedPaste(self: *Vaxis, enable: bool) !void { + if (self.tty == null) return; + self.state.bracketed_paste = enable; + const seq = if (enable) + ctlseqs.bp_set + else + ctlseqs.bp_reset; + _ = try self.tty.?.write(seq); + try self.tty.?.flush(); +} + +/// set the mouse shape +pub fn setMouseShape(self: *Vaxis, shape: Shape) void { + self.screen.mouse_shape = shape; +} + +/// turn mouse reporting on or off +pub fn setMouseMode(self: *Vaxis, enable: bool) !void { + var tty = self.tty orelse return; + self.state.mouse = enable; + if (enable) { + _ = try tty.write(ctlseqs.mouse_set); + try tty.flush(); + } else { + _ = try tty.write(ctlseqs.mouse_reset); + try tty.flush(); + } +} + +pub fn loadImage( + self: *Vaxis, + alloc: std.mem.Allocator, + src: Image.Source, +) !Image { + if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; + var tty = self.tty orelse return error.NoTTY; + defer self.next_img_id += 1; + + const writer = tty.buffered_writer.writer(); + + var img = switch (src) { + .path => |path| try zigimg.Image.fromFilePath(alloc, path), + .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes), + }; + defer img.deinit(); + const png_buf = try alloc.alloc(u8, img.imageByteSize()); + defer alloc.free(png_buf); + const png = try img.writeToMemory(png_buf, .{ .png = .{} }); + const b64_buf = try alloc.alloc(u8, base64.calcSize(png.len)); + const encoded = base64.encode(b64_buf, png); + defer alloc.free(b64_buf); + + const id = self.next_img_id; + + log.debug("transmitting kitty image: id={d}, len={d}", .{ id, encoded.len }); + + if (encoded.len < 4096) { + try std.fmt.format( + writer, + "\x1b_Gf=100,i={d};{s}\x1b\\", + .{ + id, + encoded, + }, + ); + } else { + var n: usize = 4096; + + try std.fmt.format( + writer, + "\x1b_Gf=100,i={d},m=1;{s}\x1b\\", + .{ id, encoded[0..n] }, + ); + while (n < encoded.len) : (n += 4096) { + const end: usize = @min(n + 4096, encoded.len); + const m: u2 = if (end == encoded.len) 0 else 1; + try std.fmt.format( + writer, + "\x1b_Gm={d};{s}\x1b\\", + .{ + m, + encoded[n..end], + }, + ); + } + } + try tty.buffered_writer.flush(); + return .{ + .id = id, + .width = img.width, + .height = img.height, + }; +} + +/// deletes an image from the terminal's memory +pub fn freeImage(self: Vaxis, id: u32) void { + var tty = self.tty orelse return; + const writer = tty.buffered_writer.writer(); + std.fmt.format(writer, "\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| { + log.err("couldn't delete image {d}: {}", .{ id, err }); + return; + }; + tty.buffered_writer.flush() catch |err| { + log.err("couldn't flush writer: {}", .{err}); + }; +} diff --git a/src/main.zig b/src/main.zig index 9644603..5887f27 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,7 +1,7 @@ const std = @import("std"); -pub const Vaxis = @import("vaxis.zig").Vaxis; -pub const Options = @import("Options.zig"); +pub const Vaxis = @import("Vaxis.zig"); +pub const Loop = @import("Loop.zig").Loop; pub const Queue = @import("queue.zig").Queue; pub const Key = @import("Key.zig"); @@ -22,8 +22,8 @@ pub const widgets = @import("widgets.zig"); pub const gwidth = @import("gwidth.zig"); /// Initialize a Vaxis application. -pub fn init(comptime Event: type, opts: Options) !Vaxis(Event) { - return Vaxis(Event).init(opts); +pub fn init(opts: Vaxis.Options) !Vaxis { + return Vaxis.init(opts); } test { diff --git a/src/vaxis.zig b/src/vaxis.zig deleted file mode 100644 index c1e1cd2..0000000 --- a/src/vaxis.zig +++ /dev/null @@ -1,708 +0,0 @@ -const std = @import("std"); -const builtin = @import("builtin"); -const atomic = std.atomic; -const base64 = std.base64.standard.Encoder; - -const Queue = @import("queue.zig").Queue; -const ctlseqs = @import("ctlseqs.zig"); -const Tty = @import("Tty.zig"); -const Winsize = Tty.Winsize; -const Key = @import("Key.zig"); -const Screen = @import("Screen.zig"); -const InternalScreen = @import("InternalScreen.zig"); -const Window = @import("Window.zig"); -const Options = @import("Options.zig"); -const Style = @import("Cell.zig").Style; -const Hyperlink = @import("Cell.zig").Hyperlink; -const gwidth = @import("gwidth.zig"); -const Shape = @import("Mouse.zig").Shape; -const Image = @import("Image.zig"); -const zigimg = @import("zigimg"); - -/// Vaxis is the entrypoint for a Vaxis application. The provided type T should -/// be a tagged union which contains all of the events the application will -/// handle. Vaxis will look for the following fields on the union and, if -/// found, emit them via the "nextEvent" method -/// -/// The following events are available: -/// - `key_press: Key`, for key press events -/// - `winsize: Winsize`, for resize events. Must call app.resize when receiving -/// this event -/// - `focus_in` and `focus_out` for focus events -pub fn Vaxis(comptime T: type) type { - return struct { - const Self = @This(); - - const log = std.log.scoped(.vaxis); - - pub const Event = T; - - pub const Capabilities = struct { - kitty_keyboard: bool = false, - kitty_graphics: bool = false, - rgb: bool = false, - unicode: bool = false, - }; - - /// the event queue for Vaxis - // - // TODO: is 512 ok? - queue: Queue(T, 512), - - tty: ?Tty, - - read_thread: ?std.Thread, - - /// the screen we write to - screen: Screen, - /// The last screen we drew. We keep this so we can efficiently update on - /// the next render - screen_last: InternalScreen = undefined, - - 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, - } = .{}, - - caps: Capabilities = .{}, - - /// if we should redraw the entire screen on the next render - refresh: bool = false, - - /// blocks the main thread until a DA1 query has been received, or the - /// futex times out - query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), - - // images - next_img_id: u32 = 1, - - // statistics - renders: usize = 0, - render_dur: i128 = 0, - render_timer: std.time.Timer, - - /// Initialize Vaxis with runtime options - pub fn init(_: Options) !Self { - return .{ - .queue = .{}, - .tty = null, - .screen = .{}, - .screen_last = .{}, - .render_timer = try std.time.Timer.start(), - .read_thread = null, - }; - } - - /// Resets the terminal to it's original state. If an allocator is - /// passed, this will free resources associated with Vaxis. This is left as an - /// optional so applications can choose to not free resources when the - /// application will be exiting anyways - pub fn deinit(self: *Self, alloc: ?std.mem.Allocator) void { - if (self.tty) |_| { - var tty = &self.tty.?; - if (self.state.kitty_keyboard) { - _ = tty.write(ctlseqs.csi_u_pop) catch {}; - } - if (self.state.mouse) { - _ = tty.write(ctlseqs.mouse_reset) catch {}; - } - if (self.state.bracketed_paste) { - _ = tty.write(ctlseqs.bp_reset) catch {}; - } - if (self.state.alt_screen) { - _ = tty.write(ctlseqs.rmcup) catch {}; - } - // always show the cursor on exit - _ = tty.write(ctlseqs.show_cursor) catch {}; - tty.flush() catch {}; - tty.deinit(); - } - if (alloc) |a| { - self.screen.deinit(a); - self.screen_last.deinit(a); - } - if (self.renders > 0) { - const tpr = @divTrunc(self.render_dur, self.renders); - log.debug("total renders = {d}", .{self.renders}); - log.debug("microseconds per render = {d}", .{tpr}); - } - } - - /// spawns the input thread to start listening to the tty for input - pub fn startReadThread(self: *Self) !void { - self.tty = try Tty.init(); - // run our tty read loop in it's own thread - self.read_thread = try std.Thread.spawn(.{}, Tty.run, .{ &self.tty.?, T, self }); - } - - /// stops reading from the tty - pub fn stopReadThread(self: *Self) void { - if (self.tty) |_| { - var tty = &self.tty.?; - tty.stop(); - if (self.read_thread) |thread| { - thread.join(); - self.read_thread = null; - } - } - } - - /// returns the next available event, blocking until one is available - pub fn nextEvent(self: *Self) T { - return self.queue.pop(); - } - - /// blocks until an event is available. Useful when your application is - /// operating on a poll + drain architecture (see tryEvent) - pub fn pollEvent(self: *Self) void { - self.queue.poll(); - } - - /// returns an event if one is available, otherwise null. Non-blocking. - pub fn tryEvent(self: *Self) ?Event { - return self.queue.tryPop(); - } - - /// posts an event into the event queue. Will block if there is not - /// capacity for the event - pub fn postEvent(self: *Self, event: T) void { - self.queue.push(event); - } - - /// 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 - pub fn resize(self: *Self, alloc: std.mem.Allocator, winsize: Winsize) !void { - log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); - self.screen.deinit(alloc); - self.screen = try Screen.init(alloc, winsize); - self.screen.unicode = self.caps.unicode; - // try self.screen.int(alloc, winsize.cols, winsize.rows); - // we only init our current screen. This has the effect of redrawing - // every cell - self.screen_last.deinit(alloc); - self.screen_last = try InternalScreen.init(alloc, winsize.cols, winsize.rows); - // try self.screen_last.resize(alloc, winsize.cols, winsize.rows); - } - - /// returns a Window comprising of the entire terminal screen - pub fn window(self: *Self) Window { - return .{ - .x_off = 0, - .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: *Self) !void { - if (self.state.alt_screen) return; - var tty = self.tty orelse return; - _ = try tty.write(ctlseqs.smcup); - try tty.flush(); - self.state.alt_screen = true; - } - - /// exit the alternate screen - pub fn exitAltScreen(self: *Self) !void { - if (!self.state.alt_screen) return; - var tty = self.tty orelse return; - _ = try tty.write(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 - pub fn queryTerminal(self: *Self) !void { - var tty = self.tty orelse return; - - 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: 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.write(ctlseqs.decrqm_unicode); - _ = try tty.write(ctlseqs.decrqm_color_theme); - // TODO: XTVERSION has a DCS response. uncomment when we can parse - // that - // _ = try tty.write(ctlseqs.xtversion); - _ = try tty.write(ctlseqs.csi_u_query); - _ = try tty.write(ctlseqs.kitty_graphics_query); - // TODO: sixel geometry query interferes with F4 keys. - // _ = try tty.write(ctlseqs.sixel_geometry_query); - - // TODO: XTGETTCAP queries ("RGB", "Smulx") - - _ = try tty.write(ctlseqs.primary_device_attrs); - try tty.flush(); - - // 1 second timeout - std.Thread.Futex.timedWait(&self.query_futex, 0, 1 * std.time.ns_per_s) catch {}; - - // enable detected features - if (self.caps.kitty_keyboard) { - try self.enableKittyKeyboard(.{}); - } - if (self.caps.unicode) { - _ = try tty.write(ctlseqs.unicode_set); - } - } - - // the next render call will refresh the entire screen - pub fn queueRefresh(self: *Self) void { - self.refresh = true; - } - - /// draws the screen to the terminal - pub fn render(self: *Self) !void { - var tty = self.tty orelse return; - self.renders += 1; - self.render_timer.reset(); - defer { - self.render_dur += self.render_timer.read() / std.time.ns_per_us; - } - - defer self.refresh = false; - defer tty.flush() catch {}; - - // Set up sync before we write anything - // TODO: optimize sync so we only sync _when we have changes_. This - // requires a smarter buffered writer, we'll probably have to write - // our own - _ = try tty.write(ctlseqs.sync_set); - defer _ = tty.write(ctlseqs.sync_reset) catch {}; - - // Send the cursor to 0,0 - // TODO: this needs to move after we optimize writes. We only do - // this if we have an update to make. We also need to hide cursor - // and then reshow it if needed - _ = try tty.write(ctlseqs.hide_cursor); - _ = try tty.write(ctlseqs.home); - _ = try tty.write(ctlseqs.sgr_reset); - - // initialize some variables - var reposition: bool = false; - var row: usize = 0; - var col: usize = 0; - var cursor: Style = .{}; - var link: Hyperlink = .{}; - - // Clear all images - _ = try tty.write(ctlseqs.kitty_graphics_clear); - - var i: usize = 0; - while (i < self.screen.buf.len) { - const cell = self.screen.buf[i]; - defer { - // advance by the width of this char mod 1 - const w = blk: { - if (cell.char.width != 0) break :blk cell.char.width; - - const method: gwidth.Method = if (self.caps.unicode) .unicode else .wcwidth; - const width = gwidth.gwidth(cell.char.grapheme, method) catch 1; - break :blk @max(1, width); - }; - 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; - 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) { - reposition = true; - // Close any osc8 sequence we might be in before - // repositioning - if (link.uri.len > 0) { - _ = try tty.write(ctlseqs.osc8_clear); - } - continue; - } - 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); - - // reposition the cursor, if needed - if (reposition) { - try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 }); - } - - if (cell.image) |img| { - if (img.size) |size| { - try std.fmt.format( - tty.buffered_writer.writer(), - ctlseqs.kitty_graphics_scale, - .{ img.img_id, img.z_index, size.cols, size.rows }, - ); - } else { - try std.fmt.format( - tty.buffered_writer.writer(), - ctlseqs.kitty_graphics_place, - .{ img.img_id, img.z_index }, - ); - } - } - - // something is different, so let's loop through everything and - // find out what - - // foreground - if (!std.meta.eql(cursor.fg, cell.style.fg)) { - const writer = tty.buffered_writer.writer(); - switch (cell.style.fg) { - .default => _ = try tty.write(ctlseqs.fg_reset), - .index => |idx| { - switch (idx) { - 0...7 => try std.fmt.format(writer, ctlseqs.fg_base, .{idx}), - 8...15 => try std.fmt.format(writer, ctlseqs.fg_bright, .{idx - 8}), - else => try std.fmt.format(writer, ctlseqs.fg_indexed, .{idx}), - } - }, - .rgb => |rgb| { - try std.fmt.format(writer, ctlseqs.fg_rgb, .{ rgb[0], rgb[1], rgb[2] }); - }, - } - } - // background - if (!std.meta.eql(cursor.bg, cell.style.bg)) { - const writer = tty.buffered_writer.writer(); - switch (cell.style.bg) { - .default => _ = try tty.write(ctlseqs.bg_reset), - .index => |idx| { - switch (idx) { - 0...7 => try std.fmt.format(writer, ctlseqs.bg_base, .{idx}), - 8...15 => try std.fmt.format(writer, ctlseqs.bg_bright, .{idx - 8}), - else => try std.fmt.format(writer, ctlseqs.bg_indexed, .{idx}), - } - }, - .rgb => |rgb| { - try std.fmt.format(writer, ctlseqs.bg_rgb, .{ rgb[0], rgb[1], rgb[2] }); - }, - } - } - // underline color - if (!std.meta.eql(cursor.ul, cell.style.ul)) { - const writer = tty.buffered_writer.writer(); - switch (cell.style.bg) { - .default => _ = try tty.write(ctlseqs.ul_reset), - .index => |idx| { - try std.fmt.format(writer, ctlseqs.ul_indexed, .{idx}); - }, - .rgb => |rgb| { - try std.fmt.format(writer, ctlseqs.ul_rgb, .{ rgb[0], rgb[1], rgb[2] }); - }, - } - } - // underline style - if (!std.meta.eql(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.write(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.write(seq); - if (cell.style.dim) { - _ = try tty.write(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.write(seq); - if (cell.style.bold) { - _ = try tty.write(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.write(seq); - } - // dim - if (cursor.blink != cell.style.blink) { - const seq = switch (cell.style.blink) { - true => ctlseqs.blink_set, - false => ctlseqs.blink_reset, - }; - _ = try tty.write(seq); - } - // reverse - if (cursor.reverse != cell.style.reverse) { - const seq = switch (cell.style.reverse) { - true => ctlseqs.reverse_set, - false => ctlseqs.reverse_reset, - }; - _ = try tty.write(seq); - } - // invisible - if (cursor.invisible != cell.style.invisible) { - const seq = switch (cell.style.invisible) { - true => ctlseqs.invisible_set, - false => ctlseqs.invisible_reset, - }; - _ = try tty.write(seq); - } - // strikethrough - if (cursor.strikethrough != cell.style.strikethrough) { - const seq = switch (cell.style.strikethrough) { - true => ctlseqs.strikethrough_set, - false => ctlseqs.strikethrough_reset, - }; - _ = try tty.write(seq); - } - - // url - if (!std.meta.eql(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 = ""; - } - const writer = tty.buffered_writer.writer(); - try std.fmt.format(writer, ctlseqs.osc8, .{ ps, cell.link.uri }); - } - _ = try tty.write(cell.char.grapheme); - } - if (self.screen.cursor_vis) { - try std.fmt.format( - tty.buffered_writer.writer(), - ctlseqs.cup, - .{ - self.screen.cursor_row + 1, - self.screen.cursor_col + 1, - }, - ); - _ = try tty.write(ctlseqs.show_cursor); - } - if (self.screen.mouse_shape != self.screen_last.mouse_shape) { - try std.fmt.format( - tty.buffered_writer.writer(), - 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 std.fmt.format( - tty.buffered_writer.writer(), - ctlseqs.cursor_shape, - .{@intFromEnum(self.screen.cursor_shape)}, - ); - self.screen_last.cursor_shape = self.screen.cursor_shape; - } - } - - fn enableKittyKeyboard(self: *Self, flags: Key.KittyFlags) !void { - self.state.kitty_keyboard = true; - const flag_int: u5 = @bitCast(flags); - try std.fmt.format( - self.tty.?.buffered_writer.writer(), - ctlseqs.csi_u_push, - .{ - flag_int, - }, - ); - try self.tty.?.flush(); - } - - /// send a system notification - pub fn notify(self: *Self, title: ?[]const u8, body: []const u8) !void { - if (self.tty == null) return; - if (title) |t| { - try std.fmt.format( - self.tty.?.buffered_writer.writer(), - ctlseqs.osc777_notify, - .{ t, body }, - ); - } else { - try std.fmt.format( - self.tty.?.buffered_writer.writer(), - ctlseqs.osc9_notify, - .{body}, - ); - } - try self.tty.?.flush(); - } - - /// sets the window title - pub fn setTitle(self: *Self, title: []const u8) !void { - if (self.tty == null) return; - try std.fmt.format( - self.tty.?.buffered_writer.writer(), - ctlseqs.osc2_set_title, - .{title}, - ); - try self.tty.?.flush(); - } - - // turn bracketed paste on or off. An event will be sent at the - // beginning and end of a detected paste. All keystrokes between these - // events were pasted - pub fn setBracketedPaste(self: *Self, enable: bool) !void { - if (self.tty == null) return; - self.state.bracketed_paste = enable; - const seq = if (enable) - ctlseqs.bp_set - else - ctlseqs.bp_reset; - _ = try self.tty.?.write(seq); - try self.tty.?.flush(); - } - - /// set the mouse shape - pub fn setMouseShape(self: *Self, shape: Shape) void { - self.screen.mouse_shape = shape; - } - - /// turn mouse reporting on or off - pub fn setMouseMode(self: *Self, enable: bool) !void { - var tty = self.tty orelse return; - self.state.mouse = enable; - if (enable) { - _ = try tty.write(ctlseqs.mouse_set); - try tty.flush(); - } else { - _ = try tty.write(ctlseqs.mouse_reset); - try tty.flush(); - } - } - - pub fn loadImage( - self: *Self, - alloc: std.mem.Allocator, - src: Image.Source, - ) !Image { - if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; - var tty = self.tty orelse return error.NoTTY; - defer self.next_img_id += 1; - - const writer = tty.buffered_writer.writer(); - - var img = switch (src) { - .path => |path| try zigimg.Image.fromFilePath(alloc, path), - .mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes), - }; - defer img.deinit(); - const png_buf = try alloc.alloc(u8, img.imageByteSize()); - defer alloc.free(png_buf); - const png = try img.writeToMemory(png_buf, .{ .png = .{} }); - const b64_buf = try alloc.alloc(u8, base64.calcSize(png.len)); - const encoded = base64.encode(b64_buf, png); - defer alloc.free(b64_buf); - - const id = self.next_img_id; - - log.debug("transmitting kitty image: id={d}, len={d}", .{ id, encoded.len }); - - if (encoded.len < 4096) { - try std.fmt.format( - writer, - "\x1b_Gf=100,i={d};{s}\x1b\\", - .{ - id, - encoded, - }, - ); - } else { - var n: usize = 4096; - - try std.fmt.format( - writer, - "\x1b_Gf=100,i={d},m=1;{s}\x1b\\", - .{ id, encoded[0..n] }, - ); - while (n < encoded.len) : (n += 4096) { - const end: usize = @min(n + 4096, encoded.len); - const m: u2 = if (end == encoded.len) 0 else 1; - try std.fmt.format( - writer, - "\x1b_Gm={d};{s}\x1b\\", - .{ - m, - encoded[n..end], - }, - ); - } - } - try tty.buffered_writer.flush(); - return .{ - .id = id, - .width = img.width, - .height = img.height, - }; - } - - /// deletes an image from the terminal's memory - pub fn freeImage(self: Self, id: u32) void { - var tty = self.tty orelse return; - const writer = tty.buffered_writer.writer(); - std.fmt.format(writer, "\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| { - log.err("couldn't delete image {d}: {}", .{ id, err }); - return; - }; - tty.buffered_writer.flush() catch |err| { - log.err("couldn't flush writer: {}", .{err}); - }; - } - }; -} - -// test "Vaxis: event queueing" { -// const Event = union(enum) { -// key: void, -// }; -// var vx: Vaxis(Event) = try Vaxis(Event).init(.{}); -// defer vx.deinit(null); -// vx.postEvent(.{ .key = {} }); -// const event = vx.nextEvent(); -// try std.testing.expect(event == .key); -// }