a modern tui library written in zig
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}