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_theme.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_theme.zig')
| -rw-r--r-- | src/tui_theme.zig | 136 |
1 files changed, 136 insertions, 0 deletions
diff --git a/src/tui_theme.zig b/src/tui_theme.zig new file mode 100644 index 0000000..1acbca0 --- /dev/null +++ b/src/tui_theme.zig @@ -0,0 +1,136 @@ +//! Centralized terminal color / style helpers for the TUI. +//! +//! Today the CLI emits raw ANSI escapes inline (e.g. `\x1b[2m` for dim, +//! `\x1b[36m` for tool/cyan). This module collects those into one named +//! palette so components don't hand-roll escapes. This is *centralization*, +//! not a configurable theming system — the palette is fixed at comptime. +//! +//! Ergonomic shape (mirrors pi's `fg(name, text)` / `bg(name, text)`): +//! - `Theme.fg(name)` / `Theme.bg(name)` return a `Style` value. +//! - `Style.open()` / `Style.close()` return the raw escape strings, for +//! callers that build lines manually (the common TUI case, where a line is +//! assembled into a buffer). +//! - `Style.wrap(alloc, text)` returns an allocator-owned +//! `open ++ text ++ close` string, for convenience. +//! - `Style.write(writer, text)` writes `open ++ text ++ close` into a +//! `*std.Io.Writer`, for streaming callers. +//! +//! All escapes are static strings, so `open()`/`close()` never allocate and +//! never fail. Only `wrap` allocates. + +const std = @import("std"); + +/// Reset-all sequence. Closing any style emits this. +pub const reset = "\x1b[0m"; + +/// Named styles the P1 components need. Each maps to a fixed ANSI open +/// sequence; the close is always `reset`. +pub const StyleName = enum { + /// Dimmed text — used for thinking blocks and status/retry lines. + dim, + /// Assistant message body. Plain (no escape); present so call sites can + /// be explicit rather than emitting nothing. + assistant, + /// User-entered text. + user, + /// Tool invocation prefix / cyan accent. + tool, + /// Footer / chrome line. + footer, + /// The virtual cursor: reverse video block. + cursor, + /// Error / retry text. + err, +}; + +/// A resolved style: the opening escape and (implicitly) the `reset` close. +pub const Style = struct { + open_seq: []const u8, + /// When true the style is "empty" (no visible escape); `open()` returns + /// "" and `close()` returns "" so wrapping plain text is a no-op. + is_plain: bool = false, + + pub fn open(self: Style) []const u8 { + return self.open_seq; + } + + pub fn close(self: Style) []const u8 { + return if (self.is_plain) "" else reset; + } + + /// Allocate `open ++ text ++ close`. Caller owns the returned slice. + pub fn wrap(self: Style, alloc: std.mem.Allocator, text: []const u8) ![]u8 { + return std.fmt.allocPrint(alloc, "{s}{s}{s}", .{ self.open(), text, self.close() }); + } + + /// Write `open ++ text ++ close` into `writer`. + pub fn write(self: Style, writer: *std.Io.Writer, text: []const u8) !void { + try writer.writeAll(self.open()); + try writer.writeAll(text); + try writer.writeAll(self.close()); + } +}; + +/// The (fixed) theme. A value type so the engine can hold one by value. +pub const Theme = struct { + /// Resolve a foreground style by name. + pub fn fg(self: Theme, name: StyleName) Style { + _ = self; + return styleFor(name); + } + + /// Resolve a background-oriented style by name. For this fixed palette the + /// only background-style use is `cursor` (reverse video already covers + /// fg+bg); other names fall back to the same escape as `fg`. Kept as a + /// distinct entry point so future call sites read clearly and a richer + /// palette can diverge later without churn. + pub fn bg(self: Theme, name: StyleName) Style { + _ = self; + return styleFor(name); + } +}; + +/// The single shared theme instance. +pub const default: Theme = .{}; + +fn styleFor(name: StyleName) Style { + return switch (name) { + .dim => .{ .open_seq = "\x1b[2m" }, + // Plain assistant text: no escape, no reset. + .assistant => .{ .open_seq = "", .is_plain = true }, + .user => .{ .open_seq = "\x1b[39m" }, + .tool => .{ .open_seq = "\x1b[36m" }, + .footer => .{ .open_seq = "\x1b[2m" }, + // Reverse video — the virtual cursor block. + .cursor => .{ .open_seq = "\x1b[7m" }, + .err => .{ .open_seq = "\x1b[31m" }, + }; +} + +test "open/close are static and reversible" { + const t = default; + const dim = t.fg(.dim); + try std.testing.expectEqualStrings("\x1b[2m", dim.open()); + try std.testing.expectEqualStrings(reset, dim.close()); +} + +test "plain assistant style emits nothing" { + const t = default; + const a = t.fg(.assistant); + try std.testing.expectEqualStrings("", a.open()); + try std.testing.expectEqualStrings("", a.close()); +} + +test "wrap allocates open++text++close" { + const t = default; + const s = try t.fg(.tool).wrap(std.testing.allocator, "read"); + defer std.testing.allocator.free(s); + try std.testing.expectEqualStrings("\x1b[36mread\x1b[0m", s); +} + +test "write streams open++text++close" { + var buf: [64]u8 = undefined; + var w = std.Io.Writer.fixed(&buf); + try default.fg(.cursor).write(&w, "X"); + try std.testing.expectEqualStrings("\x1b[7mX\x1b[0m", w.buffered()); +} |
