a73x

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