diff options
| author | t <t@tjp.lol> | 2026-06-08 10:41:30 -0600 |
|---|---|---|
| committer | t <t@tjp.lol> | 2026-06-08 12:25:55 -0600 |
| commit | e5ed00c52bb10aec811734c5568c881c41e58474 (patch) | |
| tree | f51342f759efac79ec175dcc3ede7ebebc0bc470 /src/tui_terminal.zig | |
| parent | a5881243f9bb4642f90fa94179f7a5bff23e4657 (diff) | |
Replace print CLIRenderer with differential TUI engine
Implement TUI Phase 1: a raw-mode terminal, differential render engine,
pinned input box + footer, and component model wired into the libpanto
event stream. Adds foundation modules (theme, key, component, input,
terminal), the render engine, components, and the app loop; removes the
old print CLIRenderer and driveTurn REPL from main.zig.
Also removes scratch status reports (tui-p1/, progress.md) for the now
completed work.
Diffstat (limited to 'src/tui_terminal.zig')
| -rw-r--r-- | src/tui_terminal.zig | 405 |
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()); +} |
