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); }