zfin/src/tui/theme.zig
Emil Lerch fad9be6ce8
All checks were successful
Generic zig build / build (push) Successful in 2m20s
Generic zig build / deploy (push) Successful in 27s
upgrade to zig 0.16.0
IO-as-an-interface refactor across the codebase. The big shifts:
- std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run.
- Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena,
  environ_map up front. main.zig + the build/ scripts use it directly.
- Threading io through everywhere that touches the outside world (HTTP,
  files, stderr, sleep, terminal detection). Functions taking `io` now
  announce side effects at the call site — the smell is the feature.
- date math takes `as_of: Date`, not `today: Date`. Caller resolves
  `--as-of` flag vs wall-clock at the boundary; the function operates
  on whatever date it's given. Every "today" parameter renamed and
  the as_of: ?Date + today: Date pattern collapsed.
- now_s: i64 (or before_s/after_s pairs) for sub-second metadata
  fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo.
  Also pure and testable.
- legitimate Timestamp.now callers (cache TTL math, FetchResult
  timestamps, rate limiter, per-frame TUI "now" captures) gain
  `// wall-clock required: ...` comments justifying the read.

Test discovery: replaced the local refAllDeclsRecursive with bare
std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level
decls reaches every test file transitively through the import graph;
no explicit _ = @import(...) lines needed.

Cleanup along the way:
- Dropped DataService.allocator()/io() accessor methods; renamed the
  fields to drop the base_ prefix. Callers use self.allocator and
  self.io directly.
- Dropped now-vestigial io parameters from buildSnapshot,
  analyzePortfolio, compareSchwabSummary, compareAccounts,
  buildPortfolioData, divs.display, quote.display, parsePortfolioOpts,
  aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator,
  aggregateDripLots, printLotRow, portfolio.display, printSnapNote.
- Dropped the unused contributions.computeAttribution date-form
  wrapper (only computeAttributionSpec is called).
- formatAge/fmtTimeAgo take (before_s, after_s) instead of io and
  reading the clock internally.
- parseProjectionsConfig uses an internal stack-buffer
  FixedBufferAllocator instead of an allocator parameter.
