src/gui/theme.zig
Ref: Size: 10.0 KiB History
//! Native appearance tokens and indexed terminal palette.
//!
//! A theme owns native defaults and chrome colours. The wire format still
//! carries terminal colours as tagged values; indexed entries use this palette
//! while explicit application RGB colours retain their values.
const std = @import("std");
const config = @import("config.zig");
pub const Overrides = struct {
foreground: ?u32 = null,
background: ?u32 = null,
cursor: ?u32 = null,
palette: [256]?u32 = [_]?u32{null} ** 256,
pub fn any(self: Overrides) bool {
if (self.foreground != null or self.background != null or self.cursor != null) return true;
for (self.palette) |v| if (v != null) return true;
return false;
}
};
pub const WarningFn = *const fn (ctx: ?*anyopaque, line: usize, key: []const u8) void;
pub fn parseTheme(bytes: []const u8, line_out: ?*usize, ctx: ?*anyopaque, callback: ?WarningFn) !Overrides {
var out: Overrides = .{};
var it = std.mem.splitScalar(u8, bytes, '\n');
var line_no: usize = 0;
while (it.next()) |raw| {
line_no += 1;
if (line_out) |p| p.* = line_no;
const line = std.mem.trim(u8, raw, " \t\r");
if (line.len == 0 or line[0] == '#') continue;
const eq = std.mem.indexOfScalar(u8, line, '=') orelse return error.InvalidSyntax;
const key = std.mem.trim(u8, line[0..eq], " \t");
const value = std.mem.trim(u8, line[eq + 1 ..], " \t");
if (key.len == 0) return error.InvalidSyntax;
if (std.mem.eql(u8, key, "background") or std.mem.eql(u8, key, "foreground") or std.mem.eql(u8, key, "cursor-color")) {
const color = config.parseColor(value) catch return error.InvalidValue;
if (std.mem.eql(u8, key, "background")) {
if (out.background != null) return error.InvalidSyntax;
out.background = color;
} else if (std.mem.eql(u8, key, "foreground")) {
if (out.foreground != null) return error.InvalidSyntax;
out.foreground = color;
} else {
if (out.cursor != null) return error.InvalidSyntax;
out.cursor = color;
}
} else if (std.mem.eql(u8, key, "palette")) {
const pair = config.parsePalette(value) catch return error.InvalidValue;
if (out.palette[pair.index] != null) return error.InvalidSyntax;
out.palette[pair.index] = pair.color;
} else {
if (callback) |warn| warn(ctx, line_no, key);
}
}
return out;
}
pub fn load(alloc: std.mem.Allocator, path: []const u8, line_out: ?*usize, ctx: ?*anyopaque, callback: ?WarningFn) !Overrides {
const file = try std.fs.cwd().openFile(path, .{});
defer file.close();
const bytes = try file.readToEndAlloc(alloc, 64 * 1024);
defer alloc.free(bytes);
return parseTheme(bytes, line_out, ctx, callback);
}
pub fn merge(base: Theme, theme: Overrides, explicit: Overrides) Theme {
var out = base;
var colors = theme;
if (explicit.foreground) |v| colors.foreground = v;
if (explicit.background) |v| colors.background = v;
if (explicit.cursor) |v| colors.cursor = v;
for (explicit.palette, 0..) |v, i| {
if (v) |c| colors.palette[i] = c;
}
if (!colors.any()) return out;
if (colors.foreground) |v| out.terminal_fg = v;
if (colors.background) |v| out.terminal_bg = v;
out.cursor = colors.cursor orelse out.terminal_fg;
for (colors.palette, 0..) |v, i| {
if (v) |c| out.palette[i] = c;
}
const bg = out.terminal_bg;
const fg = out.terminal_fg;
const focus = out.palette[6];
const bell = out.palette[3];
out.chrome_focus_bg = focus;
out.chrome_focus_fg = contrast(focus);
out.chrome_unfocused_bg = blend(bg, fg, 20);
out.chrome_unfocused_fg = fg;
out.bell_header_bg = bell;
out.bell_header_fg = contrast(bell);
out.modal_bg = blend(bg, fg, 12);
out.modal_fg = fg;
out.modal_selected_bg = blend(focus, bg, 60);
out.modal_selected_fg = contrast(out.modal_selected_bg);
out.divider = blend(bg, fg, 45);
return out;
}
fn channels(c: u32) [3]u8 {
return .{ @truncate(c >> 24), @truncate(c >> 16), @truncate(c >> 8) };
}
fn pack(c: [3]u8) u32 {
return (@as(u32, c[0]) << 24) | (@as(u32, c[1]) << 16) | (@as(u32, c[2]) << 8) | 0xff;
}
fn blend(a: u32, b: u32, amount: u8) u32 {
const x = channels(a);
const y = channels(b);
return pack(.{ @intCast((@as(u16, x[0]) * (100 - amount) + @as(u16, y[0]) * amount) / 100), @intCast((@as(u16, x[1]) * (100 - amount) + @as(u16, y[1]) * amount) / 100), @intCast((@as(u16, x[2]) * (100 - amount) + @as(u16, y[2]) * amount) / 100) });
}
fn contrast(c: u32) u32 {
const x = channels(c);
const lum = linear(@as(f64, @floatFromInt(x[0])) / 255.0) * 0.2126 +
linear(@as(f64, @floatFromInt(x[1])) / 255.0) * 0.7152 +
linear(@as(f64, @floatFromInt(x[2])) / 255.0) * 0.0722;
const black_ratio = (lum + 0.05) / 0.05;
const white_ratio = 1.05 / (lum + 0.05);
return if (white_ratio >= black_ratio) 0xffffffff else 0x000000ff;
}
fn linear(v: f64) f64 {
return if (v <= 0.04045) v / 12.92 else std.math.pow(f64, (v + 0.055) / 1.055, 2.4);
}
pub const Theme = struct {
terminal_fg: u32,
terminal_bg: u32,
cursor: u32,
palette: [256]u32,
chrome_focus_fg: u32,
chrome_focus_bg: u32,
chrome_unfocused_fg: u32,
chrome_unfocused_bg: u32,
bell_header_fg: u32,
bell_header_bg: u32,
modal_fg: u32,
modal_bg: u32,
modal_selected_fg: u32,
modal_selected_bg: u32,
divider: u32,
};
fn rgb(r: u8, g: u8, b: u8) u32 {
return (@as(u32, r) << 24) | (@as(u32, g) << 16) | (@as(u32, b) << 8) | 0xff;
}
fn generatedPalette(ansi: [16]u32) [256]u32 {
var out: [256]u32 = undefined;
@memcpy(out[0..16], ansi[0..]);
for (16..232) |i| {
const n = i - 16;
const steps = [6]u8{ 0, 0x5f, 0x87, 0xaf, 0xd7, 0xff };
out[i] = rgb(steps[n / 36], steps[(n / 6) % 6], steps[n % 6]);
}
for (232..256) |i| {
const grey: u8 = @intCast(8 + 10 * (i - 232));
out[i] = rgb(grey, grey, grey);
}
return out;
}
const legacy_ansi = [16]u32{
0x000000ff, 0xcc0000ff, 0x4e9a06ff, 0xc4a000ff,
0x0000eeff, 0x75507bff, 0x06989aff, 0xd3d7cfff,
0x555753ff, 0xef2929ff, 0x8ae234ff, 0xfce94fff,
0x729fcfff, 0xad7fa8ff, 0x34e2e2ff, 0xeeeeecff,
};
/// Original no-config appearance, including its unchanged chrome.
pub const legacy: Theme = .{
.terminal_fg = 0xd0d0d0ff,
.terminal_bg = 0x101010ff,
.cursor = 0xd0d0d0ff,
.palette = generatedPalette(legacy_ansi),
.chrome_focus_fg = 0xd0d0d0ff,
.chrome_focus_bg = 0x304860ff,
.chrome_unfocused_fg = 0xd0d0d0ff,
.chrome_unfocused_bg = 0x24282cff,
.bell_header_fg = 0xd0d0d0ff,
.bell_header_bg = 0x705020ff,
.modal_fg = 0xd0d0d0ff,
.modal_bg = 0x1c2632ff,
.modal_selected_fg = 0xd0d0d0ff,
.modal_selected_bg = 0x435e75ff,
.divider = 0x687888ff,
};
const trial_ansi = [16]u32{
0x211b1aff, 0xd46a5aff, 0x8ebf7aff, 0xd8a84eff,
0x7aa2c8ff, 0xb28abfff, 0x62b8a9ff, 0xf3e8d0ff,
0x665954ff, 0xef8b70ff, 0xb4d98fff, 0xf0c878ff,
0xa8cbe8ff, 0xd1a9d1ff, 0x8cddd0ff, 0xfff4dfff,
};
/// Explicit integration fixture for the original appearance acceptance.
pub const trial: Theme = .{
.terminal_fg = 0xf3e8d0ff,
.terminal_bg = 0x211b1aff,
.cursor = 0xd8a84eff,
.palette = generatedPalette(trial_ansi),
.chrome_focus_fg = 0xfff4dfff,
.chrome_focus_bg = 0x2f6f70ff,
.chrome_unfocused_fg = 0xd7c2aaff,
.chrome_unfocused_bg = 0x342824ff,
.bell_header_fg = 0xfff4dfff,
.bell_header_bg = 0x9a5c24ff,
.modal_fg = 0xf3e8d0ff,
.modal_bg = 0x2b211fff,
.modal_selected_fg = 0xfff4dfff,
.modal_selected_bg = 0x496f68ff,
.divider = 0x76594bff,
};
test "legacy and trial retain complete generated indexed palettes" {
try std.testing.expectEqual(@as(u32, 0xcc0000ff), legacy.palette[1]);
try std.testing.expectEqual(@as(u32, 0x00005fff), legacy.palette[17]);
try std.testing.expectEqual(@as(u32, 0x080808ff), legacy.palette[232]);
try std.testing.expectEqual(@as(u32, 0xd46a5aff), trial.palette[1]);
try std.testing.expect(trial.palette[16] != legacy.palette[16] or trial.palette[1] != legacy.palette[1]);
}
test "custom appearance merges sparse colors and derives chrome without losing defaults" {
const unchanged = merge(legacy, .{}, .{});
try std.testing.expectEqualDeep(legacy, unchanged);
var chosen: Overrides = .{ .background = 0xeff1f5ff, .foreground = 0x4c4f69ff };
chosen.palette[6] = 0x179299ff;
chosen.palette[200] = 0x112233ff;
const appearance = merge(legacy, chosen, .{ .foreground = 0x263340ff });
try std.testing.expectEqual(@as(u32, 0x263340ff), appearance.cursor);
try std.testing.expectEqual(@as(u32, 0xeff1f5ff), appearance.terminal_bg);
try std.testing.expectEqual(@as(u32, 0x112233ff), appearance.palette[200]);
try std.testing.expectEqual(legacy.palette[17], appearance.palette[17]);
try std.testing.expectEqual(@as(u32, 0x000000ff), appearance.chrome_focus_fg);
try std.testing.expectEqual(@as(u32, 0xffffffff), contrast(0x444444ff));
try std.testing.expectEqual(@as(u32, 0x000000ff), contrast(0x999999ff));
const cursor_override = merge(legacy, .{ .cursor = 0xabcdefFF }, .{ .foreground = 0x123456ff });
try std.testing.expectEqual(@as(u32, 0xabcdefff), cursor_override.cursor);
}
test "theme parser preserves line diagnostics and ignores unsupported reset keys" {
var line: usize = 0;
const parsed = try parseTheme("font-family =\nforeground = #112233\n", &line, null, null);
try std.testing.expectEqual(@as(?u32, 0x112233ff), parsed.foreground);
try std.testing.expectError(error.InvalidValue, parseTheme("# heading\npalette = 256=abcdef\n", &line, null, null));
try std.testing.expectEqual(@as(usize, 2), line);
try std.testing.expectError(error.InvalidSyntax, parseTheme("background=abcdef\nbackground=112233\n", &line, null, null));
try std.testing.expectEqual(@as(usize, 2), line);
}