summaryrefslogtreecommitdiff
path: root/src/tui_terminal.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/tui_terminal.zig')
-rw-r--r--src/tui_terminal.zig405
1 files changed, 405 insertions, 0 deletions
diff --git a/src/tui_terminal.zig b/src/tui_terminal.zig
new file mode 100644
index 0000000..c4545e9
--- /dev/null
+++ b/src/tui_terminal.zig
@@ -0,0 +1,405 @@
+//! Terminal control for the TUI.
+//!
+//! Responsibilities:
+//! - enter / exit raw mode via termios (Unix only; Windows out of scope)
+//! - restore the original termios on normal exit AND on crash:
+//! * `defer term.deinit()` for the happy path
+//! * SIGINT / SIGTERM / SIGHUP handlers that restore and re-raise
+//! * a panic hook (`installPanicRestore`) the program's panic handler
+//! can call so a Zig panic still restores the terminal
+//! Restore is idempotent — calling it twice is safe and the second call
+//! is a no-op.
+//! - NEVER use the alternate screen buffer (no `?1049h` / `?47h`). We render
+//! inline in the primary buffer.
+//! - synchronized-output framing, cursor movement, line/screen clearing,
+//! cursor show/hide, absolute positioning.
+//! - query the window size (TIOCGWINSZ) and expose it; expose a SIGWINCH
+//! "resized" flag the render engine can poll.
+//! - best-effort capability detection (synchronized output, Kitty keyboard).
+//!
+//! All escape output goes straight to the tty fd via `std.c.write` (libc is
+//! linked). The signal/panic restore path also uses `std.c.write` /
+//! `std.c.tcsetattr`, which are async-signal-safe.
+//!
+//! Threading / global-state note: termios restore from a signal handler needs
+//! the saved state at a fixed address. We keep a single file-scope
+//! `active_restore` pointer to the live `Terminal`'s saved termios + fd. This
+//! means ONE `Terminal` is "active" for signal restore at a time (the TUI only
+//! ever creates one). `init` registers it; `deinit` clears it.
+
+const std = @import("std");
+const builtin = @import("builtin");
+const posix = std.posix;
+
+/// Raw escape sequences. Kept as named constants so the engine and tests can
+/// reference them and so the "no alt screen" rule is auditable in one place.
+pub const seq = struct {
+ /// Begin / end synchronized output (DEC private mode 2026). Wrapping a
+ /// frame in these tells the terminal to buffer and present atomically,
+ /// avoiding tearing.
+ pub const sync_begin = "\x1b[?2026h";
+ pub const sync_end = "\x1b[?2026l";
+
+ /// Full-redraw clear: clear screen, home cursor, AND clear scrollback
+ /// (`3J`). Used on first paint / width change / height change.
+ pub const full_clear = "\x1b[2J\x1b[H\x1b[3J";
+
+ /// Carriage return (column 0, same row).
+ pub const carriage_return = "\r";
+ /// Clear from cursor to end of line.
+ pub const clear_line = "\x1b[K";
+ /// Clear from cursor to end of screen.
+ pub const clear_to_end = "\x1b[J";
+
+ pub const hide_cursor = "\x1b[?25l";
+ pub const show_cursor = "\x1b[?25h";
+};
+
+pub const Size = struct {
+ rows: u16,
+ cols: u16,
+};
+
+pub const Error = error{
+ NotATty,
+ TermiosGetFailed,
+ TermiosSetFailed,
+};
+
+// ---- Global state for signal/panic restore -------------------------------
+
+/// Minimal restore record: the fd whose termios we need to restore plus the
+/// saved original. Pointed to by `active_restore` while a Terminal is live.
+const Restore = struct {
+ fd: posix.fd_t,
+ original: posix.termios,
+ /// Set once restore has run, so it's idempotent across signal + deinit.
+ done: bool = false,
+};
+
+/// The single active restore record (see module note). `null` when no Terminal
+/// is in raw mode.
+var active_restore: ?*Restore = null;
+
+/// Async-signal-safe restore: reset termios and show the cursor. Safe to call
+/// multiple times. Uses only `tcsetattr` + `write`, both signal-safe.
+fn doRestore(r: *Restore) void {
+ if (r.done) return;
+ r.done = true;
+ // Best-effort; ignore errors (we may be unwinding a crash).
+ posix.tcsetattr(r.fd, .FLUSH, r.original) catch {};
+ _ = std.c.write(r.fd, seq.show_cursor.ptr, seq.show_cursor.len);
+}
+
+fn signalRestoreHandler(sig: posix.SIG) callconv(.c) void {
+ if (active_restore) |r| doRestore(r);
+ // Re-raise with the default disposition so the process dies as expected.
+ posix.sigaction(sig, &.{
+ .handler = .{ .handler = posix.SIG.DFL },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ }, null);
+ _ = std.c.raise(sig);
+}
+
+/// SIGWINCH flag. Set by the handler, polled (and cleared) by the engine via
+/// `Terminal.takeResized`.
+var winch_flag: std.atomic.Value(bool) = .init(false);
+
+fn winchHandler(sig: posix.SIG) callconv(.c) void {
+ _ = sig;
+ winch_flag.store(true, .seq_cst);
+}
+
+// ---- Capability detection -------------------------------------------------
+
+pub const Capabilities = struct {
+ /// Terminal is believed to support synchronized output (mode 2026).
+ synchronized_output: bool,
+ /// Terminal is believed to support the Kitty keyboard protocol.
+ kitty_keyboard: bool,
+};
+
+/// Best-effort capability detection by environment sniffing.
+///
+/// We do NOT block on a terminal query response here (that risks hanging on
+/// terminals that don't reply). Assumptions:
+/// - Synchronized output is widely supported by modern emulators; we treat
+/// it as available unless `TERM` looks like a dumb/legacy terminal. The
+/// sequences are no-ops on terminals that don't understand them, so a
+/// false positive is harmless.
+/// - Kitty keyboard support is inferred from `TERM`/`TERM_PROGRAM` env hints
+/// (kitty, foot, ghostty, wezterm, recent alacritty). A false negative
+/// just means we fall back to legacy decoding; a false positive is safe
+/// because the enable sequence is ignored by terminals that don't grok it
+/// and our decoder degrades gracefully on unrecognized input.
+pub fn detectCapabilities(env: std.process.Environ.Map) Capabilities {
+ const term = env.get("TERM") orelse "";
+ const term_program = env.get("TERM_PROGRAM") orelse "";
+
+ const dumb = std.mem.eql(u8, term, "") or
+ std.mem.eql(u8, term, "dumb");
+
+ const kitty = containsAny(term, &.{ "kitty", "foot", "ghostty", "wezterm" }) or
+ containsAny(term_program, &.{ "kitty", "foot", "ghostty", "WezTerm", "ghostty" }) or
+ env.get("KITTY_WINDOW_ID") != null;
+
+ return .{
+ .synchronized_output = !dumb,
+ .kitty_keyboard = kitty,
+ };
+}
+
+fn containsAny(haystack: []const u8, needles: []const []const u8) bool {
+ for (needles) |n| {
+ if (std.ascii.indexOfIgnoreCase(haystack, n) != null) return true;
+ }
+ return false;
+}
+
+// ---- The Terminal --------------------------------------------------------
+
+pub const Terminal = struct {
+ fd: posix.fd_t,
+ restore: Restore,
+ /// Whether raw mode is currently engaged.
+ raw_mode: bool = false,
+ size: Size,
+ caps: Capabilities,
+ /// Saved prior SIGWINCH disposition is not restored (handler is process-
+ /// global and harmless); we only track that we installed ours.
+ installed_signals: bool = false,
+
+ /// Open the controlling terminal on `fd` (typically stdin) and capture its
+ /// current termios. Does NOT enter raw mode yet — call `enableRawMode`.
+ pub fn init(fd: posix.fd_t, env: std.process.Environ.Map) Error!Terminal {
+ if (std.c.isatty(fd) == 0) return Error.NotATty;
+ const original = posix.tcgetattr(fd) catch return Error.TermiosGetFailed;
+ const size = querySize(fd) orelse Size{ .rows = 24, .cols = 80 };
+ return .{
+ .fd = fd,
+ .restore = .{ .fd = fd, .original = original },
+ .size = size,
+ .caps = detectCapabilities(env),
+ };
+ }
+
+ /// Enter raw mode and install signal handlers + the global restore record.
+ /// Hides nothing on its own; the engine controls the cursor.
+ pub fn enableRawMode(self: *Terminal) Error!void {
+ if (self.raw_mode) return;
+
+ var raw = self.restore.original;
+ // Input flags: no break->int, no CR->NL, no parity check, no strip,
+ // no flow control.
+ raw.iflag.BRKINT = false;
+ raw.iflag.ICRNL = false;
+ raw.iflag.INPCK = false;
+ raw.iflag.ISTRIP = false;
+ raw.iflag.IXON = false;
+ // Output: keep post-processing OFF so our explicit \r\n is exact.
+ raw.oflag.OPOST = false;
+ // Control: 8-bit chars.
+ raw.cflag.CSIZE = .CS8;
+ // Local flags: no echo, non-canonical, no signals from keys (we read
+ // Ctrl+C ourselves), no extended processing.
+ raw.lflag.ECHO = false;
+ raw.lflag.ICANON = false;
+ raw.lflag.ISIG = false;
+ raw.lflag.IEXTEN = false;
+ // Non-blocking-ish read: return as soon as >=1 byte is available.
+ raw.cc[@intFromEnum(posix.V.MIN)] = 1;
+ raw.cc[@intFromEnum(posix.V.TIME)] = 0;
+
+ posix.tcsetattr(self.fd, .FLUSH, raw) catch return Error.TermiosSetFailed;
+ self.raw_mode = true;
+ self.restore.done = false;
+ active_restore = &self.restore;
+ self.installSignalHandlers();
+ }
+
+ fn installSignalHandlers(self: *Terminal) void {
+ const restore_act = posix.Sigaction{
+ .handler = .{ .handler = signalRestoreHandler },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ };
+ posix.sigaction(posix.SIG.INT, &restore_act, null);
+ posix.sigaction(posix.SIG.TERM, &restore_act, null);
+ posix.sigaction(posix.SIG.HUP, &restore_act, null);
+
+ const winch_act = posix.Sigaction{
+ .handler = .{ .handler = winchHandler },
+ .mask = posix.sigemptyset(),
+ .flags = 0,
+ };
+ posix.sigaction(posix.SIG.WINCH, &winch_act, null);
+ self.installed_signals = true;
+ }
+
+ /// Restore the terminal: leave raw mode, show the cursor. Idempotent.
+ /// Safe to call from `defer`. Does not touch the alternate screen (we
+ /// never entered it).
+ pub fn deinit(self: *Terminal) void {
+ doRestore(&self.restore);
+ self.raw_mode = false;
+ if (active_restore == &self.restore) active_restore = null;
+ }
+
+ // -- output helpers -----------------------------------------------------
+
+ /// Raw write of `bytes` to the tty. Returns the count written (short
+ /// writes are possible on a tty but rare for small control sequences;
+ /// callers writing large frames should loop or use a buffered writer).
+ pub fn writeAll(self: *Terminal, bytes: []const u8) void {
+ var off: usize = 0;
+ while (off < bytes.len) {
+ const n = std.c.write(self.fd, bytes.ptr + off, bytes.len - off);
+ if (n <= 0) break;
+ off += @intCast(n);
+ }
+ }
+
+ pub fn beginSync(self: *Terminal) void {
+ if (self.caps.synchronized_output) self.writeAll(seq.sync_begin);
+ }
+
+ pub fn endSync(self: *Terminal) void {
+ if (self.caps.synchronized_output) self.writeAll(seq.sync_end);
+ }
+
+ pub fn hideCursor(self: *Terminal) void {
+ self.writeAll(seq.hide_cursor);
+ }
+
+ pub fn showCursor(self: *Terminal) void {
+ self.writeAll(seq.show_cursor);
+ }
+
+ pub fn carriageReturn(self: *Terminal) void {
+ self.writeAll(seq.carriage_return);
+ }
+
+ pub fn clearLine(self: *Terminal) void {
+ self.writeAll(seq.clear_line);
+ }
+
+ pub fn clearToEndOfScreen(self: *Terminal) void {
+ self.writeAll(seq.clear_to_end);
+ }
+
+ /// Full redraw clear (clears scrollback too). Use only on first paint /
+ /// width change / height change.
+ pub fn fullClear(self: *Terminal) void {
+ self.writeAll(seq.full_clear);
+ }
+
+ /// Move the cursor up `n` rows (no-op when `n == 0`). Writes into `buf`.
+ pub fn cursorUp(self: *Terminal, n: usize) void {
+ if (n == 0) return;
+ var buf: [16]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d}A", .{n}) catch return;
+ self.writeAll(s);
+ }
+
+ /// Move the cursor down `n` rows (no-op when `n == 0`).
+ pub fn cursorDown(self: *Terminal, n: usize) void {
+ if (n == 0) return;
+ var buf: [16]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d}B", .{n}) catch return;
+ self.writeAll(s);
+ }
+
+ /// Move to absolute (row, col), 1-based (CUP).
+ pub fn moveTo(self: *Terminal, row: usize, col: usize) void {
+ var buf: [32]u8 = undefined;
+ const s = std.fmt.bufPrint(&buf, "\x1b[{d};{d}H", .{ row, col }) catch return;
+ self.writeAll(s);
+ }
+
+ // -- size / resize ------------------------------------------------------
+
+ /// Re-query the window size from the kernel and update `self.size`.
+ /// Returns the new size. Falls back to the previous size on failure.
+ pub fn refreshSize(self: *Terminal) Size {
+ if (querySize(self.fd)) |s| self.size = s;
+ return self.size;
+ }
+
+ /// Poll-and-clear the SIGWINCH flag. Returns true exactly once per resize
+ /// burst the engine hasn't yet observed.
+ pub fn takeResized(self: *Terminal) bool {
+ _ = self;
+ return winch_flag.swap(false, .seq_cst);
+ }
+
+ /// Install a panic-time restore. Call this from the program's panic
+ /// handler (or once at startup if your panic handler is the default and
+ /// you want best effort). It restores the currently-active terminal.
+ pub fn installPanicRestore() void {
+ if (active_restore) |r| doRestore(r);
+ }
+};
+
+/// Query the terminal size via TIOCGWINSZ. Returns null on failure.
+fn querySize(fd: posix.fd_t) ?Size {
+ var ws: posix.winsize = undefined;
+ const rc = std.c.ioctl(fd, std.c.T.IOCGWINSZ, @intFromPtr(&ws));
+ if (rc != 0) return null;
+ if (ws.row == 0 or ws.col == 0) return null;
+ return .{ .rows = ws.row, .cols = ws.col };
+}
+
+// ---- Tests ----------------------------------------------------------------
+
+test "no alt screen sequences anywhere in seq" {
+ // Guardrail: the alternate-screen private modes must never appear.
+ inline for (.{
+ seq.sync_begin, seq.sync_end, seq.full_clear, seq.clear_line,
+ seq.clear_to_end, seq.hide_cursor, seq.show_cursor,
+ }) |s| {
+ try std.testing.expect(std.mem.indexOf(u8, s, "?1049") == null);
+ try std.testing.expect(std.mem.indexOf(u8, s, "?47") == null);
+ }
+}
+
+test "full clear includes scrollback (3J)" {
+ try std.testing.expect(std.mem.indexOf(u8, seq.full_clear, "\x1b[3J") != null);
+}
+
+test "sync framing values" {
+ try std.testing.expectEqualStrings("\x1b[?2026h", seq.sync_begin);
+ try std.testing.expectEqualStrings("\x1b[?2026l", seq.sync_end);
+}
+
+test "capability detection: dumb terminal" {
+ var env = std.process.Environ.Map.init(std.testing.allocator);
+ defer env.deinit();
+ try env.put("TERM", "dumb");
+ const caps = detectCapabilities(env);
+ try std.testing.expect(!caps.synchronized_output);
+ try std.testing.expect(!caps.kitty_keyboard);
+}
+
+test "capability detection: kitty" {
+ var env = std.process.Environ.Map.init(std.testing.allocator);
+ defer env.deinit();
+ try env.put("TERM", "xterm-kitty");
+ const caps = detectCapabilities(env);
+ try std.testing.expect(caps.synchronized_output);
+ try std.testing.expect(caps.kitty_keyboard);
+}
+
+test "resize flag round-trips" {
+ winch_flag.store(false, .seq_cst);
+ var t: Terminal = .{
+ .fd = 0,
+ .restore = .{ .fd = 0, .original = undefined },
+ .size = .{ .rows = 24, .cols = 80 },
+ .caps = .{ .synchronized_output = true, .kitty_keyboard = false },
+ };
+ try std.testing.expect(!t.takeResized());
+ winch_flag.store(true, .seq_cst);
+ try std.testing.expect(t.takeResized());
+ try std.testing.expect(!t.takeResized());
+}