a modern tui library written in zig
1const std = @import("std");
2const testing = std.testing;
3
4const Key = @This();
5
6/// Modifier Keys for a Key Match Event.
7pub const Modifiers = packed struct(u8) {
8 shift: bool = false,
9 alt: bool = false,
10 ctrl: bool = false,
11 super: bool = false,
12 hyper: bool = false,
13 meta: bool = false,
14 caps_lock: bool = false,
15 num_lock: bool = false,
16
17 pub fn eql(self: Modifiers, other: Modifiers) bool {
18 const a: u8 = @bitCast(self);
19 const b: u8 = @bitCast(other);
20 return a == b;
21 }
22};
23
24/// Flags for the Kitty Protocol.
25pub const KittyFlags = packed struct(u5) {
26 disambiguate: bool = true,
27 report_events: bool = false,
28 report_alternate_keys: bool = true,
29 report_all_as_ctl_seqs: bool = true,
30 report_text: bool = true,
31};
32
33/// the unicode codepoint of the key event.
34codepoint: u21,
35
36/// the text generated from the key event. The underlying slice has a limited
37/// lifetime. Vaxis maintains an internal ring buffer to temporarily store text.
38/// If the application needs these values longer than the lifetime of the event
39/// it must copy the data.
40text: ?[]const u8 = null,
41
42/// the shifted codepoint of this key event. This will only be present if the
43/// Shift modifier was used to generate the event
44shifted_codepoint: ?u21 = null,
45
46/// the key that would have been pressed on a standard keyboard layout. This is
47/// useful for shortcut matching
48base_layout_codepoint: ?u21 = null,
49
50mods: Modifiers = .{},
51
52// matches follows a loose matching algorithm for key matches.
53// 1. If the codepoint and modifiers are exact matches, after removing caps_lock
54// and num_lock
55// 2. If the utf8 encoding of the codepoint matches the text, after removing
56// num_lock
57// 3. If there is a shifted codepoint and it matches after removing the shift
58// modifier from self, after removing caps_lock and num_lock
59pub fn matches(self: Key, cp: u21, mods: Modifiers) bool {
60 // rule 1
61 if (self.matchExact(cp, mods)) return true;
62
63 // rule 2
64 if (self.matchText(cp, mods)) return true;
65
66 // rule 3
67 if (self.matchShiftedCodepoint(cp, mods)) return true;
68
69 return false;
70}
71
72/// matches against any of the provided codepoints.
73pub fn matchesAny(self: Key, cps: []const u21, mods: Modifiers) bool {
74 for (cps) |cp| {
75 if (self.matches(cp, mods)) return true;
76 }
77 return false;
78}
79
80/// matches base layout codes, useful for shortcut matching when an alternate key
81/// layout is used
82pub fn matchShortcut(self: Key, cp: u21, mods: Modifiers) bool {
83 if (self.base_layout_codepoint == null) return false;
84 return cp == self.base_layout_codepoint.? and std.meta.eql(self.mods, mods);
85}
86
87/// matches keys that aren't upper case versions when shifted. For example, shift
88/// + semicolon produces a colon. The key can be matched against shift +
89/// semicolon or just colon...or shift + ctrl + ; or just ctrl + :
90pub fn matchShiftedCodepoint(self: Key, cp: u21, mods: Modifiers) bool {
91 if (self.shifted_codepoint == null) return false;
92 if (!self.mods.shift) return false;
93 var self_mods = self.mods;
94 self_mods.shift = false;
95 self_mods.caps_lock = false;
96 self_mods.num_lock = false;
97 var tgt_mods = mods;
98 tgt_mods.caps_lock = false;
99 tgt_mods.num_lock = false;
100 return cp == self.shifted_codepoint.? and std.meta.eql(self_mods, mods);
101}
102
103/// matches when the utf8 encoding of the codepoint and relevant mods matches the
104/// text of the key. This function will consume Shift and Caps Lock when matching
105pub fn matchText(self: Key, cp: u21, mods: Modifiers) bool {
106 // return early if we have no text
107 if (self.text == null) return false;
108
109 var self_mods = self.mods;
110 self_mods.num_lock = false;
111 self_mods.shift = false;
112 self_mods.caps_lock = false;
113 var arg_mods = mods;
114
115 // TODO: Use zg case_data for full unicode support. We'll need to allocate the case data
116 // somewhere
117 const _cp: u21 = if (cp < 128 and (mods.shift or mods.caps_lock))
118 // Uppercase our codepoint
119 std.ascii.toUpper(@intCast(cp))
120 else
121 cp;
122
123 arg_mods.num_lock = false;
124 arg_mods.shift = false;
125 arg_mods.caps_lock = false;
126
127 var buf: [4]u8 = undefined;
128 const n = std.unicode.utf8Encode(_cp, &buf) catch return false;
129 return std.mem.eql(u8, self.text.?, buf[0..n]) and std.meta.eql(self_mods, arg_mods);
130}
131
132// The key must exactly match the codepoint and modifiers. caps_lock and
133// num_lock are removed before matching
134pub fn matchExact(self: Key, cp: u21, mods: Modifiers) bool {
135 var self_mods = self.mods;
136 self_mods.caps_lock = false;
137 self_mods.num_lock = false;
138 var tgt_mods = mods;
139 tgt_mods.caps_lock = false;
140 tgt_mods.num_lock = false;
141 return self.codepoint == cp and std.meta.eql(self_mods, tgt_mods);
142}
143
144/// True if the key is a single modifier (ie: left_shift)
145pub fn isModifier(self: Key) bool {
146 return self.codepoint == left_shift or
147 self.codepoint == left_alt or
148 self.codepoint == left_super or
149 self.codepoint == left_hyper or
150 self.codepoint == left_control or
151 self.codepoint == left_meta or
152 self.codepoint == right_shift or
153 self.codepoint == right_alt or
154 self.codepoint == right_super or
155 self.codepoint == right_hyper or
156 self.codepoint == right_control or
157 self.codepoint == right_meta;
158}
159
160// a few special keys that we encode as their actual ascii value
161pub const tab: u21 = 0x09;
162pub const enter: u21 = 0x0D;
163pub const escape: u21 = 0x1B;
164pub const space: u21 = 0x20;
165pub const backspace: u21 = 0x7F;
166
167/// multicodepoint is a key which generated text but cannot be expressed as a
168/// single codepoint. The value is the maximum unicode codepoint + 1
169pub const multicodepoint: u21 = 1_114_112 + 1;
170
171// kitty encodes these keys directly in the private use area. We reuse those
172// mappings
173pub const insert: u21 = 57348;
174pub const delete: u21 = 57349;
175pub const left: u21 = 57350;
176pub const right: u21 = 57351;
177pub const up: u21 = 57352;
178pub const down: u21 = 57353;
179pub const page_up: u21 = 57354;
180pub const page_down: u21 = 57355;
181pub const home: u21 = 57356;
182pub const end: u21 = 57357;
183pub const caps_lock: u21 = 57358;
184pub const scroll_lock: u21 = 57359;
185pub const num_lock: u21 = 57360;
186pub const print_screen: u21 = 57361;
187pub const pause: u21 = 57362;
188pub const menu: u21 = 57363;
189pub const f1: u21 = 57364;
190pub const f2: u21 = 57365;
191pub const f3: u21 = 57366;
192pub const f4: u21 = 57367;
193pub const f5: u21 = 57368;
194pub const f6: u21 = 57369;
195pub const f7: u21 = 57370;
196pub const f8: u21 = 57371;
197pub const f9: u21 = 57372;
198pub const f10: u21 = 57373;
199pub const f11: u21 = 57374;
200pub const f12: u21 = 57375;
201pub const f13: u21 = 57376;
202pub const f14: u21 = 57377;
203pub const f15: u21 = 57378;
204pub const @"f16": u21 = 57379;
205pub const f17: u21 = 57380;
206pub const f18: u21 = 57381;
207pub const f19: u21 = 57382;
208pub const f20: u21 = 57383;
209pub const f21: u21 = 57384;
210pub const f22: u21 = 57385;
211pub const f23: u21 = 57386;
212pub const f24: u21 = 57387;
213pub const f25: u21 = 57388;
214pub const f26: u21 = 57389;
215pub const f27: u21 = 57390;
216pub const f28: u21 = 57391;
217pub const f29: u21 = 57392;
218pub const f30: u21 = 57393;
219pub const f31: u21 = 57394;
220pub const @"f32": u21 = 57395;
221pub const f33: u21 = 57396;
222pub const f34: u21 = 57397;
223pub const f35: u21 = 57398;
224pub const kp_0: u21 = 57399;
225pub const kp_1: u21 = 57400;
226pub const kp_2: u21 = 57401;
227pub const kp_3: u21 = 57402;
228pub const kp_4: u21 = 57403;
229pub const kp_5: u21 = 57404;
230pub const kp_6: u21 = 57405;
231pub const kp_7: u21 = 57406;
232pub const kp_8: u21 = 57407;
233pub const kp_9: u21 = 57408;
234pub const kp_decimal: u21 = 57409;
235pub const kp_divide: u21 = 57410;
236pub const kp_multiply: u21 = 57411;
237pub const kp_subtract: u21 = 57412;
238pub const kp_add: u21 = 57413;
239pub const kp_enter: u21 = 57414;
240pub const kp_equal: u21 = 57415;
241pub const kp_separator: u21 = 57416;
242pub const kp_left: u21 = 57417;
243pub const kp_right: u21 = 57418;
244pub const kp_up: u21 = 57419;
245pub const kp_down: u21 = 57420;
246pub const kp_page_up: u21 = 57421;
247pub const kp_page_down: u21 = 57422;
248pub const kp_home: u21 = 57423;
249pub const kp_end: u21 = 57424;
250pub const kp_insert: u21 = 57425;
251pub const kp_delete: u21 = 57426;
252pub const kp_begin: u21 = 57427;
253pub const media_play: u21 = 57428;
254pub const media_pause: u21 = 57429;
255pub const media_play_pause: u21 = 57430;
256pub const media_reverse: u21 = 57431;
257pub const media_stop: u21 = 57432;
258pub const media_fast_forward: u21 = 57433;
259pub const media_rewind: u21 = 57434;
260pub const media_track_next: u21 = 57435;
261pub const media_track_previous: u21 = 57436;
262pub const media_record: u21 = 57437;
263pub const lower_volume: u21 = 57438;
264pub const raise_volume: u21 = 57439;
265pub const mute_volume: u21 = 57440;
266pub const left_shift: u21 = 57441;
267pub const left_control: u21 = 57442;
268pub const left_alt: u21 = 57443;
269pub const left_super: u21 = 57444;
270pub const left_hyper: u21 = 57445;
271pub const left_meta: u21 = 57446;
272pub const right_shift: u21 = 57447;
273pub const right_control: u21 = 57448;
274pub const right_alt: u21 = 57449;
275pub const right_super: u21 = 57450;
276pub const right_hyper: u21 = 57451;
277pub const right_meta: u21 = 57452;
278pub const iso_level_3_shift: u21 = 57453;
279pub const iso_level_5_shift: u21 = 57454;
280
281pub const name_map = blk: {
282 @setEvalBranchQuota(2000);
283 break :blk std.StaticStringMap(u21).initComptime(.{
284 // common names
285 .{ "plus", '+' },
286 .{ "minus", '-' },
287 .{ "colon", ':' },
288 .{ "semicolon", ';' },
289 .{ "comma", ',' },
290
291 // special keys
292 .{ "tab", tab },
293 .{ "enter", enter },
294 .{ "escape", escape },
295 .{ "space", space },
296 .{ "backspace", backspace },
297 .{ "insert", insert },
298 .{ "delete", delete },
299 .{ "left", left },
300 .{ "right", right },
301 .{ "up", up },
302 .{ "down", down },
303 .{ "page_up", page_up },
304 .{ "page_down", page_down },
305 .{ "home", home },
306 .{ "end", end },
307 .{ "caps_lock", caps_lock },
308 .{ "scroll_lock", scroll_lock },
309 .{ "num_lock", num_lock },
310 .{ "print_screen", print_screen },
311 .{ "pause", pause },
312 .{ "menu", menu },
313 .{ "f1", f1 },
314 .{ "f2", f2 },
315 .{ "f3", f3 },
316 .{ "f4", f4 },
317 .{ "f5", f5 },
318 .{ "f6", f6 },
319 .{ "f7", f7 },
320 .{ "f8", f8 },
321 .{ "f9", f9 },
322 .{ "f10", f10 },
323 .{ "f11", f11 },
324 .{ "f12", f12 },
325 .{ "f13", f13 },
326 .{ "f14", f14 },
327 .{ "f15", f15 },
328 .{ "f16", @"f16" },
329 .{ "f17", f17 },
330 .{ "f18", f18 },
331 .{ "f19", f19 },
332 .{ "f20", f20 },
333 .{ "f21", f21 },
334 .{ "f22", f22 },
335 .{ "f23", f23 },
336 .{ "f24", f24 },
337 .{ "f25", f25 },
338 .{ "f26", f26 },
339 .{ "f27", f27 },
340 .{ "f28", f28 },
341 .{ "f29", f29 },
342 .{ "f30", f30 },
343 .{ "f31", f31 },
344 .{ "f32", @"f32" },
345 .{ "f33", f33 },
346 .{ "f34", f34 },
347 .{ "f35", f35 },
348 .{ "kp_0", kp_0 },
349 .{ "kp_1", kp_1 },
350 .{ "kp_2", kp_2 },
351 .{ "kp_3", kp_3 },
352 .{ "kp_4", kp_4 },
353 .{ "kp_5", kp_5 },
354 .{ "kp_6", kp_6 },
355 .{ "kp_7", kp_7 },
356 .{ "kp_8", kp_8 },
357 .{ "kp_9", kp_9 },
358 .{ "kp_decimal", kp_decimal },
359 .{ "kp_divide", kp_divide },
360 .{ "kp_multiply", kp_multiply },
361 .{ "kp_subtract", kp_subtract },
362 .{ "kp_add", kp_add },
363 .{ "kp_enter", kp_enter },
364 .{ "kp_equal", kp_equal },
365 .{ "kp_separator", kp_separator },
366 .{ "kp_left", kp_left },
367 .{ "kp_right", kp_right },
368 .{ "kp_up", kp_up },
369 .{ "kp_down", kp_down },
370 .{ "kp_page_up", kp_page_up },
371 .{ "kp_page_down", kp_page_down },
372 .{ "kp_home", kp_home },
373 .{ "kp_end", kp_end },
374 .{ "kp_insert", kp_insert },
375 .{ "kp_delete", kp_delete },
376 .{ "kp_begin", kp_begin },
377 .{ "media_play", media_play },
378 .{ "media_pause", media_pause },
379 .{ "media_play_pause", media_play_pause },
380 .{ "media_reverse", media_reverse },
381 .{ "media_stop", media_stop },
382 .{ "media_fast_forward", media_fast_forward },
383 .{ "media_rewind", media_rewind },
384 .{ "media_track_next", media_track_next },
385 .{ "media_track_previous", media_track_previous },
386 .{ "media_record", media_record },
387 .{ "lower_volume", lower_volume },
388 .{ "raise_volume", raise_volume },
389 .{ "mute_volume", mute_volume },
390 .{ "left_shift", left_shift },
391 .{ "left_control", left_control },
392 .{ "left_alt", left_alt },
393 .{ "left_super", left_super },
394 .{ "left_hyper", left_hyper },
395 .{ "left_meta", left_meta },
396 .{ "right_shift", right_shift },
397 .{ "right_control", right_control },
398 .{ "right_alt", right_alt },
399 .{ "right_super", right_super },
400 .{ "right_hyper", right_hyper },
401 .{ "right_meta", right_meta },
402 .{ "iso_level_3_shift", iso_level_3_shift },
403 .{ "iso_level_5_shift", iso_level_5_shift },
404 });
405};
406
407test "matches 'a'" {
408 const key: Key = .{
409 .codepoint = 'a',
410 .mods = .{ .num_lock = true },
411 .text = "a",
412 };
413 try testing.expect(key.matches('a', .{}));
414 try testing.expect(!key.matches('a', .{ .shift = true }));
415}
416
417test "matches 'shift+a'" {
418 const key: Key = .{
419 .codepoint = 'a',
420 .shifted_codepoint = 'A',
421 .mods = .{ .shift = true },
422 .text = "A",
423 };
424 try testing.expect(key.matches('a', .{ .shift = true }));
425 try testing.expect(!key.matches('a', .{}));
426 try testing.expect(key.matches('A', .{}));
427 try testing.expect(!key.matches('A', .{ .ctrl = true }));
428}
429
430test "matches 'shift+tab'" {
431 const key: Key = .{
432 .codepoint = Key.tab,
433 .mods = .{ .shift = true, .num_lock = true },
434 };
435 try testing.expect(key.matches(Key.tab, .{ .shift = true }));
436 try testing.expect(!key.matches(Key.tab, .{}));
437}
438
439test "matches 'shift+;'" {
440 const key: Key = .{
441 .codepoint = ';',
442 .shifted_codepoint = ':',
443 .mods = .{ .shift = true },
444 .text = ":",
445 };
446 try testing.expect(key.matches(';', .{ .shift = true }));
447 try testing.expect(key.matches(':', .{}));
448
449 const colon: Key = .{
450 .codepoint = ':',
451 .mods = .{},
452 };
453 try testing.expect(colon.matches(':', .{}));
454}
455
456test "name_map" {
457 try testing.expectEqual(insert, name_map.get("insert"));
458}