a modern tui library written in zig
1//! A Windows TTY implementation, using virtual terminal process output and
2//! native windows input
3const Tty = @This();
4
5const std = @import("std");
6const Event = @import("../event.zig").Event;
7const Key = @import("../Key.zig");
8const Mouse = @import("../Mouse.zig");
9const Parser = @import("../Parser.zig");
10const windows = std.os.windows;
11
12stdin: windows.HANDLE,
13stdout: windows.HANDLE,
14
15initial_codepage: c_uint,
16initial_input_mode: u32,
17initial_output_mode: u32,
18
19// a buffer to write key text into
20buf: [4]u8 = undefined,
21
22/// The last mouse button that was pressed. We store the previous state of button presses on each
23/// mouse event so we can detect which button was released
24last_mouse_button_press: u16 = 0,
25
26pub var global_tty: ?Tty = null;
27
28const utf8_codepage: c_uint = 65001;
29
30const InputMode = struct {
31 const enable_window_input: u32 = 0x0008; // resize events
32 const enable_mouse_input: u32 = 0x0010;
33 const enable_extended_flags: u32 = 0x0080; // allows mouse events
34
35 pub fn rawMode() u32 {
36 return enable_window_input | enable_mouse_input | enable_extended_flags;
37 }
38};
39
40const OutputMode = struct {
41 const enable_processed_output: u32 = 0x0001; // handle control sequences
42 const enable_virtual_terminal_processing: u32 = 0x0004; // handle ANSI sequences
43 const disable_newline_auto_return: u32 = 0x0008; // disable inserting a new line when we write at the last column
44 const enable_lvb_grid_worldwide: u32 = 0x0010; // enables reverse video and underline
45
46 fn rawMode() u32 {
47 return enable_processed_output |
48 enable_virtual_terminal_processing |
49 disable_newline_auto_return |
50 enable_lvb_grid_worldwide;
51 }
52};
53
54pub fn init() !Tty {
55 const stdin = try windows.GetStdHandle(windows.STD_INPUT_HANDLE);
56 const stdout = try windows.GetStdHandle(windows.STD_OUTPUT_HANDLE);
57
58 // get initial modes
59 var initial_input_mode: windows.DWORD = undefined;
60 var initial_output_mode: windows.DWORD = undefined;
61 const initial_output_codepage = windows.kernel32.GetConsoleOutputCP();
62 {
63 if (windows.kernel32.GetConsoleMode(stdin, &initial_input_mode) == 0) {
64 return windows.unexpectedError(windows.kernel32.GetLastError());
65 }
66 if (windows.kernel32.GetConsoleMode(stdout, &initial_output_mode) == 0) {
67 return windows.unexpectedError(windows.kernel32.GetLastError());
68 }
69 }
70
71 // set new modes
72 {
73 if (SetConsoleMode(stdin, InputMode.rawMode()) == 0)
74 return windows.unexpectedError(windows.kernel32.GetLastError());
75
76 if (SetConsoleMode(stdout, OutputMode.rawMode()) == 0)
77 return windows.unexpectedError(windows.kernel32.GetLastError());
78
79 if (windows.kernel32.SetConsoleOutputCP(utf8_codepage) == 0)
80 return windows.unexpectedError(windows.kernel32.GetLastError());
81 }
82
83 const self: Tty = .{
84 .stdin = stdin,
85 .stdout = stdout,
86 .initial_codepage = initial_output_codepage,
87 .initial_input_mode = initial_input_mode,
88 .initial_output_mode = initial_output_mode,
89 };
90
91 // save a copy of this tty as the global_tty for panic handling
92 global_tty = self;
93
94 return self;
95}
96
97pub fn deinit(self: Tty) void {
98 _ = windows.kernel32.SetConsoleOutputCP(self.initial_codepage);
99 _ = SetConsoleMode(self.stdin, self.initial_input_mode);
100 _ = SetConsoleMode(self.stdout, self.initial_output_mode);
101 windows.CloseHandle(self.stdin);
102 windows.CloseHandle(self.stdout);
103}
104
105pub fn opaqueWrite(ptr: *const anyopaque, bytes: []const u8) !usize {
106 const self: *const Tty = @ptrCast(@alignCast(ptr));
107 return windows.WriteFile(self.stdout, bytes, null);
108}
109
110pub fn anyWriter(self: *const Tty) std.io.AnyWriter {
111 return .{
112 .context = self,
113 .writeFn = Tty.opaqueWrite,
114 };
115}
116
117pub fn bufferedWriter(self: *const Tty) std.io.BufferedWriter(4096, std.io.AnyWriter) {
118 return std.io.bufferedWriter(self.anyWriter());
119}
120
121pub fn nextEvent(self: *Tty, parser: *Parser, paste_allocator: ?std.mem.Allocator) !Event {
122 // We use a loop so we can ignore certain events
123 var state: EventState = .{};
124 while (true) {
125 var event_count: u32 = 0;
126 var input_record: INPUT_RECORD = undefined;
127 if (ReadConsoleInputW(self.stdin, &input_record, 1, &event_count) == 0)
128 return windows.unexpectedError(windows.kernel32.GetLastError());
129
130 if (try self.eventFromRecord(&input_record, &state, parser, paste_allocator)) |ev| {
131 return ev;
132 }
133 }
134}
135
136pub const EventState = struct {
137 ansi_buf: [128]u8 = undefined,
138 ansi_idx: usize = 0,
139 utf16_buf: [2]u16 = undefined,
140 utf16_half: bool = false,
141};
142
143pub fn eventFromRecord(self: *Tty, record: *const INPUT_RECORD, state: *EventState, parser: *Parser, paste_allocator: ?std.mem.Allocator) !?Event {
144 switch (record.EventType) {
145 0x0001 => { // Key event
146 const event = record.Event.KeyEvent;
147
148 if (state.utf16_half) half: {
149 state.utf16_half = false;
150 state.utf16_buf[1] = event.uChar.UnicodeChar;
151 const codepoint: u21 = std.unicode.utf16DecodeSurrogatePair(&state.utf16_buf) catch break :half;
152 const n = std.unicode.utf8Encode(codepoint, &self.buf) catch return null;
153
154 const key: Key = .{
155 .codepoint = codepoint,
156 .base_layout_codepoint = codepoint,
157 .mods = translateMods(event.dwControlKeyState),
158 .text = self.buf[0..n],
159 };
160
161 switch (event.bKeyDown) {
162 0 => return .{ .key_release = key },
163 else => return .{ .key_press = key },
164 }
165 }
166
167 const base_layout: u16 = switch (event.wVirtualKeyCode) {
168 0x00 => blk: { // delivered when we get an escape sequence or a unicode codepoint
169 if (state.ansi_idx == 0 and event.uChar.AsciiChar != 27)
170 break :blk event.uChar.UnicodeChar;
171 state.ansi_buf[state.ansi_idx] = event.uChar.AsciiChar;
172 state.ansi_idx += 1;
173 if (state.ansi_idx <= 2) return null;
174 const result = try parser.parse(state.ansi_buf[0..state.ansi_idx], paste_allocator);
175 return if (result.n == 0) null else evt: {
176 state.ansi_idx = 0;
177 break :evt result.event;
178 };
179 },
180 0x08 => Key.backspace,
181 0x09 => Key.tab,
182 0x0D => Key.enter,
183 0x13 => Key.pause,
184 0x14 => Key.caps_lock,
185 0x1B => Key.escape,
186 0x20 => Key.space,
187 0x21 => Key.page_up,
188 0x22 => Key.page_down,
189 0x23 => Key.end,
190 0x24 => Key.home,
191 0x25 => Key.left,
192 0x26 => Key.up,
193 0x27 => Key.right,
194 0x28 => Key.down,
195 0x2c => Key.print_screen,
196 0x2d => Key.insert,
197 0x2e => Key.delete,
198 0x30...0x39 => |k| k,
199 0x41...0x5a => |k| k + 0x20, // translate to lowercase
200 0x5b => Key.left_meta,
201 0x5c => Key.right_meta,
202 0x60 => Key.kp_0,
203 0x61 => Key.kp_1,
204 0x62 => Key.kp_2,
205 0x63 => Key.kp_3,
206 0x64 => Key.kp_4,
207 0x65 => Key.kp_5,
208 0x66 => Key.kp_6,
209 0x67 => Key.kp_7,
210 0x68 => Key.kp_8,
211 0x69 => Key.kp_9,
212 0x6a => Key.kp_multiply,
213 0x6b => Key.kp_add,
214 0x6c => Key.kp_separator,
215 0x6d => Key.kp_subtract,
216 0x6e => Key.kp_decimal,
217 0x6f => Key.kp_divide,
218 0x70 => Key.f1,
219 0x71 => Key.f2,
220 0x72 => Key.f3,
221 0x73 => Key.f4,
222 0x74 => Key.f5,
223 0x75 => Key.f6,
224 0x76 => Key.f8,
225 0x77 => Key.f8,
226 0x78 => Key.f9,
227 0x79 => Key.f10,
228 0x7a => Key.f11,
229 0x7b => Key.f12,
230 0x7c => Key.f13,
231 0x7d => Key.f14,
232 0x7e => Key.f15,
233 0x7f => Key.f16,
234 0x80 => Key.f17,
235 0x81 => Key.f18,
236 0x82 => Key.f19,
237 0x83 => Key.f20,
238 0x84 => Key.f21,
239 0x85 => Key.f22,
240 0x86 => Key.f23,
241 0x87 => Key.f24,
242 0x90 => Key.num_lock,
243 0x91 => Key.scroll_lock,
244 0xa0 => Key.left_shift,
245 0xa1 => Key.right_shift,
246 0xa2 => Key.left_control,
247 0xa3 => Key.right_control,
248 0xa4 => Key.left_alt,
249 0xa5 => Key.right_alt,
250 0xad => Key.mute_volume,
251 0xae => Key.lower_volume,
252 0xaf => Key.raise_volume,
253 0xb0 => Key.media_track_next,
254 0xb1 => Key.media_track_previous,
255 0xb2 => Key.media_stop,
256 0xb3 => Key.media_play_pause,
257 0xba => ';',
258 0xbb => '+',
259 0xbc => ',',
260 0xbd => '-',
261 0xbe => '.',
262 0xbf => '/',
263 0xc0 => '`',
264 0xdb => '[',
265 0xdc => '\\',
266 0xdd => ']',
267 0xde => '\'',
268 else => return null,
269 };
270
271 if (std.unicode.utf16IsHighSurrogate(base_layout)) {
272 state.utf16_buf[0] = base_layout;
273 state.utf16_half = true;
274 return null;
275 }
276 if (std.unicode.utf16IsLowSurrogate(base_layout)) {
277 return null;
278 }
279
280 var codepoint: u21 = base_layout;
281 var text: ?[]const u8 = null;
282 switch (event.uChar.UnicodeChar) {
283 0x00...0x1F => {},
284 else => |cp| {
285 codepoint = cp;
286 const n = try std.unicode.utf8Encode(codepoint, &self.buf);
287 text = self.buf[0..n];
288 },
289 }
290
291 const key: Key = .{
292 .codepoint = codepoint,
293 .base_layout_codepoint = base_layout,
294 .mods = translateMods(event.dwControlKeyState),
295 .text = text,
296 };
297
298 switch (event.bKeyDown) {
299 0 => return .{ .key_release = key },
300 else => return .{ .key_press = key },
301 }
302 },
303 0x0002 => { // Mouse event
304 // see https://learn.microsoft.com/en-us/windows/console/mouse-event-record-str
305
306 const event = record.Event.MouseEvent;
307
308 // High word of dwButtonState represents mouse wheel. Positive is wheel_up, negative
309 // is wheel_down
310 // Low word represents button state
311 const mouse_wheel_direction: i16 = blk: {
312 const wheelu32: u32 = event.dwButtonState >> 16;
313 const wheelu16: u16 = @truncate(wheelu32);
314 break :blk @bitCast(wheelu16);
315 };
316
317 const buttons: u16 = @truncate(event.dwButtonState);
318 // save the current state when we are done
319 defer self.last_mouse_button_press = buttons;
320 const button_xor = self.last_mouse_button_press ^ buttons;
321
322 var event_type: Mouse.Type = .press;
323 const btn: Mouse.Button = switch (button_xor) {
324 0x0000 => blk: {
325 // Check wheel event
326 if (event.dwEventFlags & 0x0004 > 0) {
327 if (mouse_wheel_direction > 0)
328 break :blk .wheel_up
329 else
330 break :blk .wheel_down;
331 }
332
333 // If we have no change but one of the buttons is still pressed we have a
334 // drag event. Find out which button is held down
335 if (buttons > 0 and event.dwEventFlags & 0x0001 > 0) {
336 event_type = .drag;
337 if (buttons & 0x0001 > 0) break :blk .left;
338 if (buttons & 0x0002 > 0) break :blk .right;
339 if (buttons & 0x0004 > 0) break :blk .middle;
340 if (buttons & 0x0008 > 0) break :blk .button_8;
341 if (buttons & 0x0010 > 0) break :blk .button_9;
342 }
343
344 if (event.dwEventFlags & 0x0001 > 0) event_type = .motion;
345 break :blk .none;
346 },
347 0x0001 => blk: {
348 if (buttons & 0x0001 == 0) event_type = .release;
349 break :blk .left;
350 },
351 0x0002 => blk: {
352 if (buttons & 0x0002 == 0) event_type = .release;
353 break :blk .right;
354 },
355 0x0004 => blk: {
356 if (buttons & 0x0004 == 0) event_type = .release;
357 break :blk .middle;
358 },
359 0x0008 => blk: {
360 if (buttons & 0x0008 == 0) event_type = .release;
361 break :blk .button_8;
362 },
363 0x0010 => blk: {
364 if (buttons & 0x0010 == 0) event_type = .release;
365 break :blk .button_9;
366 },
367 else => {
368 std.log.warn("unknown mouse event: {}", .{event});
369 return null;
370 },
371 };
372
373 const shift: u32 = 0x0010;
374 const alt: u32 = 0x0001 | 0x0002;
375 const ctrl: u32 = 0x0004 | 0x0008;
376 const mods: Mouse.Modifiers = .{
377 .shift = event.dwControlKeyState & shift > 0,
378 .alt = event.dwControlKeyState & alt > 0,
379 .ctrl = event.dwControlKeyState & ctrl > 0,
380 };
381
382 const mouse: Mouse = .{
383 .col = @as(u16, @bitCast(event.dwMousePosition.X)), // Windows reports with 0 index
384 .row = @as(u16, @bitCast(event.dwMousePosition.Y)), // Windows reports with 0 index
385 .mods = mods,
386 .type = event_type,
387 .button = btn,
388 };
389 return .{ .mouse = mouse };
390 },
391 0x0004 => { // Screen resize events
392 // NOTE: Even though the event comes with a size, it may not be accurate. We ask for
393 // the size directly when we get this event
394 var console_info: windows.CONSOLE_SCREEN_BUFFER_INFO = undefined;
395 if (windows.kernel32.GetConsoleScreenBufferInfo(self.stdout, &console_info) == 0) {
396 return windows.unexpectedError(windows.kernel32.GetLastError());
397 }
398 const window_rect = console_info.srWindow;
399 const width = window_rect.Right - window_rect.Left + 1;
400 const height = window_rect.Bottom - window_rect.Top + 1;
401 return .{
402 .winsize = .{
403 .cols = @intCast(width),
404 .rows = @intCast(height),
405 .x_pixel = 0,
406 .y_pixel = 0,
407 },
408 };
409 },
410 0x0010 => { // Focus events
411 switch (record.Event.FocusEvent.bSetFocus) {
412 0 => return .focus_out,
413 else => return .focus_in,
414 }
415 },
416 else => {},
417 }
418 return null;
419}
420
421fn translateMods(mods: u32) Key.Modifiers {
422 const left_alt: u32 = 0x0002;
423 const right_alt: u32 = 0x0001;
424 const left_ctrl: u32 = 0x0008;
425 const right_ctrl: u32 = 0x0004;
426
427 const caps: u32 = 0x0080;
428 const num_lock: u32 = 0x0020;
429 const shift: u32 = 0x0010;
430 const alt: u32 = left_alt | right_alt;
431 const ctrl: u32 = left_ctrl | right_ctrl;
432
433 return .{
434 .shift = mods & shift > 0,
435 .alt = mods & alt > 0,
436 .ctrl = mods & ctrl > 0,
437 .caps_lock = mods & caps > 0,
438 .num_lock = mods & num_lock > 0,
439 };
440}
441
442// From gitub.com/ziglibs/zig-windows-console. Thanks :)
443//
444// Events
445const union_unnamed_248 = extern union {
446 UnicodeChar: windows.WCHAR,
447 AsciiChar: windows.CHAR,
448};
449pub const KEY_EVENT_RECORD = extern struct {
450 bKeyDown: windows.BOOL,
451 wRepeatCount: windows.WORD,
452 wVirtualKeyCode: windows.WORD,
453 wVirtualScanCode: windows.WORD,
454 uChar: union_unnamed_248,
455 dwControlKeyState: windows.DWORD,
456};
457pub const PKEY_EVENT_RECORD = *KEY_EVENT_RECORD;
458
459pub const MOUSE_EVENT_RECORD = extern struct {
460 dwMousePosition: windows.COORD,
461 dwButtonState: windows.DWORD,
462 dwControlKeyState: windows.DWORD,
463 dwEventFlags: windows.DWORD,
464};
465pub const PMOUSE_EVENT_RECORD = *MOUSE_EVENT_RECORD;
466
467pub const WINDOW_BUFFER_SIZE_RECORD = extern struct {
468 dwSize: windows.COORD,
469};
470pub const PWINDOW_BUFFER_SIZE_RECORD = *WINDOW_BUFFER_SIZE_RECORD;
471
472pub const MENU_EVENT_RECORD = extern struct {
473 dwCommandId: windows.UINT,
474};
475pub const PMENU_EVENT_RECORD = *MENU_EVENT_RECORD;
476
477pub const FOCUS_EVENT_RECORD = extern struct {
478 bSetFocus: windows.BOOL,
479};
480pub const PFOCUS_EVENT_RECORD = *FOCUS_EVENT_RECORD;
481
482const union_unnamed_249 = extern union {
483 KeyEvent: KEY_EVENT_RECORD,
484 MouseEvent: MOUSE_EVENT_RECORD,
485 WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD,
486 MenuEvent: MENU_EVENT_RECORD,
487 FocusEvent: FOCUS_EVENT_RECORD,
488};
489pub const INPUT_RECORD = extern struct {
490 EventType: windows.WORD,
491 Event: union_unnamed_249,
492};
493pub const PINPUT_RECORD = *INPUT_RECORD;
494
495pub extern "kernel32" fn ReadConsoleInputW(hConsoleInput: windows.HANDLE, lpBuffer: PINPUT_RECORD, nLength: windows.DWORD, lpNumberOfEventsRead: *windows.DWORD) callconv(windows.WINAPI) windows.BOOL;
496// TODO: remove this in zig 0.13.0
497pub extern "kernel32" fn SetConsoleMode(in_hConsoleHandle: windows.HANDLE, in_dwMode: windows.DWORD) callconv(windows.WINAPI) windows.BOOL;