- ThreadSafeAllocator wrappers in cache concurrency tests dropped
  (0.16's DebugAllocator is thread-safe by default).
- analyzePortfolio bug surfaced by the rename: snapshot.zig was
  passing wall-clock today instead of as_of, mis-valuing cash/CDs
  for historical backfills.

83 new unit tests added due to removal of IO, bringing coverage from 58%
-> 64%
2026-05-09 22:40:33 -07:00

334 lines
12 KiB
Zig

const std = @import("std");
const vaxis = @import("vaxis");
const srf = @import("srf");
const fmt = @import("../format.zig");
pub const Color = [3]u8;
pub const Theme = struct {
// Backgrounds
bg: Color,
bg_panel: Color,
bg_element: Color,
// Tab bar
tab_bg: Color,
tab_fg: Color,
tab_active_bg: Color,
tab_active_fg: Color,
// Content
text: Color,
text_muted: Color,
text_dim: Color,
// Status bar
status_bg: Color,
status_fg: Color,
// Input prompt
input_bg: Color,
input_fg: Color,
input_hint: Color,
// Semantic
accent: Color,
positive: Color,
negative: Color,
warning: Color,
info: Color,
// Selection / cursor highlight
select_bg: Color,
select_fg: Color,
// Border
border: Color,
// Chart / data visualization
bar_fill: Color,
pub fn vcolor(c: Color) vaxis.Cell.Color {
return .{ .rgb = c };
}
pub fn style(_: Theme, fg_color: Color, bg_color: Color) vaxis.Style {
return .{ .fg = vcolor(fg_color), .bg = vcolor(bg_color) };
}
pub fn contentStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.text), .bg = vcolor(self.bg) };
}
pub fn mutedStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.text_muted), .bg = vcolor(self.bg) };
}
pub fn dimStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.bg) };
}
pub fn statusStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.status_fg), .bg = vcolor(self.status_bg) };
}
pub fn tabStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.tab_fg), .bg = vcolor(self.tab_bg) };
}
pub fn tabActiveStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.tab_active_fg), .bg = vcolor(self.tab_active_bg), .bold = true };
}
pub fn tabDisabledStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.tab_bg) };
}
pub fn inputStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.input_fg), .bg = vcolor(self.input_bg), .bold = true };
}
pub fn inputHintStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.input_hint), .bg = vcolor(self.input_bg) };
}
pub fn selectStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.select_fg), .bg = vcolor(self.select_bg), .bold = true };
}
pub fn positiveStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.positive), .bg = vcolor(self.bg) };
}
pub fn negativeStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.negative), .bg = vcolor(self.bg) };
}
pub fn borderStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.border), .bg = vcolor(self.bg) };
}
pub fn headerStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.accent), .bg = vcolor(self.bg), .bold = true };
}
pub fn watchlistStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.text_dim), .bg = vcolor(self.bg) };
}
pub fn warningStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.warning), .bg = vcolor(self.bg) };
}
/// Map a semantic StyleIntent to a vaxis style.
pub fn styleFor(self: Theme, intent: fmt.StyleIntent) vaxis.Style {
return switch (intent) {
.normal => self.contentStyle(),
.muted => self.mutedStyle(),
.positive => self.positiveStyle(),
.negative => self.negativeStyle(),
.warning => self.warningStyle(),
};
}
pub fn barFillStyle(self: Theme) vaxis.Style {
return .{ .fg = vcolor(self.bar_fill), .bg = vcolor(self.bg) };
}
};
// Monokai-inspired dark theme, influenced by opencode color system.
// Backgrounds are near-black for transparent terminal compatibility.
// Accent colors draw from Monokai's iconic palette: orange, purple, green, pink, yellow, cyan.
pub const default_theme = Theme{
.bg = .{ 0x0a, 0x0a, 0x0a }, // near-black (opencode darkStep1)
.bg_panel = .{ 0x14, 0x14, 0x14 }, // slightly lighter (opencode darkStep2)
.bg_element = .{ 0x1e, 0x1e, 0x1e }, // element bg (opencode darkStep3)
.tab_bg = .{ 0x14, 0x14, 0x14 }, // panel bg
.tab_fg = .{ 0x80, 0x80, 0x80 }, // muted gray
.tab_active_bg = .{ 0xfa, 0xb2, 0x83 }, // warm orange (opencode primary/darkStep9)
.tab_active_fg = .{ 0x0a, 0x0a, 0x0a }, // dark on orange
.text = .{ 0xee, 0xee, 0xee }, // bright text (opencode darkStep12)
.text_muted = .{ 0x80, 0x80, 0x80 }, // muted (opencode darkStep11)
.text_dim = .{ 0x48, 0x48, 0x48 }, // dim (opencode darkStep7)
.status_bg = .{ 0x14, 0x14, 0x14 }, // panel bg
.status_fg = .{ 0x80, 0x80, 0x80 }, // muted gray
.input_bg = .{ 0x28, 0x28, 0x28 }, // subtle element bg
.input_fg = .{ 0xfa, 0xb2, 0x83 }, // warm orange prompt
.input_hint = .{ 0x60, 0x60, 0x60 }, // dim hint
.accent = .{ 0x9d, 0x7c, 0xd8 }, // purple (opencode darkAccent)
.positive = .{ 0x7f, 0xd8, 0x8f }, // green (opencode darkGreen)
.negative = .{ 0xe0, 0x6c, 0x75 }, // red (opencode darkRed)
.warning = .{ 0xe5, 0xc0, 0x7b }, // yellow (opencode darkYellow)
.info = .{ 0x56, 0xb6, 0xc2 }, // cyan (opencode darkCyan)
.select_bg = .{ 0x32, 0x32, 0x32 }, // subtle highlight (opencode darkStep5)
.select_fg = .{ 0xff, 0xc0, 0x9f }, // bright orange (opencode darkStep10)
.border = .{ 0x3c, 0x3c, 0x3c }, // subtle border (opencode darkStep6)
.bar_fill = .{ 0x89, 0xb4, 0xfa }, // blue (bar chart fill, matches CLI accent)
};
// ── SRF serialization ────────────────────────────────────────
const field_names = [_]struct { name: []const u8, offset: usize }{
.{ .name = "bg", .offset = @offsetOf(Theme, "bg") },
.{ .name = "bg_panel", .offset = @offsetOf(Theme, "bg_panel") },
.{ .name = "bg_element", .offset = @offsetOf(Theme, "bg_element") },
.{ .name = "tab_bg", .offset = @offsetOf(Theme, "tab_bg") },
.{ .name = "tab_fg", .offset = @offsetOf(Theme, "tab_fg") },
.{ .name = "tab_active_bg", .offset = @offsetOf(Theme, "tab_active_bg") },
.{ .name = "tab_active_fg", .offset = @offsetOf(Theme, "tab_active_fg") },
.{ .name = "text", .offset = @offsetOf(Theme, "text") },
.{ .name = "text_muted", .offset = @offsetOf(Theme, "text_muted") },
.{ .name = "text_dim", .offset = @offsetOf(Theme, "text_dim") },
.{ .name = "status_bg", .offset = @offsetOf(Theme, "status_bg") },
.{ .name = "status_fg", .offset = @offsetOf(Theme, "status_fg") },
.{ .name = "input_bg", .offset = @offsetOf(Theme, "input_bg") },
.{ .name = "input_fg", .offset = @offsetOf(Theme, "input_fg") },
.{ .name = "input_hint", .offset = @offsetOf(Theme, "input_hint") },
.{ .name = "accent", .offset = @offsetOf(Theme, "accent") },
.{ .name = "positive", .offset = @offsetOf(Theme, "positive") },
.{ .name = "negative", .offset = @offsetOf(Theme, "negative") },
.{ .name = "warning", .offset = @offsetOf(Theme, "warning") },
.{ .name = "info", .offset = @offsetOf(Theme, "info") },
.{ .name = "select_bg", .offset = @offsetOf(Theme, "select_bg") },
.{ .name = "select_fg", .offset = @offsetOf(Theme, "select_fg") },
.{ .name = "border", .offset = @offsetOf(Theme, "border") },
.{ .name = "bar_fill", .offset = @offsetOf(Theme, "bar_fill") },
};
fn colorPtr(theme: *Theme, offset: usize) *Color {
const bytes: [*]u8 = @ptrCast(theme);
return @ptrCast(@alignCast(bytes + offset));
}
fn colorPtrConst(theme: *const Theme, offset: usize) *const Color {
const bytes: [*]const u8 = @ptrCast(theme);
return @ptrCast(@alignCast(bytes + offset));
}
fn formatHex(c: Color) [7]u8 {
var buf: [7]u8 = undefined;
_ = std.fmt.bufPrint(&buf, "#{x:0>2}{x:0>2}{x:0>2}", .{ c[0], c[1], c[2] }) catch {};
return buf;
}
fn parseHex(s: []const u8) ?Color {
const hex = if (s.len > 0 and s[0] == '#') s[1..] else s;
if (hex.len != 6) return null;
const r = std.fmt.parseUnsigned(u8, hex[0..2], 16) catch return null;
const g = std.fmt.parseUnsigned(u8, hex[2..4], 16) catch return null;
const b = std.fmt.parseUnsigned(u8, hex[4..6], 16) catch return null;
return .{ r, g, b };
}
pub fn printDefaults(io: std.Io) !void {
var buf: [4096]u8 = undefined;
var writer = std.Io.File.stdout().writer(io, &buf);
const out = &writer.interface;
try out.writeAll("#!srfv1\n");
try out.writeAll("# zfin TUI theme\n");
try out.writeAll("# This file is the sole source of colors when present.\n");
try out.writeAll("# If removed, built-in defaults (monokai/opencode) are used.\n");
try out.writeAll("# Regenerate: zfin interactive --default-theme > ~/.config/zfin/theme.srf\n");
try out.writeAll("#\n");
try out.writeAll("# All values are hex RGB: #rrggbb\n");
for (field_names) |f| {
const c = colorPtrConst(&default_theme, f.offset);
const hex = formatHex(c.*);
try out.print("{s}::{s}\n", .{ f.name, hex });
}
try out.flush();
}
pub fn loadFromFile(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ?Theme {
const data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(64 * 1024)) catch return null;
defer allocator.free(data);
return loadFromData(data);
}
pub fn loadFromData(data: []const u8) ?Theme {
// Use a stack allocator for parsing -- we don't need to keep parsed data
var arena_buf: [32 * 1024]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&arena_buf);
const alloc = fba.allocator();
var reader = std.Io.Reader.fixed(data);
var it = srf.iterator(&reader, alloc, .{ .alloc_strings = false }) catch return null;
// Don't deinit -- fba owns everything
var theme = default_theme;
while (it.next() catch return null) |fields| {
while (fields.next() catch return null) |field| {
if (field.value) |v| {
const str = switch (v) {
.string => |s| s,
else => continue,
};
const color = parseHex(str) orelse continue;
for (field_names) |f| {
if (std.mem.eql(u8, field.key, f.name)) {
colorPtr(&theme, f.offset).* = color;
break;
}
}
}
}
}
return theme;
}
// ── Tests ────────────────────────────────────────────────────
test "parseHex" {
const c = parseHex("#f8f8f2").?;
try std.testing.expectEqual(@as(u8, 0xf8), c[0]);
try std.testing.expectEqual(@as(u8, 0xf8), c[1]);
try std.testing.expectEqual(@as(u8, 0xf2), c[2]);
}
test "parseHex no hash" {
const c = parseHex("272822").?;
try std.testing.expectEqual(@as(u8, 0x27), c[0]);
}
test "formatHex roundtrip" {
const c = Color{ 0xae, 0x81, 0xff };
const hex = formatHex(c);
const parsed = parseHex(&hex).?;
try std.testing.expectEqual(c[0], parsed[0]);
try std.testing.expectEqual(c[1], parsed[1]);
try std.testing.expectEqual(c[2], parsed[2]);
}
test "loadFromData" {
const data =
\\#!srfv1
\\bg::#ff0000
\\text::#00ff00
;
const t = loadFromData(data).?;
try std.testing.expectEqual(@as(u8, 0xff), t.bg[0]);
try std.testing.expectEqual(@as(u8, 0x00), t.bg[1]);
try std.testing.expectEqual(@as(u8, 0x00), t.text[0]);
try std.testing.expectEqual(@as(u8, 0xff), t.text[1]);
}
test "default theme has valid colors" {
const t = default_theme;
// Background should be dark
try std.testing.expect(t.bg[0] < 0x20);
// Text should be bright
try std.testing.expect(t.text[0] > 0xc0);
}