remove hand maintenance of theme data, use comptime introspection

This commit is contained in:
Emil Lerch 2026-09-24 08:32:02 -07:00
parent 433b83e78f
commit a2c8d478b6
Signed by: lobo
GPG key ID: A7B62D657EF764F8

View file

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