// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! What a configuration file decodes to. //! //! The file is a struthio program — ZON with `let`, `if` and templated //! strings — evaluated against a `Context` that says what the desktop //! prefers and which machine and repository grebe is looking at, so that //! one file can serve them all: //! //! ```zig //! .{ //! .font = .{ .mono = .{ .family = "Iosevka", .size = 11 } }, //! .colors = if (ctx.appearance.color_scheme == .dark) //! .{ .list_background = "#101418" } //! else //! .{}, //! .diff = .{ .context = 5 }, //! .keybind = .{ .{ .keys = "ctrl+r", .action = .reload } }, //! } //! ``` //! //! Every field has a default, so an empty file — or none at all — is a //! working configuration. Unknown fields are an error, with a position. //! The file is evaluated again whenever one of the desktop's settings //! changes, and what it then says of the colors and the fonts is drawn //! with at once. const std = @import("std"); const aerie = @import("aerie"); const theme = @import("../ui/theme.zig"); const Text = @import("../ui/Text.zig"); const Config = @This(); font: Fonts = .{}, /// Colors in place of the palette's, which is light or dark as the /// desktop prefers; see `Colors`. colors: Colors = .{}, /// How a diff is shown at first; the bar above it changes it after. diff: Diff = .{}, /// Key bindings, applied on top of the defaults, which are `gitk`'s. A /// binding for keys that already have one replaces it; `.unbind` removes /// it. keybind: []const Keybind = &.{}, /// The language grebe's own text is in, as a list of language tags in /// order of preference, as `LANGUAGE` takes them: `"de"`, `"pt-BR:pt"`. /// Unset follows `LANGUAGE`, `LC_ALL`, `LC_MESSAGES` and `LANG`. language: ?[]const u8 = null, pub const Fonts = struct { /// The commit list and everything else of the interface. ui: Font = .{}, /// The details and the diff. mono: Font = .{}, }; pub const Font = struct { /// A fontconfig family name. Unset is the desktop's own interface or /// monospace font, as the portal reports it, or else fontconfig's /// `sans-serif` or `monospace`. family: ?[]const u8 = null, /// In points. Unset is the desktop's size for that font, or else 10.5 /// for the interface and 9.75 for monospace. size: ?f32 = null, }; pub const Diff = struct { /// Lines of context around each change. context: u16 = 3, /// Changes only in whitespace are not changes. ignore_whitespace: bool = false, }; /// A color for any of the palette's, by the same name, written `#rrggbb`, /// `#rrggbbaa` or `#rgb`; unset keeps the palette's. `graph` is the colors /// of lines of descent in the graph, in turn, as many as given in place of /// the first of the palette's eight. pub const Colors = blk: { // Every field of the palette, as an optional string: written out by // hand, they would drift apart. const names = @typeInfo(theme.Palette).@"struct".field_names; var types: [names.len]type = undefined; var attrs: [names.len]std.builtin.Type.Struct.FieldAttributes = undefined; for (names, &types, &attrs) |name, *t, *a| { t.* = if (std.mem.eql(u8, name, "graph")) ?[]const []const u8 else ?[]const u8; const none: t.* = null; a.* = .{ .default_value_ptr = @ptrCast(&none) }; } break :blk @Struct(.auto, null, names, &types, &attrs); }; pub const Keybind = struct { /// `ctrl+g`, `shift+page_down`, `/`: modifiers then a key, joined by /// `+`. A key is a character, or the name of one that types none: /// `arrow_up`, `page_down`, `home`, `end`, `enter`, `escape`, `tab`, /// `backspace`, `delete`, `insert`, `space`, `f1` to `f24`, and `plus` /// for the plus key. keys: []const u8, action: Action, }; pub const Action = enum { /// The commit above the selected one, or below it. select_previous, select_next, /// A page of commits up or down. select_page_up, select_page_down, /// The first commit, or the last. select_first, select_last, /// The diff a page down or up. diff_page_down, diff_page_up, /// The find bar, its text selected to type over. find, /// The next commit the find matches below the selected one, or above. find_next, find_previous, /// Reads the history again. reload, /// Copies the text selected in the diff to the clipboard. copy, quit, /// Removes the binding for these keys. unbind, }; /// What a configuration file sees as `ctx`. pub const Context = struct { /// This machine's name. hostname: []const u8, /// `"linux"`, ... os: []const u8, /// The repository grebe is showing: the path of its working tree. repository: []const u8, /// The desktop's preferences, from the XDG desktop portal: the color /// scheme (`.dark`, `.light` or `.no_preference`), the accent color as /// `#rrggbb` or null, the contrast (`.normal` or `.high`), and reduced /// motion (`.reduce` or `.no_preference`). The configuration is /// evaluated again whenever one changes. appearance: aerie.desktop.Appearance = .{}, }; /// The palette for `appearance` with the configuration's colors over it. /// A color that is not one is left as the palette has it, and named in /// `bad`, if there is one. pub fn palette(c: *const Config, appearance: aerie.desktop.Appearance, bad: ?*?[]const u8) theme.Palette { var p = theme.forAppearance(appearance).*; inline for (@typeInfo(theme.Palette).@"struct".field_names) |name| { if (@field(c.colors, name)) |given| { if (comptime std.mem.eql(u8, name, "graph")) { for (given[0..@min(given.len, p.graph.len)], 0..) |s, i| { if (parseColor(s)) |rgba| p.graph[i] = rgba else if (bad) |b| { b.* = name; } } } else if (parseColor(given)) |rgba| { @field(p, name) = rgba; } else if (bad) |b| b.* = name; } } return p; } /// The families to draw with: the configuration's, or the desktop's, or /// fontconfig's defaults. The names point into `c` or `settings`. pub fn families(c: *const Config, settings: *const aerie.desktop.Settings) [2]Text.Spec { const interface = "org.gnome.desktop.interface"; return .{ resolve(c.font.ui, desktopFont(settings, interface, "font-name"), .{ .family = "sans-serif", .points = 10.5 }), resolve(c.font.mono, desktopFont(settings, interface, "monospace-font-name"), .{ .family = "monospace", .points = 9.75 }), }; } const Named = struct { family: []const u8, points: f32 }; fn resolve(f: Font, from_desktop: ?Named, fallback: Named) Text.Spec { const d = from_desktop orelse fallback; return .{ .family = f.family orelse d.family, .px = (f.size orelse d.points) * 96.0 / 72.0 }; } /// A font the desktop names, as GNOME writes one, `"Cantarell 11"`: a /// family, and the size in points after the last space. fn desktopFont(settings: *const aerie.desktop.Settings, namespace: []const u8, key: []const u8) ?Named { const v = settings.get(namespace, key) orelse return null; if (v != .string) return null; return parseFontName(v.string); } fn parseFontName(s: []const u8) ?Named { const trimmed = std.mem.trim(u8, s, " "); const space = std.mem.findScalarLast(u8, trimmed, ' ') orelse return null; const points = std.fmt.parseFloat(f32, trimmed[space + 1 ..]) catch return null; if (!(points > 0 and points < 200)) return null; const family = std.mem.trimEnd(u8, trimmed[0..space], " ,"); if (family.len == 0) return null; return .{ .family = family, .points = points }; } /// `#rrggbb`, `#rrggbbaa` or `#rgb`; null for anything else. pub fn parseColor(s: []const u8) ?theme.Color { if (s.len < 1 or s[0] != '#') return null; const hex = s[1..]; var out: theme.Color = .{ 0, 0, 0, 255 }; switch (hex.len) { 3 => for (hex, 0..) |ch, i| { const v = std.fmt.charToDigit(ch, 16) catch return null; out[i] = v * 17; }, 6, 8 => for (0..hex.len / 2) |i| { out[i] = std.fmt.parseInt(u8, hex[2 * i ..][0..2], 16) catch return null; }, else => return null, } return out; } const testing = std.testing; test "colors" { try testing.expectEqual(theme.Color{ 0x10, 0x20, 0x30, 255 }, parseColor("#102030").?); try testing.expectEqual(theme.Color{ 0x10, 0x20, 0x30, 0x40 }, parseColor("#10203040").?); try testing.expectEqual(theme.Color{ 0xff, 0x00, 0xaa, 255 }, parseColor("#f0a").?); try testing.expectEqual(@as(?theme.Color, null), parseColor("102030")); try testing.expectEqual(@as(?theme.Color, null), parseColor("#10203")); try testing.expectEqual(@as(?theme.Color, null), parseColor("#xyzxyz")); } test "a font as the desktop names it" { const n = parseFontName("Source Code Pro 10.5").?; try testing.expectEqualStrings("Source Code Pro", n.family); try testing.expectEqual(@as(f32, 10.5), n.points); try testing.expectEqual(@as(?Named, null), parseFontName("Cantarell")); try testing.expectEqual(@as(?Named, null), parseFontName(" 11")); } test "the families: the configuration's, the desktop's, or fontconfig's" { var s: aerie.desktop.Settings = .init(testing.allocator); defer s.deinit(); const none: Config = .{}; var f = none.families(&s); try testing.expectEqualStrings("sans-serif", f[0].family); try testing.expectEqual(@as(f32, 14), f[0].px); _ = try s.put("org.gnome.desktop.interface", "font-name", .{ .string = "Cantarell 12" }); f = none.families(&s); try testing.expectEqualStrings("Cantarell", f[0].family); try testing.expectEqual(@as(f32, 16), f[0].px); try testing.expectEqualStrings("monospace", f[1].family); // A family of its own is at the desktop's size, unless it says. const own: Config = .{ .font = .{ .ui = .{ .family = "Inter" }, .mono = .{ .size = 12 } } }; f = own.families(&s); try testing.expectEqualStrings("Inter", f[0].family); try testing.expectEqual(@as(f32, 16), f[0].px); try testing.expectEqual(@as(f32, 16), f[1].px); } test "the configuration's colors over the palette" { const c: Config = .{ .colors = .{ .foreground = "#010203", .graph = &.{ "#ff0000", "nope" }, .divider = "red" } }; var bad: ?[]const u8 = null; const p = c.palette(.{ .color_scheme = .dark }, &bad); try testing.expectEqual(theme.Color{ 1, 2, 3, 255 }, p.foreground); try testing.expectEqual(theme.dark.background, p.background); try testing.expectEqual(theme.Color{ 255, 0, 0, 255 }, p.graph[0]); try testing.expectEqual(theme.dark.graph[1], p.graph[1]); try testing.expectEqual(theme.dark.divider, p.divider); try testing.expect(bad != null); }