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%
334 lines
12 KiB
Zig
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);
|
|
}
|