Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479//! Windows Tty backend.//!//! Reads INPUT_RECORDs via ReadConsoleInputW and **serializes them into VT//! byte sequences** that flow through the same `input.parse` decoder POSIX//! uses. This keeps the parser single-sourced: mouse, focus, window-size,//! and keys all share the cross-platform decode path. Cancellation uses a//! CreateEventW handle multiplexed with stdin via WaitForMultipleObjects.
const std = @import("std");const builtin = @import("builtin");const windows = std.os.windows;const Io = std.Io;const Allocator = std.mem.Allocator;
const InputBuffer = @import("../InputBuffer.zig");const Msg = @import("../msg.zig").Msg;const Terminal = @import("../Terminal.zig");
// ─── Win32 constants ───────────────────────────────────────────────────────
// Input mode flags.const ENABLE_PROCESSED_INPUT: DWORD = 0x0001;const ENABLE_LINE_INPUT: DWORD = 0x0002;const ENABLE_ECHO_INPUT: DWORD = 0x0004;const ENABLE_WINDOW_INPUT: DWORD = 0x0008;const ENABLE_MOUSE_INPUT: DWORD = 0x0010;const ENABLE_INSERT_MODE: DWORD = 0x0020;const ENABLE_QUICK_EDIT_MODE: DWORD = 0x0040;const ENABLE_EXTENDED_FLAGS: DWORD = 0x0080;const ENABLE_VIRTUAL_TERMINAL_INPUT: DWORD = 0x0200;
// Output mode flags.const ENABLE_PROCESSED_OUTPUT: DWORD = 0x0001;const ENABLE_WRAP_AT_EOL_OUTPUT: DWORD = 0x0002;const ENABLE_VIRTUAL_TERMINAL_PROCESSING: DWORD = windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING;const DISABLE_NEWLINE_AUTO_RETURN: DWORD = windows.DISABLE_NEWLINE_AUTO_RETURN;const ENABLE_LVB_GRID_WORLDWIDE: DWORD = 0x0010;
const CP_UTF8: UINT = 65001;
const INFINITE: DWORD = 0xFFFFFFFF;const WAIT_OBJECT_0: DWORD = 0x00000000;const WAIT_FAILED: DWORD = 0xFFFFFFFF;
// INPUT_RECORD event types.const KEY_EVENT: WORD = 0x0001;const MOUSE_EVENT: WORD = 0x0002;const WINDOW_BUFFER_SIZE_EVENT: WORD = 0x0004;const MENU_EVENT: WORD = 0x0008;const FOCUS_EVENT: WORD = 0x0010;
// Mouse event flags.const MOUSE_MOVED: DWORD = 0x0001;const DOUBLE_CLICK: DWORD = 0x0002;const MOUSE_WHEELED: DWORD = 0x0004;const MOUSE_HWHEELED: DWORD = 0x0008;
// Mouse button state bits.const FROM_LEFT_1ST_BUTTON_PRESSED: DWORD = 0x0001;const RIGHTMOST_BUTTON_PRESSED: DWORD = 0x0002;const FROM_LEFT_2ND_BUTTON_PRESSED: DWORD = 0x0004;const FROM_LEFT_3RD_BUTTON_PRESSED: DWORD = 0x0008;const FROM_LEFT_4TH_BUTTON_PRESSED: DWORD = 0x0010;
// Control key state bits (also referenced by win32-input-mode encoding in// input.zig — keep these constants in sync if Win32 ever changes them).const LEFT_CTRL_PRESSED: DWORD = 0x0008;
// ─── Win32 types ───────────────────────────────────────────────────────────
const BOOL = windows.BOOL;const HANDLE = windows.HANDLE;const COORD = windows.COORD;const DWORD = windows.DWORD;const WORD = windows.WORD;const UINT = windows.UINT;const SHORT = windows.SHORT;const WCHAR = windows.WCHAR;const SECURITY_ATTRIBUTES = extern struct { nLength: DWORD, lpSecurityDescriptor: ?*anyopaque, bInheritHandle: BOOL,};
const SMALL_RECT = extern struct { Left: SHORT, Top: SHORT, Right: SHORT, Bottom: SHORT,};
const CONSOLE_SCREEN_BUFFER_INFO = extern struct { dwSize: COORD, dwCursorPosition: COORD, wAttributes: WORD, srWindow: SMALL_RECT, dwMaximumWindowSize: COORD,};
const KEY_EVENT_RECORD = extern struct { bKeyDown: BOOL, wRepeatCount: WORD, wVirtualKeyCode: WORD, wVirtualScanCode: WORD, uChar: extern union { UnicodeChar: WCHAR, AsciiChar: u8, }, dwControlKeyState: DWORD,};
const MOUSE_EVENT_RECORD = extern struct { dwMousePosition: COORD, dwButtonState: DWORD, dwControlKeyState: DWORD, dwEventFlags: DWORD,};
const WINDOW_BUFFER_SIZE_RECORD = extern struct { dwSize: COORD,};
const FOCUS_EVENT_RECORD = extern struct { bSetFocus: BOOL,};
const MENU_EVENT_RECORD = extern struct { dwCommandId: UINT,};
const INPUT_RECORD = extern struct { EventType: WORD, Event: extern union { KeyEvent: KEY_EVENT_RECORD, MouseEvent: MOUSE_EVENT_RECORD, WindowBufferSizeEvent: WINDOW_BUFFER_SIZE_RECORD, MenuEvent: MENU_EVENT_RECORD, FocusEvent: FOCUS_EVENT_RECORD, },};
// ─── Externs ───────────────────────────────────────────────────────────────
extern "kernel32" fn GetConsoleMode( hConsoleHandle: HANDLE, lpMode: *DWORD,) callconv(.winapi) BOOL;extern "kernel32" fn SetConsoleMode( hConsoleHandle: HANDLE, dwMode: DWORD,) callconv(.winapi) BOOL;extern "kernel32" fn GetConsoleScreenBufferInfo( hConsoleOutput: HANDLE, lpConsoleScreenBufferInfo: *CONSOLE_SCREEN_BUFFER_INFO,) callconv(.winapi) BOOL;extern "kernel32" fn ReadConsoleInputW( hConsoleInput: HANDLE, lpBuffer: [*]INPUT_RECORD, nLength: DWORD, lpNumberOfEventsRead: *DWORD,) callconv(.winapi) BOOL;extern "kernel32" fn CreateEventW( lpEventAttributes: ?*SECURITY_ATTRIBUTES, bManualReset: BOOL, bInitialState: BOOL, lpName: ?[*:0]const WCHAR,) callconv(.winapi) ?HANDLE;extern "kernel32" fn SetEvent(hEvent: HANDLE) callconv(.winapi) BOOL;extern "kernel32" fn ResetEvent(hEvent: HANDLE) callconv(.winapi) BOOL;extern "kernel32" fn WaitForMultipleObjects( nCount: DWORD, lpHandles: [*]const HANDLE, bWaitAll: BOOL, dwMilliseconds: DWORD,) callconv(.winapi) DWORD;extern "kernel32" fn SetConsoleOutputCP(wCodePageID: UINT) callconv(.winapi) BOOL;extern "kernel32" fn GetConsoleOutputCP() callconv(.winapi) UINT;
// ─── Tty ───────────────────────────────────────────────────────────────────
pub const Tty = struct { io: Io, stdin: HANDLE, stdout: HANDLE, wake_event: HANDLE,
saved_input_mode: DWORD, saved_output_mode: DWORD, saved_output_cp: UINT,
woken: std.atomic.Value(bool) = .init(false),
input_buf: InputBuffer,
/// Previous mouse button bitmap; diffed against new state to derive /// per-button press/release events for SGR emission. last_mouse_buttons: DWORD = 0, /// UTF-16 high surrogate carried over from the previous KEY_EVENT. /// Non-null means the next event supplies the matching low half. utf16_high: ?WCHAR = null,
stdout_writer: Io.File.Writer,
/// `Terminal` seam. Defaulted so `init` literals need not mention it. terminal: Terminal = .{ .vtable = &terminal_vtable },
pub const InitError = error{ NotATerminal, EventCreationFailed, ConsoleModeFailed, Unexpected, };
pub fn init(gpa: Allocator, io: Io, stdout_buf: []u8) InitError!Tty { const stdin_file = Io.File.stdin(); const stdout_file = Io.File.stdout(); const stdin = stdin_file.handle; const stdout_h = stdout_file.handle;
var saved_in: DWORD = 0; if (GetConsoleMode(stdin, &saved_in) == .FALSE) return error.NotATerminal;
var saved_out: DWORD = 0; if (GetConsoleMode(stdout_h, &saved_out) == .FALSE) return error.NotATerminal;
const saved_cp = GetConsoleOutputCP();
// Raw input with window-resize, mouse, focus, and VT input events. // Drop QUICK_EDIT (eats mouse selection). VT input lets the terminal // deliver bracketed-paste markers, function keys, and other CSI // sequences as byte streams via KEY_EVENT.UnicodeChar; we accumulate // them in InputBuffer and let the shared decoder handle the rest. const new_in: DWORD = (saved_in & ~ENABLE_LINE_INPUT & ~ENABLE_ECHO_INPUT & ~ENABLE_PROCESSED_INPUT & ~ENABLE_QUICK_EDIT_MODE) | ENABLE_WINDOW_INPUT | ENABLE_MOUSE_INPUT | ENABLE_EXTENDED_FLAGS | ENABLE_VIRTUAL_TERMINAL_INPUT; if (SetConsoleMode(stdin, new_in) == .FALSE) return error.ConsoleModeFailed; errdefer _ = SetConsoleMode(stdin, saved_in);
// Output: ANSI escape handling, no auto-wrap at last column, allow // LVB grid (underline/reverse). const new_out: DWORD = saved_out | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING | DISABLE_NEWLINE_AUTO_RETURN | ENABLE_LVB_GRID_WORLDWIDE; if (SetConsoleMode(stdout_h, new_out) == .FALSE) return error.ConsoleModeFailed; errdefer _ = SetConsoleMode(stdout_h, saved_out);
_ = SetConsoleOutputCP(CP_UTF8); errdefer _ = SetConsoleOutputCP(saved_cp);
const wake_handle = CreateEventW(null, .FALSE, .FALSE, null) orelse return error.EventCreationFailed;
return .{ .io = io, .stdin = stdin, .stdout = stdout_h, .wake_event = wake_handle, .saved_input_mode = saved_in, .saved_output_mode = saved_out, .saved_output_cp = saved_cp, .input_buf = .init(gpa), .stdout_writer = Io.File.stdout().writer(io, stdout_buf), }; }
pub fn deinit(self: *Tty) void { _ = SetConsoleMode(self.stdin, self.saved_input_mode); _ = SetConsoleMode(self.stdout, self.saved_output_mode); _ = SetConsoleOutputCP(self.saved_output_cp); windows.CloseHandle(self.wake_event); self.input_buf.deinit(); }
pub fn writer(self: *Tty) *Io.Writer { return &self.stdout_writer.interface; }
pub fn windowSize(self: *Tty) !struct { width: u16, height: u16 } { var info: CONSOLE_SCREEN_BUFFER_INFO = undefined; if (GetConsoleScreenBufferInfo(self.stdout, &info) == .FALSE) { return windows.unexpectedError(windows.GetLastError()); } const width: u16 = @intCast(info.srWindow.Right - info.srWindow.Left + 1); const height: u16 = @intCast(info.srWindow.Bottom - info.srWindow.Top + 1); return .{ .width = width, .height = height }; }
pub fn wake(self: *Tty) void { self.woken.store(true, .release); _ = SetEvent(self.wake_event); }
pub fn nextMsg(self: *Tty) !?Msg { while (true) { if (self.woken.load(.acquire)) return null; if (try self.input_buf.next()) |msg| return msg;
const handles = [_]HANDLE{ self.stdin, self.wake_event }; const wait = WaitForMultipleObjects(2, &handles, .FALSE, INFINITE); if (wait == WAIT_FAILED) return windows.unexpectedError(windows.GetLastError()); if (wait == WAIT_OBJECT_0 + 1) return null; if (wait != WAIT_OBJECT_0) continue;
var records: [16]INPUT_RECORD = undefined; var read: DWORD = 0; if (ReadConsoleInputW(self.stdin, &records, records.len, &read) == .FALSE) { return windows.unexpectedError(windows.GetLastError()); } for (records[0..read]) |rec| try self.serialize(rec); if (try self.input_buf.next()) |msg| return msg; } }
/// Translate one Win32 INPUT_RECORD into the equivalent VT byte sequence /// and feed it to `input_buf`. Mirrors charmbracelet/ultraviolet's /// serializeWin32InputRecords. fn serialize(self: *Tty, rec: INPUT_RECORD) !void { switch (rec.EventType) { KEY_EVENT => try self.serializeKey(rec.Event.KeyEvent), MOUSE_EVENT => try self.serializeMouse(rec.Event.MouseEvent), WINDOW_BUFFER_SIZE_EVENT => { const sz = rec.Event.WindowBufferSizeEvent.dwSize; var scratch: [32]u8 = undefined; const s = std.fmt.bufPrint(&scratch, "\x1b[8;{d};{d}t", .{ @as(u32, @intCast(@max(sz.Y, 0))), @as(u32, @intCast(@max(sz.X, 0))), }) catch return; self.input_buf.append(s); }, FOCUS_EVENT => self.input_buf.append( if (rec.Event.FocusEvent.bSetFocus == .FALSE) "\x1b[O" else "\x1b[I", ), else => {}, // MENU_EVENT and others: ignore } }
fn serializeKey(self: *Tty, ke: KEY_EVENT_RECORD) !void { // Only emit on key down. POSIX terminals don't surface release events // by default; matching that keeps Msg semantics uniform across // platforms. (Kitty keyboard protocol can re-enable release later.) if (ke.bKeyDown == .FALSE) return;
const wc = ke.uChar.UnicodeChar; if (wc == 0) return; // pure VK with no char (terminal already sent the VT bytes)
// Combine UTF-16 surrogate pairs from sequential events. var codepoint: u21 = wc; if (self.utf16_high) |hi| { self.utf16_high = null; if (wc >= 0xDC00 and wc <= 0xDFFF) { codepoint = 0x10000 + (@as(u21, hi - 0xD800) << 10) + (@as(u21, wc) - 0xDC00); } else { codepoint = wc; // orphaned high — drop and use current char } } else if (wc >= 0xD800 and wc <= 0xDBFF) { self.utf16_high = wc; return; } else if (wc >= 0xDC00 and wc <= 0xDFFF) { return; // orphaned low half }
// Emit as UTF-8. The terminal's VT sequences (paste markers, arrow // keys, etc.) arrive byte-by-byte here; writing them through forms // the same stream POSIX would see. var scratch: [4]u8 = undefined; const n = std.unicode.utf8Encode(codepoint, &scratch) catch return; self.input_buf.append(scratch[0..n]); }
fn serializeMouse(self: *Tty, me: MOUSE_EVENT_RECORD) !void { const x: u32 = @intCast(@max(me.dwMousePosition.X, 0) + 1); const y: u32 = @intCast(@max(me.dwMousePosition.Y, 0) + 1);
// SGR mouse encoding bits: shift=0x4, alt=0x8, ctrl=0x10, // motion=0x20, wheel=0x40, additional buttons (8+)=0x80. const LEFT_ALT_PRESSED: DWORD = 0x0002; const RIGHT_ALT_PRESSED: DWORD = 0x0001; const RIGHT_CTRL_PRESSED: DWORD = 0x0004; const SHIFT_PRESSED: DWORD = 0x0010; var sgr_mods: u32 = 0; if (me.dwControlKeyState & SHIFT_PRESSED != 0) sgr_mods |= 0x04; if (me.dwControlKeyState & (LEFT_ALT_PRESSED | RIGHT_ALT_PRESSED) != 0) sgr_mods |= 0x08; if (me.dwControlKeyState & (LEFT_CTRL_PRESSED | RIGHT_CTRL_PRESSED) != 0) sgr_mods |= 0x10;
var cb: u32 = 0; var final: u8 = 'M';
if (me.dwEventFlags & MOUSE_WHEELED != 0) { const delta: i16 = @bitCast(@as(u16, @truncate(me.dwButtonState >> 16))); cb = 0x40 | (if (delta > 0) @as(u32, 0) else @as(u32, 1)); // wheel_up=0, wheel_down=1 cb |= sgr_mods; } else if (me.dwEventFlags & MOUSE_HWHEELED != 0) { const delta: i16 = @bitCast(@as(u16, @truncate(me.dwButtonState >> 16))); // wheel_left=2, wheel_right=3 (relative to wheel_up base) cb = 0x40 | (if (delta > 0) @as(u32, 3) else @as(u32, 2)); cb |= sgr_mods; } else if (me.dwEventFlags & MOUSE_MOVED != 0) { const btn = primaryButton(me.dwButtonState); cb = btn | 0x20 | sgr_mods; } else { // Press / release: diff with last button state. const pressed = me.dwButtonState & ~self.last_mouse_buttons; const released = self.last_mouse_buttons & ~me.dwButtonState; if (pressed != 0) { cb = primaryButton(pressed) | sgr_mods; } else if (released != 0) { cb = primaryButton(released) | sgr_mods; final = 'm'; } else { self.last_mouse_buttons = me.dwButtonState; return; // no diff, no event } } self.last_mouse_buttons = me.dwButtonState;
var scratch: [32]u8 = undefined; const s = std.fmt.bufPrint(&scratch, "\x1b[<{d};{d};{d}{c}", .{ cb, x, y, final, }) catch return; self.input_buf.append(s); }};
// ─── Terminal vtable ─────────────────────────────────────────────────────
const terminal_vtable: Terminal.VTable = .{ .nextMsg = tNextMsg, .writer = tWriter, .windowSize = tWindowSize, .wake = tWake, .deinit = tDeinit,};
fn tNextMsg(t: *Terminal) anyerror!?Msg { const self: *Tty = @fieldParentPtr("terminal", t); return self.nextMsg();}
fn tWriter(t: *Terminal) *Io.Writer { const self: *Tty = @fieldParentPtr("terminal", t); return self.writer();}
fn tWindowSize(t: *Terminal) anyerror!Terminal.Size { const self: *Tty = @fieldParentPtr("terminal", t); const ws = try self.windowSize(); return .{ .width = ws.width, .height = ws.height };}
fn tWake(t: *Terminal) void { const self: *Tty = @fieldParentPtr("terminal", t); self.wake();}
fn tDeinit(t: *Terminal) void { const self: *Tty = @fieldParentPtr("terminal", t); self.deinit();}
/// Map a Win32 button-state bit to the SGR `Cb` low bits. Returns just the/// button portion; caller ORs in mods/motion/wheel/add bits.fn primaryButton(state: DWORD) u32 { if (state & FROM_LEFT_1ST_BUTTON_PRESSED != 0) return 0; // left if (state & FROM_LEFT_2ND_BUTTON_PRESSED != 0) return 1; // middle if (state & RIGHTMOST_BUTTON_PRESSED != 0) return 2; // right if (state & FROM_LEFT_3RD_BUTTON_PRESSED != 0) return 0x80; // backward (add bit only) if (state & FROM_LEFT_4TH_BUTTON_PRESSED != 0) return 0x80 | 1; // forward return 3; // no button → "none" (release marker in X10, ignored in SGR)}