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