Something went wrong. Try again.
A history browser for git, a replacement for gitk, drawn with Vulkan on Wayland.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// 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);}