diff --git a/src/tui/theme.zig b/src/tui/theme.zig index 152c3ba..e1704b8 100644 --- a/src/tui/theme.zig +++ b/src/tui/theme.zig @@ -197,56 +197,17 @@ pub const default_theme = Theme{ // ── 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") }, -}; - /// Schema contract for `theme.srf`. See `srf_lint.validateSchema`. /// -/// `Record = Theme` rather than a parallel name list: `loadFromData` -/// matches keys against the hand-maintained `field_names` table above, -/// but the authoritative set is `std.meta.fields(Theme)`, and deriving -/// from the struct means the lint cannot disagree with the type even if -/// `field_names` falls behind it. +/// `Record = Theme`: the loader, `printDefaults` and this lint all take +/// the key set from `std.meta.fields(Theme)`, so a new color field is +/// loadable, printed and linted with no second list to update. pub const srf_schema = struct { pub const Record = Theme; pub const file_label: []const u8 = "theme.srf"; pub const doc_path: []const u8 = "reference/config/theme-srf.md"; }; -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 @@ -276,9 +237,10 @@ pub fn printDefaults(io: std.Io) !void { 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.*); + // Declaration order of `Theme`, which is also the order the file + // has always been written in. + inline for (std.meta.fields(Theme)) |f| { + const hex = formatHex(@field(default_theme, f.name)); try out.print("{s}::{s}\n", .{ f.name, hex }); } @@ -311,11 +273,10 @@ pub fn loadFromData(data: []const u8) ?Theme { 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; - } + // Unknown keys fall through untouched; `zfin doctor` + // reports them via `srf_schema`. + inline for (std.meta.fields(Theme)) |f| { + if (std.mem.eql(u8, field.key, f.name)) @field(theme, f.name) = color; } } } @@ -367,3 +328,26 @@ test "default theme has valid colors" { // Text should be bright try std.testing.expect(t.text[0] > 0xc0); } + +test "loadFromData: every Theme field is loadable by its own name" { + // Replaces the old hand-maintained name table: a field added to + // `Theme` must load from theme.srf with no second list to update. + // Each field gets a distinct color, so a key that landed on the + // wrong field would show up here too. + var aw: std.Io.Writer.Allocating = .init(std.testing.allocator); + defer aw.deinit(); + try aw.writer.writeAll("#!srfv1\n"); + inline for (std.meta.fields(Theme), 0..) |f, i| { + try aw.writer.print("{s}::#0101{x:0>2}\n", .{ f.name, @as(u8, @intCast(i)) }); + } + const t = loadFromData(aw.written()) orelse return error.TestUnexpectedResult; + inline for (std.meta.fields(Theme), 0..) |f, i| { + try std.testing.expectEqual(Color{ 0x01, 0x01, @intCast(i) }, @field(t, f.name)); + } +} + +test "loadFromData: unknown keys are ignored, not applied to a neighbor" { + const t = loadFromData("#!srfv1\nbg_panle::#123456\n") orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(default_theme.bg_panel, t.bg_panel); + try std.testing.expectEqual(default_theme.bg, t.bg); +}