this repo has no description
1# Usage
2
3## Custom Event Loops
4
5Vaxis provides an abstract enough API to allow the usage of a custom event loop.
6An event loop implementation is responsible for three primary tasks:
7
81. Read raw bytes from the TTY
92. Pass bytes to the Vaxis input event parser
103. Handle the returned events
11
12Everything after this can be left up to user code, or brought into an event loop
13to be a more abstract application layer. One important part of handling the
14events is to update the Vaxis struct with discovered terminal capabilities. This
15lets Vaxis know what features it can use. For example, the Kitty Keyboard
16protocol, in-band-resize reports, and Unicode width measurements are just a few
17examples.
18
19### `libxev`
20
21Below is an example [`libxev`](https://github.com/mitchellh/libxev) event loop.
22Note that this code is not necessarily up-to-date with the latest `libxev`
23release and is shown here merely as a proof of concept.
24
25```zig
26const std = @import("std");
27const xev = @import("xev");
28
29const Tty = @import("main.zig").Tty;
30const Winsize = @import("main.zig").Winsize;
31const Vaxis = @import("Vaxis.zig");
32const Parser = @import("Parser.zig");
33const Key = @import("Key.zig");
34const Mouse = @import("Mouse.zig");
35const Color = @import("Cell.zig").Color;
36
37const log = std.log.scoped(.vaxis_xev);
38
39pub const Event = union(enum) {
40 key_press: Key,
41 key_release: Key,
42 mouse: Mouse,
43 focus_in,
44 focus_out,
45 paste_start, // bracketed paste start
46 paste_end, // bracketed paste end
47 paste: []const u8, // osc 52 paste, caller must free
48 color_report: Color.Report, // osc 4, 10, 11, 12 response
49 color_scheme: Color.Scheme,
50 winsize: Winsize,
51};
52
53pub fn TtyWatcher(comptime Userdata: type) type {
54 return struct {
55 const Self = @This();
56
57 file: xev.File,
58 tty: *Tty,
59
60 read_buf: [4096]u8,
61 read_buf_start: usize,
62 read_cmp: xev.Completion,
63
64 winsize_wakeup: xev.Async,
65 winsize_cmp: xev.Completion,
66
67 callback: *const fn (
68 ud: ?*Userdata,
69 loop: *xev.Loop,
70 watcher: *Self,
71 event: Event,
72 ) xev.CallbackAction,
73
74 ud: ?*Userdata,
75 vx: *Vaxis,
76 parser: Parser,
77
78 pub fn init(
79 self: *Self,
80 tty: *Tty,
81 vaxis: *Vaxis,
82 loop: *xev.Loop,
83 userdata: ?*Userdata,
84 callback: *const fn (
85 ud: ?*Userdata,
86 loop: *xev.Loop,
87 watcher: *Self,
88 event: Event,
89 ) xev.CallbackAction,
90 ) !void {
91 self.* = .{
92 .tty = tty,
93 .file = xev.File.initFd(tty.fd),
94 .read_buf = undefined,
95 .read_buf_start = 0,
96 .read_cmp = .{},
97
98 .winsize_wakeup = try xev.Async.init(),
99 .winsize_cmp = .{},
100
101 .callback = callback,
102 .ud = userdata,
103 .vx = vaxis,
104 .parser = .{ .grapheme_data = &vaxis.unicode.width_data.g_data },
105 };
106
107 self.file.read(
108 loop,
109 &self.read_cmp,
110 .{ .slice = &self.read_buf },
111 Self,
112 self,
113 Self.ttyReadCallback,
114 );
115 self.winsize_wakeup.wait(
116 loop,
117 &self.winsize_cmp,
118 Self,
119 self,
120 winsizeCallback,
121 );
122 const handler: Tty.SignalHandler = .{
123 .context = self,
124 .callback = Self.signalCallback,
125 };
126 try Tty.notifyWinsize(handler);
127 }
128
129 fn signalCallback(ptr: *anyopaque) void {
130 const self: *Self = @ptrCast(@alignCast(ptr));
131 self.winsize_wakeup.notify() catch |err| {
132 log.warn("couldn't wake up winsize callback: {}", .{err});
133 };
134 }
135
136 fn ttyReadCallback(
137 ud: ?*Self,
138 loop: *xev.Loop,
139 c: *xev.Completion,
140 _: xev.File,
141 buf: xev.ReadBuffer,
142 r: xev.ReadError!usize,
143 ) xev.CallbackAction {
144 const n = r catch |err| {
145 log.err("read error: {}", .{err});
146 return .disarm;
147 };
148 const self = ud orelse unreachable;
149
150 // reset read start state
151 self.read_buf_start = 0;
152
153 var seq_start: usize = 0;
154 parse_loop: while (seq_start < n) {
155 const result = self.parser.parse(buf.slice[seq_start..n], null) catch |err| {
156 log.err("couldn't parse input: {}", .{err});
157 return .disarm;
158 };
159 if (result.n == 0) {
160 // copy the read to the beginning. We don't use memcpy because
161 // this could be overlapping, and it's also rare
162 const initial_start = seq_start;
163 while (seq_start < n) : (seq_start += 1) {
164 self.read_buf[seq_start - initial_start] = self.read_buf[seq_start];
165 }
166 self.read_buf_start = seq_start - initial_start + 1;
167 return .rearm;
168 }
169 seq_start += n;
170 const event_inner = result.event orelse {
171 log.debug("unknown event: {s}", .{self.read_buf[seq_start - n + 1 .. seq_start]});
172 continue :parse_loop;
173 };
174
175 // Capture events we want to bubble up
176 const event: ?Event = switch (event_inner) {
177 .key_press => |key| .{ .key_press = key },
178 .key_release => |key| .{ .key_release = key },
179 .mouse => |mouse| .{ .mouse = mouse },
180 .focus_in => .focus_in,
181 .focus_out => .focus_out,
182 .paste_start => .paste_start,
183 .paste_end => .paste_end,
184 .paste => |paste| .{ .paste = paste },
185 .color_report => |report| .{ .color_report = report },
186 .color_scheme => |scheme| .{ .color_scheme = scheme },
187 .winsize => |ws| .{ .winsize = ws },
188
189 // capability events which we handle below
190 .cap_kitty_keyboard,
191 .cap_kitty_graphics,
192 .cap_rgb,
193 .cap_unicode,
194 .cap_sgr_pixels,
195 .cap_color_scheme_updates,
196 .cap_da1,
197 => null, // handled below
198 };
199
200 if (event) |ev| {
201 const action = self.callback(self.ud, loop, self, ev);
202 switch (action) {
203 .disarm => return .disarm,
204 else => continue :parse_loop,
205 }
206 }
207
208 switch (event_inner) {
209 .key_press,
210 .key_release,
211 .mouse,
212 .focus_in,
213 .focus_out,
214 .paste_start,
215 .paste_end,
216 .paste,
217 .color_report,
218 .color_scheme,
219 .winsize,
220 => unreachable, // handled above
221
222 .cap_kitty_keyboard => {
223 log.info("kitty keyboard capability detected", .{});
224 self.vx.caps.kitty_keyboard = true;
225 },
226 .cap_kitty_graphics => {
227 if (!self.vx.caps.kitty_graphics) {
228 log.info("kitty graphics capability detected", .{});
229 self.vx.caps.kitty_graphics = true;
230 }
231 },
232 .cap_rgb => {
233 log.info("rgb capability detected", .{});
234 self.vx.caps.rgb = true;
235 },
236 .cap_unicode => {
237 log.info("unicode capability detected", .{});
238 self.vx.caps.unicode = .unicode;
239 self.vx.screen.width_method = .unicode;
240 },
241 .cap_sgr_pixels => {
242 log.info("pixel mouse capability detected", .{});
243 self.vx.caps.sgr_pixels = true;
244 },
245 .cap_color_scheme_updates => {
246 log.info("color_scheme_updates capability detected", .{});
247 self.vx.caps.color_scheme_updates = true;
248 },
249 .cap_da1 => {
250 self.vx.enableDetectedFeatures(self.tty.writer()) catch |err| {
251 log.err("couldn't enable features: {}", .{err});
252 };
253 },
254 }
255 }
256
257 self.file.read(
258 loop,
259 c,
260 .{ .slice = &self.read_buf },
261 Self,
262 self,
263 Self.ttyReadCallback,
264 );
265 return .disarm;
266 }
267
268 fn winsizeCallback(
269 ud: ?*Self,
270 l: *xev.Loop,
271 c: *xev.Completion,
272 r: xev.Async.WaitError!void,
273 ) xev.CallbackAction {
274 _ = r catch |err| {
275 log.err("async error: {}", .{err});
276 return .disarm;
277 };
278 const self = ud orelse unreachable; // no userdata
279 const winsize = Tty.getWinsize(self.tty.fd) catch |err| {
280 log.err("couldn't get winsize: {}", .{err});
281 return .disarm;
282 };
283 const ret = self.callback(self.ud, l, self, .{ .winsize = winsize });
284 if (ret == .disarm) return .disarm;
285
286 self.winsize_wakeup.wait(
287 l,
288 c,
289 Self,
290 self,
291 winsizeCallback,
292 );
293 return .disarm;
294 }
295 };
296}
297```
298
299### zig-aio
300
301Below is an example [`zig-aio`](https://github.com/Cloudef/zig-aio) event loop.
302Note that this code is not necessarily up-to-date with the latest `zig-aio`
303release and is shown here merely as a proof of concept.
304
305```zig
306const builtin = @import("builtin");
307const std = @import("std");
308const vaxis = @import("vaxis");
309const handleEventGeneric = vaxis.loop.handleEventGeneric;
310const log = std.log.scoped(.vaxis_aio);
311
312const Yield = enum { no_state, took_event };
313
314/// zig-aio based event loop
315/// <https://github.com/Cloudef/zig-aio>
316pub fn LoopWithModules(T: type, aio: type, coro: type) type {
317 return struct {
318 const Event = T;
319
320 winsize_task: ?coro.Task.Generic2(winsizeTask) = null,
321 reader_task: ?coro.Task.Generic2(ttyReaderTask) = null,
322 queue: std.BoundedArray(T, 512) = .{},
323 source: aio.EventSource,
324 fatal: bool = false,
325
326 pub fn init() !@This() {
327 return .{ .source = try aio.EventSource.init() };
328 }
329
330 pub fn deinit(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) void {
331 vx.deviceStatusReport(tty.writer()) catch {};
332 if (self.winsize_task) |task| task.cancel();
333 if (self.reader_task) |task| task.cancel();
334 self.source.deinit();
335 self.* = undefined;
336 }
337
338 fn winsizeInner(self: *@This(), tty: *vaxis.Tty) !void {
339 const Context = struct {
340 loop: *@TypeOf(self.*),
341 tty: *vaxis.Tty,
342 winsize: ?vaxis.Winsize = null,
343 fn cb(ptr: *anyopaque) void {
344 std.debug.assert(coro.current() == null);
345 const ctx: *@This() = @ptrCast(@alignCast(ptr));
346 ctx.winsize = vaxis.Tty.getWinsize(ctx.tty.fd) catch return;
347 ctx.loop.source.notify();
348 }
349 };
350
351 // keep on stack
352 var ctx: Context = .{ .loop = self, .tty = tty };
353 if (builtin.target.os.tag != .windows) {
354 if (@hasField(Event, "winsize")) {
355 const handler: vaxis.Tty.SignalHandler = .{ .context = &ctx, .callback = Context.cb };
356 try vaxis.Tty.notifyWinsize(handler);
357 }
358 }
359
360 while (true) {
361 try coro.io.single(aio.WaitEventSource{ .source = &self.source });
362 if (ctx.winsize) |winsize| {
363 if (!@hasField(Event, "winsize")) unreachable;
364 ctx.loop.postEvent(.{ .winsize = winsize }) catch {};
365 ctx.winsize = null;
366 }
367 }
368 }
369
370 fn winsizeTask(self: *@This(), tty: *vaxis.Tty) void {
371 self.winsizeInner(tty) catch |err| {
372 if (err != error.Canceled) log.err("winsize: {}", .{err});
373 self.fatal = true;
374 };
375 }
376
377 fn windowsReadEvent(tty: *vaxis.Tty) !vaxis.Event {
378 var state: vaxis.Tty.EventState = .{};
379 while (true) {
380 var bytes_read: usize = 0;
381 var input_record: vaxis.Tty.INPUT_RECORD = undefined;
382 try coro.io.single(aio.ReadTty{
383 .tty = .{ .handle = tty.stdin },
384 .buffer = std.mem.asBytes(&input_record),
385 .out_read = &bytes_read,
386 });
387
388 if (try tty.eventFromRecord(&input_record, &state)) |ev| {
389 return ev;
390 }
391 }
392 }
393
394 fn ttyReaderWindows(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty) !void {
395 var cache: vaxis.GraphemeCache = .{};
396 while (true) {
397 const event = try windowsReadEvent(tty);
398 try handleEventGeneric(self, vx, &cache, Event, event, null);
399 }
400 }
401
402 fn ttyReaderPosix(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) !void {
403 // initialize a grapheme cache
404 var cache: vaxis.GraphemeCache = .{};
405
406 // get our initial winsize
407 const winsize = try vaxis.Tty.getWinsize(tty.fd);
408 if (@hasField(Event, "winsize")) {
409 try self.postEvent(.{ .winsize = winsize });
410 }
411
412 var parser: vaxis.Parser = .{
413 .grapheme_data = &vx.unicode.width_data.g_data,
414 };
415
416 const file: std.fs.File = .{ .handle = tty.fd };
417 while (true) {
418 var buf: [4096]u8 = undefined;
419 var n: usize = undefined;
420 var read_start: usize = 0;
421 try coro.io.single(aio.ReadTty{ .tty = file, .buffer = buf[read_start..], .out_read = &n });
422 var seq_start: usize = 0;
423 while (seq_start < n) {
424 const result = try parser.parse(buf[seq_start..n], paste_allocator);
425 if (result.n == 0) {
426 // copy the read to the beginning. We don't use memcpy because
427 // this could be overlapping, and it's also rare
428 const initial_start = seq_start;
429 while (seq_start < n) : (seq_start += 1) {
430 buf[seq_start - initial_start] = buf[seq_start];
431 }
432 read_start = seq_start - initial_start + 1;
433 continue;
434 }
435 read_start = 0;
436 seq_start += result.n;
437
438 const event = result.event orelse continue;
439 try handleEventGeneric(self, vx, &cache, Event, event, paste_allocator);
440 }
441 }
442 }
443
444 fn ttyReaderTask(self: *@This(), vx: *vaxis.Vaxis, tty: *vaxis.Tty, paste_allocator: ?std.mem.Allocator) void {
445 return switch (builtin.target.os.tag) {
446 .windows => self.ttyReaderWindows(vx, tty),
447 else => self.ttyReaderPosix(vx, tty, paste_allocator),
448 } catch |err| {
449 if (err != error.Canceled) log.err("ttyReader: {}", .{err});
450 self.fatal = true;
451 };
452 }
453
454 /// Spawns tasks to handle winsize signal and tty
455 pub fn spawn(
456 self: *@This(),
457 scheduler: *coro.Scheduler,
458 vx: *vaxis.Vaxis,
459 tty: *vaxis.Tty,
460 paste_allocator: ?std.mem.Allocator,
461 spawn_options: coro.Scheduler.SpawnOptions,
462 ) coro.Scheduler.SpawnError!void {
463 if (self.reader_task) |_| unreachable; // programming error
464 // This is required even if app doesn't care about winsize
465 // It is because it consumes the EventSource, so it can wakeup the scheduler
466 // Without that custom `postEvent`'s wouldn't wake up the scheduler and UI wouldn't update
467 self.winsize_task = try scheduler.spawn(winsizeTask, .{ self, tty }, spawn_options);
468 self.reader_task = try scheduler.spawn(ttyReaderTask, .{ self, vx, tty, paste_allocator }, spawn_options);
469 }
470
471 pub const PopEventError = error{TtyCommunicationSevered};
472
473 /// Call this in a while loop in the main event handler until it returns null
474 pub fn popEvent(self: *@This()) PopEventError!?T {
475 if (self.fatal) return error.TtyCommunicationSevered;
476 defer self.winsize_task.?.wakeupIf(Yield.took_event);
477 defer self.reader_task.?.wakeupIf(Yield.took_event);
478 return self.queue.popOrNull();
479 }
480
481 pub const PostEventError = error{Overflow};
482
483 pub fn postEvent(self: *@This(), event: T) !void {
484 if (coro.current()) |_| {
485 while (true) {
486 self.queue.insert(0, event) catch {
487 // wait for the app to take event
488 try coro.yield(Yield.took_event);
489 continue;
490 };
491 break;
492 }
493 } else {
494 // queue can be full, app could handle this error by spinning the scheduler
495 try self.queue.insert(0, event);
496 }
497 // wakes up the scheduler, so custom events update UI
498 self.source.notify();
499 }
500 };
501}
502```