a modern tui library written in zig
at main 16 kB view raw
1const std = @import("std"); 2const builtin = @import("builtin"); 3 4const GraphemeCache = @import("GraphemeCache.zig"); 5const Parser = @import("Parser.zig"); 6const Queue = @import("queue.zig").Queue; 7const vaxis = @import("main.zig"); 8const Tty = vaxis.Tty; 9const Vaxis = @import("Vaxis.zig"); 10 11const log = std.log.scoped(.vaxis); 12 13pub fn Loop(comptime T: type) type { 14 return struct { 15 const Self = @This(); 16 17 const Event = T; 18 19 tty: *Tty, 20 vaxis: *Vaxis, 21 22 queue: Queue(T, 512) = .{}, 23 thread: ?std.Thread = null, 24 should_quit: bool = false, 25 26 /// Initialize the event loop. This is an intrusive init so that we have 27 /// a stable pointer to register signal callbacks with posix TTYs 28 pub fn init(self: *Self) !void { 29 switch (builtin.os.tag) { 30 .windows => {}, 31 else => { 32 if (!builtin.is_test) { 33 const handler: Tty.SignalHandler = .{ 34 .context = self, 35 .callback = Self.winsizeCallback, 36 }; 37 try Tty.notifyWinsize(handler); 38 } 39 }, 40 } 41 } 42 43 /// spawns the input thread to read input from the tty 44 pub fn start(self: *Self) !void { 45 if (self.thread) |_| return; 46 self.thread = try std.Thread.spawn(.{}, Self.ttyRun, .{ 47 self, 48 self.vaxis.opts.system_clipboard_allocator, 49 }); 50 } 51 52 /// stops reading from the tty. 53 pub fn stop(self: *Self) void { 54 // If we don't have a thread, we have nothing to stop 55 if (self.thread == null) return; 56 self.should_quit = true; 57 // trigger a read 58 self.vaxis.deviceStatusReport(self.tty.writer()) catch {}; 59 60 if (self.thread) |thread| { 61 thread.join(); 62 self.thread = null; 63 self.should_quit = false; 64 } 65 } 66 67 /// returns the next available event, blocking until one is available 68 pub fn nextEvent(self: *Self) T { 69 return self.queue.pop(); 70 } 71 72 /// blocks until an event is available. Useful when your application is 73 /// operating on a poll + drain architecture (see tryEvent) 74 pub fn pollEvent(self: *Self) void { 75 self.queue.poll(); 76 } 77 78 /// returns an event if one is available, otherwise null. Non-blocking. 79 pub fn tryEvent(self: *Self) ?T { 80 return self.queue.tryPop(); 81 } 82 83 /// posts an event into the event queue. Will block if there is not 84 /// capacity for the event 85 pub fn postEvent(self: *Self, event: T) void { 86 self.queue.push(event); 87 } 88 89 pub fn tryPostEvent(self: *Self, event: T) bool { 90 return self.queue.tryPush(event); 91 } 92 93 pub fn winsizeCallback(ptr: *anyopaque) void { 94 const self: *Self = @ptrCast(@alignCast(ptr)); 95 // We will be receiving winsize updates in-band 96 if (self.vaxis.state.in_band_resize) return; 97 98 const winsize = Tty.getWinsize(self.tty.fd) catch return; 99 if (@hasField(Event, "winsize")) { 100 self.postEvent(.{ .winsize = winsize }); 101 } 102 } 103 104 /// read input from the tty. This is run in a separate thread 105 fn ttyRun( 106 self: *Self, 107 paste_allocator: ?std.mem.Allocator, 108 ) !void { 109 // Return early if we're in test mode to avoid infinite loops 110 if (builtin.is_test) return; 111 112 // initialize a grapheme cache 113 var cache: GraphemeCache = .{}; 114 115 switch (builtin.os.tag) { 116 .windows => { 117 var parser: Parser = .{}; 118 while (!self.should_quit) { 119 const event = try self.tty.nextEvent(&parser, paste_allocator); 120 try handleEventGeneric(self, self.vaxis, &cache, Event, event, null); 121 } 122 }, 123 else => { 124 // get our initial winsize 125 const winsize = try Tty.getWinsize(self.tty.fd); 126 if (@hasField(Event, "winsize")) { 127 self.postEvent(.{ .winsize = winsize }); 128 } 129 130 var parser: Parser = .{}; 131 132 // initialize the read buffer 133 var buf: [1024]u8 = undefined; 134 var read_start: usize = 0; 135 // read loop 136 read_loop: while (!self.should_quit) { 137 const n = try self.tty.read(buf[read_start..]); 138 var seq_start: usize = 0; 139 while (seq_start < n) { 140 const result = try parser.parse(buf[seq_start..n], paste_allocator); 141 if (result.n == 0) { 142 // copy the read to the beginning. We don't use memcpy because 143 // this could be overlapping, and it's also rare 144 const initial_start = seq_start; 145 while (seq_start < n) : (seq_start += 1) { 146 buf[seq_start - initial_start] = buf[seq_start]; 147 } 148 read_start = seq_start - initial_start + 1; 149 continue :read_loop; 150 } 151 read_start = 0; 152 seq_start += result.n; 153 154 const event = result.event orelse continue; 155 try handleEventGeneric(self, self.vaxis, &cache, Event, event, paste_allocator); 156 } 157 } 158 }, 159 } 160 } 161 }; 162} 163 164// Use return on the self.postEvent's so it can either return error union or void 165pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Event: type, event: anytype, paste_allocator: ?std.mem.Allocator) !void { 166 switch (builtin.os.tag) { 167 .windows => { 168 switch (event) { 169 .winsize => |ws| { 170 if (@hasField(Event, "winsize")) { 171 return self.postEvent(.{ .winsize = ws }); 172 } 173 }, 174 .key_press => |key| { 175 // Check for a cursor position response for our explicit width query. This will 176 // always be an F3 key with shift = true, and we must be looking for queries 177 if (key.codepoint == vaxis.Key.f3 and 178 key.mods.shift and 179 !vx.queries_done.load(.unordered)) 180 { 181 log.info("explicit width capability detected", .{}); 182 vx.caps.explicit_width = true; 183 vx.caps.unicode = .unicode; 184 vx.screen.width_method = .unicode; 185 return; 186 } 187 // Check for a cursor position response for our scaled text query. This will 188 // always be an F3 key with alt = true, and we must be looking for queries 189 if (key.codepoint == vaxis.Key.f3 and 190 key.mods.alt and 191 !vx.queries_done.load(.unordered)) 192 { 193 log.info("scaled text capability detected", .{}); 194 vx.caps.scaled_text = true; 195 return; 196 } 197 if (@hasField(Event, "key_press")) { 198 // HACK: yuck. there has to be a better way 199 var mut_key = key; 200 if (key.text) |text| { 201 mut_key.text = cache.put(text); 202 } 203 return self.postEvent(.{ .key_press = mut_key }); 204 } 205 }, 206 .key_release => |key| { 207 if (@hasField(Event, "key_release")) { 208 // HACK: yuck. there has to be a better way 209 var mut_key = key; 210 if (key.text) |text| { 211 mut_key.text = cache.put(text); 212 } 213 return self.postEvent(.{ .key_release = mut_key }); 214 } 215 }, 216 .cap_da1 => { 217 std.Thread.Futex.wake(&vx.query_futex, 10); 218 vx.queries_done.store(true, .unordered); 219 }, 220 .mouse => |mouse| { 221 if (@hasField(Event, "mouse")) { 222 return self.postEvent(.{ .mouse = vx.translateMouse(mouse) }); 223 } 224 }, 225 .focus_in => { 226 if (@hasField(Event, "focus_in")) { 227 return self.postEvent(.focus_in); 228 } 229 }, 230 .focus_out => { 231 if (@hasField(Event, "focus_out")) { 232 return self.postEvent(.focus_out); 233 } 234 }, // Unsupported currently 235 else => {}, 236 } 237 }, 238 else => { 239 switch (event) { 240 .key_press => |key| { 241 // Check for a cursor position response for our explicity width query. This will 242 // always be an F3 key with shift = true, and we must be looking for queries 243 if (key.codepoint == vaxis.Key.f3 and 244 key.mods.shift and 245 !vx.queries_done.load(.unordered)) 246 { 247 log.info("explicit width capability detected", .{}); 248 vx.caps.explicit_width = true; 249 vx.caps.unicode = .unicode; 250 vx.screen.width_method = .unicode; 251 return; 252 } 253 // Check for a cursor position response for our scaled text query. This will 254 // always be an F3 key with alt = true, and we must be looking for queries 255 if (key.codepoint == vaxis.Key.f3 and 256 key.mods.alt and 257 !vx.queries_done.load(.unordered)) 258 { 259 log.info("scaled text capability detected", .{}); 260 vx.caps.scaled_text = true; 261 return; 262 } 263 if (@hasField(Event, "key_press")) { 264 // HACK: yuck. there has to be a better way 265 var mut_key = key; 266 if (key.text) |text| { 267 mut_key.text = cache.put(text); 268 } 269 return self.postEvent(.{ .key_press = mut_key }); 270 } 271 }, 272 .key_release => |key| { 273 if (@hasField(Event, "key_release")) { 274 // HACK: yuck. there has to be a better way 275 var mut_key = key; 276 if (key.text) |text| { 277 mut_key.text = cache.put(text); 278 } 279 return self.postEvent(.{ .key_release = mut_key }); 280 } 281 }, 282 .mouse => |mouse| { 283 if (@hasField(Event, "mouse")) { 284 return self.postEvent(.{ .mouse = vx.translateMouse(mouse) }); 285 } 286 }, 287 .mouse_leave => { 288 if (@hasField(Event, "mouse_leave")) { 289 return self.postEvent(.mouse_leave); 290 } 291 }, 292 .focus_in => { 293 if (@hasField(Event, "focus_in")) { 294 return self.postEvent(.focus_in); 295 } 296 }, 297 .focus_out => { 298 if (@hasField(Event, "focus_out")) { 299 return self.postEvent(.focus_out); 300 } 301 }, 302 .paste_start => { 303 if (@hasField(Event, "paste_start")) { 304 return self.postEvent(.paste_start); 305 } 306 }, 307 .paste_end => { 308 if (@hasField(Event, "paste_end")) { 309 return self.postEvent(.paste_end); 310 } 311 }, 312 .paste => |text| { 313 if (@hasField(Event, "paste")) { 314 return self.postEvent(.{ .paste = text }); 315 } else { 316 if (paste_allocator) |_| 317 paste_allocator.?.free(text); 318 } 319 }, 320 .color_report => |report| { 321 if (@hasField(Event, "color_report")) { 322 return self.postEvent(.{ .color_report = report }); 323 } 324 }, 325 .color_scheme => |scheme| { 326 if (@hasField(Event, "color_scheme")) { 327 return self.postEvent(.{ .color_scheme = scheme }); 328 } 329 }, 330 .cap_kitty_keyboard => { 331 log.info("kitty keyboard capability detected", .{}); 332 vx.caps.kitty_keyboard = true; 333 }, 334 .cap_kitty_graphics => { 335 if (!vx.caps.kitty_graphics) { 336 log.info("kitty graphics capability detected", .{}); 337 vx.caps.kitty_graphics = true; 338 } 339 }, 340 .cap_rgb => { 341 log.info("rgb capability detected", .{}); 342 vx.caps.rgb = true; 343 }, 344 .cap_unicode => { 345 log.info("unicode capability detected", .{}); 346 vx.caps.unicode = .unicode; 347 vx.screen.width_method = .unicode; 348 }, 349 .cap_sgr_pixels => { 350 log.info("pixel mouse capability detected", .{}); 351 vx.caps.sgr_pixels = true; 352 }, 353 .cap_color_scheme_updates => { 354 log.info("color_scheme_updates capability detected", .{}); 355 vx.caps.color_scheme_updates = true; 356 }, 357 .cap_multi_cursor => { 358 log.info("multi cursor capability detected", .{}); 359 vx.caps.multi_cursor = true; 360 }, 361 .cap_da1 => { 362 std.Thread.Futex.wake(&vx.query_futex, 10); 363 vx.queries_done.store(true, .unordered); 364 }, 365 .winsize => |winsize| { 366 vx.state.in_band_resize = true; 367 switch (builtin.os.tag) { 368 .windows => {}, 369 // Reset the signal handler if we are receiving in_band_resize 370 else => Tty.resetSignalHandler(), 371 } 372 if (@hasField(Event, "winsize")) { 373 return self.postEvent(.{ .winsize = winsize }); 374 } 375 }, 376 } 377 }, 378 } 379} 380 381test Loop { 382 const Event = union(enum) { 383 key_press: vaxis.Key, 384 winsize: vaxis.Winsize, 385 focus_in, 386 foo: u8, 387 }; 388 389 var tty = try vaxis.Tty.init(&.{}); 390 defer tty.deinit(); 391 392 var vx = try vaxis.init(std.testing.allocator, .{}); 393 defer vx.deinit(std.testing.allocator, tty.writer()); 394 395 var loop: vaxis.Loop(Event) = .{ .tty = &tty, .vaxis = &vx }; 396 try loop.init(); 397 398 try loop.start(); 399 defer loop.stop(); 400 401 // Optionally enter the alternate screen 402 try vx.enterAltScreen(tty.writer()); 403 try vx.queryTerminal(tty.writer(), 1 * std.time.ns_per_ms); 404}