a modern tui library written in zig
1const std = @import("std");
2const builtin = @import("builtin");
3
4const grapheme = @import("grapheme");
5
6const GraphemeCache = @import("GraphemeCache.zig");
7const Parser = @import("Parser.zig");
8const Queue = @import("queue.zig").Queue;
9const vaxis = @import("main.zig");
10const Tty = vaxis.Tty;
11const Vaxis = @import("Vaxis.zig");
12
13const log = std.log.scoped(.vaxis);
14
15pub fn Loop(comptime T: type) type {
16 return struct {
17 const Self = @This();
18
19 const Event = T;
20
21 tty: *Tty,
22 vaxis: *Vaxis,
23
24 queue: Queue(T, 512) = .{},
25 thread: ?std.Thread = null,
26 should_quit: bool = false,
27
28 /// Initialize the event loop. This is an intrusive init so that we have
29 /// a stable pointer to register signal callbacks with posix TTYs
30 pub fn init(self: *Self) !void {
31 switch (builtin.os.tag) {
32 .windows => {},
33 else => {
34 const handler: Tty.SignalHandler = .{
35 .context = self,
36 .callback = Self.winsizeCallback,
37 };
38 try Tty.notifyWinsize(handler);
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.unicode.grapheme_data,
49 self.vaxis.opts.system_clipboard_allocator,
50 });
51 }
52
53 /// stops reading from the tty.
54 pub fn stop(self: *Self) void {
55 // If we don't have a thread, we have nothing to stop
56 if (self.thread == null) return;
57 self.should_quit = true;
58 // trigger a read
59 self.vaxis.deviceStatusReport(self.tty.anyWriter()) catch {};
60
61 if (self.thread) |thread| {
62 thread.join();
63 self.thread = null;
64 self.should_quit = false;
65 }
66 }
67
68 /// returns the next available event, blocking until one is available
69 pub fn nextEvent(self: *Self) T {
70 return self.queue.pop();
71 }
72
73 /// blocks until an event is available. Useful when your application is
74 /// operating on a poll + drain architecture (see tryEvent)
75 pub fn pollEvent(self: *Self) void {
76 self.queue.poll();
77 }
78
79 /// returns an event if one is available, otherwise null. Non-blocking.
80 pub fn tryEvent(self: *Self) ?T {
81 return self.queue.tryPop();
82 }
83
84 /// posts an event into the event queue. Will block if there is not
85 /// capacity for the event
86 pub fn postEvent(self: *Self, event: T) void {
87 self.queue.push(event);
88 }
89
90 pub fn tryPostEvent(self: *Self, event: T) bool {
91 return self.queue.tryPush(event);
92 }
93
94 pub fn winsizeCallback(ptr: *anyopaque) void {
95 const self: *Self = @ptrCast(@alignCast(ptr));
96 // We will be receiving winsize updates in-band
97 if (self.vaxis.state.in_band_resize) return;
98
99 const winsize = Tty.getWinsize(self.tty.fd) catch return;
100 if (@hasField(Event, "winsize")) {
101 self.postEvent(.{ .winsize = winsize });
102 }
103 }
104
105 /// read input from the tty. This is run in a separate thread
106 fn ttyRun(
107 self: *Self,
108 grapheme_data: *const grapheme.GraphemeData,
109 paste_allocator: ?std.mem.Allocator,
110 ) !void {
111 // initialize a grapheme cache
112 var cache: GraphemeCache = .{};
113
114 switch (builtin.os.tag) {
115 .windows => {
116 var parser: Parser = .{
117 .grapheme_data = grapheme_data,
118 };
119 while (!self.should_quit) {
120 const event = try self.tty.nextEvent(&parser, paste_allocator);
121 try handleEventGeneric(self, self.vaxis, &cache, Event, event, null);
122 }
123 },
124 else => {
125 // get our initial winsize
126 const winsize = try Tty.getWinsize(self.tty.fd);
127 if (@hasField(Event, "winsize")) {
128 self.postEvent(.{ .winsize = winsize });
129 }
130
131 var parser: Parser = .{
132 .grapheme_data = grapheme_data,
133 };
134
135 // initialize the read buffer
136 var buf: [1024]u8 = undefined;
137 var read_start: usize = 0;
138 // read loop
139 read_loop: while (!self.should_quit) {
140 const n = try self.tty.read(buf[read_start..]);
141 var seq_start: usize = 0;
142 while (seq_start < n) {
143 const result = try parser.parse(buf[seq_start..n], paste_allocator);
144 if (result.n == 0) {
145 // copy the read to the beginning. We don't use memcpy because
146 // this could be overlapping, and it's also rare
147 const initial_start = seq_start;
148 while (seq_start < n) : (seq_start += 1) {
149 buf[seq_start - initial_start] = buf[seq_start];
150 }
151 read_start = seq_start - initial_start + 1;
152 continue :read_loop;
153 }
154 read_start = 0;
155 seq_start += result.n;
156
157 const event = result.event orelse continue;
158 try handleEventGeneric(self, self.vaxis, &cache, Event, event, paste_allocator);
159 }
160 }
161 },
162 }
163 }
164 };
165}
166
167// Use return on the self.postEvent's so it can either return error union or void
168pub fn handleEventGeneric(self: anytype, vx: *Vaxis, cache: *GraphemeCache, Event: type, event: anytype, paste_allocator: ?std.mem.Allocator) !void {
169 switch (builtin.os.tag) {
170 .windows => {
171 switch (event) {
172 .winsize => |ws| {
173 if (@hasField(Event, "winsize")) {
174 return self.postEvent(.{ .winsize = ws });
175 }
176 },
177 .key_press => |key| {
178 if (@hasField(Event, "key_press")) {
179 // HACK: yuck. there has to be a better way
180 var mut_key = key;
181 if (key.text) |text| {
182 mut_key.text = cache.put(text);
183 }
184 return self.postEvent(.{ .key_press = mut_key });
185 }
186 },
187 .key_release => |key| {
188 if (@hasField(Event, "key_release")) {
189 // HACK: yuck. there has to be a better way
190 var mut_key = key;
191 if (key.text) |text| {
192 mut_key.text = cache.put(text);
193 }
194 return self.postEvent(.{ .key_release = mut_key });
195 }
196 },
197 .cap_da1 => {
198 std.Thread.Futex.wake(&vx.query_futex, 10);
199 },
200 .mouse => {}, // Unsupported currently
201 else => {},
202 }
203 },
204 else => {
205 switch (event) {
206 .key_press => |key| {
207 if (@hasField(Event, "key_press")) {
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_press = mut_key });
214 }
215 },
216 .key_release => |key| {
217 if (@hasField(Event, "key_release")) {
218 // HACK: yuck. there has to be a better way
219 var mut_key = key;
220 if (key.text) |text| {
221 mut_key.text = cache.put(text);
222 }
223 return self.postEvent(.{ .key_release = mut_key });
224 }
225 },
226 .mouse => |mouse| {
227 if (@hasField(Event, "mouse")) {
228 return self.postEvent(.{ .mouse = vx.translateMouse(mouse) });
229 }
230 },
231 .focus_in => {
232 if (@hasField(Event, "focus_in")) {
233 return self.postEvent(.focus_in);
234 }
235 },
236 .focus_out => {
237 if (@hasField(Event, "focus_out")) {
238 return self.postEvent(.focus_out);
239 }
240 },
241 .paste_start => {
242 if (@hasField(Event, "paste_start")) {
243 return self.postEvent(.paste_start);
244 }
245 },
246 .paste_end => {
247 if (@hasField(Event, "paste_end")) {
248 return self.postEvent(.paste_end);
249 }
250 },
251 .paste => |text| {
252 if (@hasField(Event, "paste")) {
253 return self.postEvent(.{ .paste = text });
254 } else {
255 if (paste_allocator) |_|
256 paste_allocator.?.free(text);
257 }
258 },
259 .color_report => |report| {
260 if (@hasField(Event, "color_report")) {
261 return self.postEvent(.{ .color_report = report });
262 }
263 },
264 .color_scheme => |scheme| {
265 if (@hasField(Event, "color_scheme")) {
266 return self.postEvent(.{ .color_scheme = scheme });
267 }
268 },
269 .cap_kitty_keyboard => {
270 log.info("kitty keyboard capability detected", .{});
271 vx.caps.kitty_keyboard = true;
272 },
273 .cap_kitty_graphics => {
274 if (!vx.caps.kitty_graphics) {
275 log.info("kitty graphics capability detected", .{});
276 vx.caps.kitty_graphics = true;
277 }
278 },
279 .cap_rgb => {
280 log.info("rgb capability detected", .{});
281 vx.caps.rgb = true;
282 },
283 .cap_unicode => {
284 log.info("unicode capability detected", .{});
285 vx.caps.unicode = .unicode;
286 vx.screen.width_method = .unicode;
287 },
288 .cap_sgr_pixels => {
289 log.info("pixel mouse capability detected", .{});
290 vx.caps.sgr_pixels = true;
291 },
292 .cap_color_scheme_updates => {
293 log.info("color_scheme_updates capability detected", .{});
294 vx.caps.color_scheme_updates = true;
295 },
296 .cap_da1 => {
297 std.Thread.Futex.wake(&vx.query_futex, 10);
298 },
299 .winsize => |winsize| {
300 vx.state.in_band_resize = true;
301 if (@hasField(Event, "winsize")) {
302 return self.postEvent(.{ .winsize = winsize });
303 }
304
305 switch (builtin.os.tag) {
306 .windows => {},
307 // Reset the signal handler if we are receiving in_band_resize
308 else => self.tty.resetSignalHandler(),
309 }
310 },
311 }
312 },
313 }
314}