a modern tui library written in zig
1const std = @import("std");
2const xev = @import("xev");
3
4const Tty = @import("main.zig").Tty;
5const Winsize = @import("main.zig").Winsize;
6const Vaxis = @import("Vaxis.zig");
7const Parser = @import("Parser.zig");
8const Key = @import("Key.zig");
9const Mouse = @import("Mouse.zig");
10const Color = @import("Cell.zig").Color;
11
12const log = std.log.scoped(.tty_watcher);
13
14pub const Event = union(enum) {
15 key_press: Key,
16 key_release: Key,
17 mouse: Mouse,
18 focus_in,
19 focus_out,
20 paste_start, // bracketed paste start
21 paste_end, // bracketed paste end
22 paste: []const u8, // osc 52 paste, caller must free
23 color_report: Color.Report, // osc 4, 10, 11, 12 response
24 color_scheme: Color.Scheme,
25 winsize: Winsize,
26};
27
28pub fn TtyWatcher(comptime Userdata: type) type {
29 return struct {
30 const Self = @This();
31
32 file: xev.File,
33 tty: *Tty,
34
35 read_buf: [4096]u8,
36 read_buf_start: usize,
37 read_cmp: xev.Completion,
38
39 winsize_wakeup: xev.Async,
40 winsize_cmp: xev.Completion,
41
42 callback: *const fn (
43 ud: ?*Userdata,
44 loop: *xev.Loop,
45 watcher: *Self,
46 event: Event,
47 ) xev.CallbackAction,
48
49 ud: ?*Userdata,
50 vx: *Vaxis,
51 parser: Parser,
52
53 pub fn init(
54 self: *Self,
55 tty: *Tty,
56 vaxis: *Vaxis,
57 loop: *xev.Loop,
58 userdata: ?*Userdata,
59 callback: *const fn (
60 ud: ?*Userdata,
61 loop: *xev.Loop,
62 watcher: *Self,
63 event: Event,
64 ) xev.CallbackAction,
65 ) !void {
66 self.* = .{
67 .tty = tty,
68 .file = xev.File.initFd(tty.fd),
69 .read_buf = undefined,
70 .read_buf_start = 0,
71 .read_cmp = .{},
72
73 .winsize_wakeup = try xev.Async.init(),
74 .winsize_cmp = .{},
75
76 .callback = callback,
77 .ud = userdata,
78 .vx = vaxis,
79 .parser = .{ .grapheme_data = &vaxis.unicode.grapheme_data },
80 };
81
82 self.file.read(
83 loop,
84 &self.read_cmp,
85 .{ .slice = &self.read_buf },
86 Self,
87 self,
88 Self.ttyReadCallback,
89 );
90 self.winsize_wakeup.wait(
91 loop,
92 &self.winsize_cmp,
93 Self,
94 self,
95 winsizeCallback,
96 );
97 const handler: Tty.SignalHandler = .{
98 .context = self,
99 .callback = Self.signalCallback,
100 };
101 try Tty.notifyWinsize(handler);
102 const winsize = try Tty.getWinsize(self.tty.fd);
103 _ = self.callback(self.ud, loop, self, .{ .winsize = winsize });
104 }
105
106 fn signalCallback(ptr: *anyopaque) void {
107 const self: *Self = @ptrCast(@alignCast(ptr));
108 self.winsize_wakeup.notify() catch |err| {
109 log.warn("couldn't wake up winsize callback: {}", .{err});
110 };
111 }
112
113 fn ttyReadCallback(
114 ud: ?*Self,
115 loop: *xev.Loop,
116 c: *xev.Completion,
117 _: xev.File,
118 buf: xev.ReadBuffer,
119 r: xev.ReadError!usize,
120 ) xev.CallbackAction {
121 const n = r catch |err| {
122 log.err("read error: {}", .{err});
123 return .disarm;
124 };
125 const self = ud orelse unreachable;
126
127 // reset read start state
128 self.read_buf_start = 0;
129
130 var seq_start: usize = 0;
131 parse_loop: while (seq_start < n) {
132 const result = self.parser.parse(buf.slice[seq_start..n], null) catch |err| {
133 log.err("couldn't parse input: {}", .{err});
134 return .disarm;
135 };
136 if (result.n == 0) {
137 // copy the read to the beginning. We don't use memcpy because
138 // this could be overlapping, and it's also rare
139 const initial_start = seq_start;
140 while (seq_start < n) : (seq_start += 1) {
141 self.read_buf[seq_start - initial_start] = self.read_buf[seq_start];
142 }
143 self.read_buf_start = seq_start - initial_start + 1;
144 return .rearm;
145 }
146 seq_start += n;
147 const event_inner = result.event orelse {
148 log.debug("unknown event: {s}", .{self.read_buf[seq_start - n + 1 .. seq_start]});
149 continue :parse_loop;
150 };
151
152 // Capture events we want to bubble up
153 const event: ?Event = switch (event_inner) {
154 .key_press => |key| .{ .key_press = key },
155 .key_release => |key| .{ .key_release = key },
156 .mouse => |mouse| .{ .mouse = mouse },
157 .focus_in => .focus_in,
158 .focus_out => .focus_out,
159 .paste_start => .paste_start,
160 .paste_end => .paste_end,
161 .paste => |paste| .{ .paste = paste },
162 .color_report => |report| .{ .color_report = report },
163 .color_scheme => |scheme| .{ .color_scheme = scheme },
164 .winsize => |ws| .{ .winsize = ws },
165
166 // capability events which we handle below
167 .cap_kitty_keyboard,
168 .cap_kitty_graphics,
169 .cap_rgb,
170 .cap_unicode,
171 .cap_sgr_pixels,
172 .cap_color_scheme_updates,
173 .cap_da1,
174 => null, // handled below
175 };
176
177 if (event) |ev| {
178 const action = self.callback(self.ud, loop, self, ev);
179 switch (action) {
180 .disarm => return .disarm,
181 else => continue :parse_loop,
182 }
183 }
184
185 switch (event_inner) {
186 .key_press,
187 .key_release,
188 .mouse,
189 .focus_in,
190 .focus_out,
191 .paste_start,
192 .paste_end,
193 .paste,
194 .color_report,
195 .color_scheme,
196 .winsize,
197 => unreachable, // handled above
198
199 .cap_kitty_keyboard => {
200 log.info("kitty keyboard capability detected", .{});
201 self.vx.caps.kitty_keyboard = true;
202 },
203 .cap_kitty_graphics => {
204 if (!self.vx.caps.kitty_graphics) {
205 log.info("kitty graphics capability detected", .{});
206 self.vx.caps.kitty_graphics = true;
207 }
208 },
209 .cap_rgb => {
210 log.info("rgb capability detected", .{});
211 self.vx.caps.rgb = true;
212 },
213 .cap_unicode => {
214 log.info("unicode capability detected", .{});
215 self.vx.caps.unicode = .unicode;
216 self.vx.screen.width_method = .unicode;
217 },
218 .cap_sgr_pixels => {
219 log.info("pixel mouse capability detected", .{});
220 self.vx.caps.sgr_pixels = true;
221 },
222 .cap_color_scheme_updates => {
223 log.info("color_scheme_updates capability detected", .{});
224 self.vx.caps.color_scheme_updates = true;
225 },
226 .cap_da1 => {
227 self.vx.enableDetectedFeatures(self.tty.anyWriter()) catch |err| {
228 log.err("couldn't enable features: {}", .{err});
229 };
230 },
231 }
232 }
233
234 self.file.read(
235 loop,
236 c,
237 .{ .slice = &self.read_buf },
238 Self,
239 self,
240 Self.ttyReadCallback,
241 );
242 return .disarm;
243 }
244
245 fn winsizeCallback(
246 ud: ?*Self,
247 l: *xev.Loop,
248 c: *xev.Completion,
249 r: xev.Async.WaitError!void,
250 ) xev.CallbackAction {
251 _ = r catch |err| {
252 log.err("async error: {}", .{err});
253 return .disarm;
254 };
255 const self = ud orelse unreachable; // no userdata
256 const winsize = Tty.getWinsize(self.tty.fd) catch |err| {
257 log.err("couldn't get winsize: {}", .{err});
258 return .disarm;
259 };
260 const ret = self.callback(self.ud, l, self, .{ .winsize = winsize });
261 if (ret == .disarm) return .disarm;
262
263 self.winsize_wakeup.wait(
264 l,
265 c,
266 Self,
267 self,
268 winsizeCallback,
269 );
270 return .disarm;
271 }
272 };
273}