summaryrefslogtreecommitdiff
path: root/src/colors.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 07:41:35 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit411d0b6059192b75853e220e1242b694ae461a3e (patch)
tree50f8bb207f3cdda497bdd84658a2d3ea74b4bc2b /src/colors.zig
parentda920ca49e3f5d1649e811bbdc3ddba482c6aae4 (diff)
parent5526c68beec454cfe607a579723b97b4848e7d3a (diff)
downloadpardes-411d0b6059192b75853e220e1242b694ae461a3e.tar.gz
pardes-411d0b6059192b75853e220e1242b694ae461a3e.zip
Merge: 9P rounds 9-17 + render DPI, dark tints, G4-G10
Diffstat (limited to 'src/colors.zig')
-rw-r--r--src/colors.zig238
1 files changed, 231 insertions, 7 deletions
diff --git a/src/colors.zig b/src/colors.zig
index 224591d3..8319a112 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -17,12 +17,207 @@ const MAX_PANES = pardes.MAX_PANES;
const BOX_H = pardes.BOX_H;
const Pardes = pardes.Pardes;
+/// WCAG relative luminance, and the contrast of two colours.
+pub fn relativeLuminance(rgb: [3]u8) f32 {
+ var l: [3]f32 = undefined;
+ for (&l, rgb) |*out, v| {
+ const x = @as(f32, @floatFromInt(v)) / 255;
+ out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4);
+ }
+ return 0.2126 * l[0] + 0.7152 * l[1] + 0.0722 * l[2];
+}
+
+pub fn contrast(a: [3]u8, b: [3]u8) f32 {
+ const x = relativeLuminance(a);
+ const y = relativeLuminance(b);
+ return (@max(x, y) + 0.05) / (@min(x, y) + 0.05);
+}
+
+fn lerp(a: [3]u8, b: [3]u8, t: f32) [3]u8 {
+ var out: [3]u8 = undefined;
+ for (&out, a, b) |*o, x, y| o.* = @intFromFloat(std.math.clamp(@round(@as(f32, @floatFromInt(x)) + (@as(f32, @floatFromInt(y)) - @as(f32, @floatFromInt(x))) * t), 0, 255));
+ return out;
+}
+
+/// The rules between panes and columns: acme's contrast, black on white.
+/// A theme's own border, or its scroll track, as long as it stands off the
+/// page by 1.5:1; one that does not (on a near-black page, a dark rule all
+/// but vanishes) is lifted toward the text until it does, to 1.6:1.
+pub const separator_floor: f32 = 1.5;
+/// The page a theme with none (`dark`: the host's own) is judged against:
+/// the GUI's default ground, and a dark terminal's.
+pub const null_page: [3]u8 = .{ 18, 18, 18 };
+fn separatorOf(th: *const Theme) [3]u8 {
+ const base = th.border orelse th.scroll_track;
+ const page = th.bg orelse null_page;
+ if (contrast(base, page) >= separator_floor) return base;
+ const ink = th.fg orelse th.tag_fg;
+ var t: f32 = 0.05;
+ while (t <= 1) : (t += 0.05) {
+ const lifted = lerp(base, ink, t);
+ if (contrast(lifted, page) >= 1.6) return lifted;
+ }
+ return ink;
+}
+
+/// The focused tag's tint, the main focus indicator: a theme's own, pushed
+/// further the way it already leans from the plain tag where the two are
+/// closer than the floor (1.5:1 on a dark page, where a tint is all that
+/// tells the focused tag at a glance; 1.25:1 on a light one), but never so
+/// far its text drops below its own contrast or 4.5. A theme with no tint
+/// (acme: the grip's button says it) keeps none.
+pub const focus_tint_floor: f32 = 1.25;
+pub const focus_tint_floor_dark: f32 = 1.5;
+pub fn focusTintFloor(th: *const Theme) f32 {
+ return if (relativeLuminance(th.bg orelse null_page) < 0.18) focus_tint_floor_dark else focus_tint_floor;
+}
+fn focusTint(th: *const Theme) [3]u8 {
+ const tint = th.tag_active_bg orelse return th.tag_bg;
+ const floor = focusTintFloor(th);
+ if (contrast(tint, th.tag_bg) >= floor) return tint;
+ const ink = th.tag_active_fg orelse th.tag_fg;
+ const text_floor = @min(contrast(ink, tint), 4.5);
+ var best = tint;
+ var k: f32 = 1.1;
+ while (k <= 12) : (k += 0.1) {
+ const pushed = lerp(th.tag_bg, tint, k);
+ if (contrast(ink, pushed) < text_floor) break;
+ best = pushed;
+ if (contrast(pushed, th.tag_bg) >= floor) break;
+ }
+ return best;
+}
+
+test "every native theme's separators stand off its page, and its focus tint off its tag" {
+ for (themes[0..native_count]) |*theme| {
+ const chrome = ChromeTheme.fromTheme(theme);
+ const page = theme.bg orelse null_page;
+ try std.testing.expect(contrast(chrome.border, page) >= separator_floor);
+ // A dark page's focused tag is told by its band too, not the grip
+ // alone; acme's (light, and acme's own) is told by its button.
+ if (relativeLuminance(page) < 0.18) try std.testing.expect(theme.tag_active_bg != null);
+ if (theme.tag_active_bg) |own| {
+ try std.testing.expect(contrast(chrome.tag_active_bg, theme.tag_bg) >= focusTintFloor(theme));
+ // And its text still reads.
+ const ink = theme.tag_active_fg orelse theme.tag_fg;
+ try std.testing.expect(contrast(ink, chrome.tag_active_bg) >= @min(contrast(ink, own), 4.5) - 0.01);
+ }
+ }
+}
+
+test "a derived separator or tint keeps the theme's own when it already stands off" {
+ const forge = &themes[for (themes[0..native_count], 0..) |t, i| {
+ if (std.mem.eql(u8, t.name, "forge")) break i;
+ } else unreachable];
+ const chrome = ChromeTheme.fromTheme(forge);
+ try std.testing.expectEqual(forge.border.?, chrome.border);
+ // forge's own tint is 1.23:1 off its tag: pushed on the way it leans.
+ try std.testing.expect(!std.meta.eql(forge.tag_active_bg.?, chrome.tag_active_bg));
+ for (chrome.tag_active_bg, forge.tag_active_bg.?, forge.tag_bg) |pushed, own, plain|
+ try std.testing.expect(pushed >= own and own >= plain);
+}
+
+/// sRGB bytes to OKLab and back (Björn Ottosson's), and a mix in it.
+fn toOklab(rgb: [3]u8) [3]f32 {
+ var l: [3]f32 = undefined;
+ for (&l, rgb) |*out, v| {
+ const x = @as(f32, @floatFromInt(v)) / 255;
+ out.* = if (x <= 0.04045) x / 12.92 else std.math.pow(f32, (x + 0.055) / 1.055, 2.4);
+ }
+ const lm = std.math.cbrt(0.4122214708 * l[0] + 0.5363325363 * l[1] + 0.0514459929 * l[2]);
+ const mm = std.math.cbrt(0.2119034982 * l[0] + 0.6806995451 * l[1] + 0.1073969566 * l[2]);
+ const sm = std.math.cbrt(0.0883024619 * l[0] + 0.2817188376 * l[1] + 0.6299787005 * l[2]);
+ return .{
+ 0.2104542553 * lm + 0.7936177850 * mm - 0.0040720468 * sm,
+ 1.9779984951 * lm - 2.4285922050 * mm + 0.4505937099 * sm,
+ 0.0259040371 * lm + 0.7827717662 * mm - 0.8086757660 * sm,
+ };
+}
+
+fn fromOklab(lab: [3]f32) [3]u8 {
+ const lm = lab[0] + 0.3963377774 * lab[1] + 0.2158037573 * lab[2];
+ const mm = lab[0] - 0.1055613458 * lab[1] - 0.0638541728 * lab[2];
+ const sm = lab[0] - 0.0894841775 * lab[1] - 1.2914855480 * lab[2];
+ const l = lm * lm * lm;
+ const m = mm * mm * mm;
+ const s = sm * sm * sm;
+ const linear: [3]f32 = .{
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
+ -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s,
+ };
+ var out: [3]u8 = undefined;
+ for (&out, linear) |*o, v| {
+ const x = std.math.clamp(v, 0, 1);
+ const srgb = if (x <= 0.0031308) x * 12.92 else 1.055 * std.math.pow(f32, x, 1.0 / 2.4) - 0.055;
+ o.* = @intFromFloat(@round(std.math.clamp(srgb, 0, 1) * 255));
+ }
+ return out;
+}
+
+pub fn mixOklab(a: [3]u8, b: [3]u8, t: f32) [3]u8 {
+ if (std.mem.eql(u8, &a, &b)) return a;
+ const x = toOklab(a);
+ const y = toOklab(b);
+ return fromOklab(.{ x[0] + (y[0] - x[0]) * t, x[1] + (y[1] - x[1]) * t, x[2] + (y[2] - x[2]) * t });
+}
+
+test "an OKLab fade holds its ends exactly and keeps a mid-way bright" {
+ for ([_][3]u8{ .{ 0, 0, 0 }, .{ 255, 255, 255 }, .{ 0x0c, 0x0c, 0x0e }, .{ 0xe8, 0xc4, 0x6a }, .{ 0x1c, 0x51, 0x72 } }) |rgb| {
+ try std.testing.expectEqual(rgb, fromOklab(toOklab(rgb)));
+ try std.testing.expectEqual(rgb, mixOklab(rgb, .{ 10, 200, 30 }, 0));
+ }
+ // Red to green through sRGB bytes dips dark at the middle; through
+ // OKLab it keeps its lightness.
+ const red: [3]u8 = .{ 255, 0, 0 };
+ const green: [3]u8 = .{ 0, 200, 0 };
+ const srgb_mid = mix(red, green);
+ const lab_mid = mixOklab(red, green, 0.5);
+ try std.testing.expect(relativeLuminance(lab_mid) > relativeLuminance(srgb_mid) * 1.3);
+}
+
pub fn mix(a: [3]u8, b: [3]u8) [3]u8 {
var out: [3]u8 = undefined;
for (&out, a, b) |*c, x, y| c.* = @intCast((@as(u16, x) + y) / 2);
return out;
}
+/// A theme's ornament, drawn by a pixel shell inside the chrome it already
+/// has (docs/render-pipeline.md §10: no gaps between panes): the lapis
+/// theme's. Sizes are logical pixels. None of it touches a focus indicator:
+/// the dots lie on the page's own ground, the checker in the scroll track
+/// (never its thumb), a tag's plaque inside its band beside its grip (and
+/// the focused one only the more striking), and the shadow under a file
+/// name's own letters.
+pub const Decor = struct {
+ /// A dot grid on the page's ground, `page_dot_alpha` of 255 strong, a
+ /// dot one logical pixel across (fading out by a pixel and a half) every
+ /// `page_dot_px`, fixed to the window.
+ page_dots: ?[3]u8 = null,
+ page_dot_alpha: u8 = 0x22,
+ page_dot_px: u8 = 22,
+ /// A checker in the scroll track, squares of `rail_checker_px`, this
+ /// colour and the track's.
+ rail_checker: ?[3]u8 = null,
+ rail_checker_px: u8 = 3,
+ /// Every tag drawn as a raised plaque inside its own band (the site's
+ /// trail and title bars): a `tag_border` frame round the text, a hard
+ /// `tag_shadow` offset down and right by `tag_shadow_px`, both within
+ /// the band's rows and columns, never past them; the focused pane's
+ /// plaque striped `tag_stripe` and the tag's ground, `tag_stripe_px`
+ /// each, its words on plates of the ground. Column and workspace tags
+ /// take a quieter one (a one-pixel frame, a shadow of two, no stripes).
+ tag_border: ?[3]u8 = null,
+ tag_border_px: u8 = 2,
+ tag_shadow: ?[3]u8 = null,
+ tag_shadow_px: u8 = 5,
+ tag_stripe: ?[3]u8 = null,
+ tag_stripe_px: u8 = 2,
+ /// The file name's offset shadow, down and right, in a pane's tag.
+ title_shadow: ?[3]u8 = null,
+ title_shadow_px: u8 = 2,
+};
+
pub const Theme = struct {
name: []const u8,
bg: ?[3]u8,
@@ -88,6 +283,8 @@ pub const Theme = struct {
/// in pixels at a 17px tagline, the thumb one narrower (acme's 12px
/// Scrollwid, the default).
rail_px: ?u8 = null,
+ /// A pixel shell's ornament (Decor): lapis's.
+ decor: ?Decor = null,
};
const curated = struct {
@@ -109,6 +306,7 @@ const curated = struct {
pub const helix = @import("themes/helix.zig");
pub const dark = @import("themes/dark.zig");
pub const acme = @import("themes/acme.zig");
+ pub const lapis = @import("themes/lapis.zig");
};
fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
@@ -119,7 +317,15 @@ fn fold(comptime C: type) [@typeInfo(C).@"struct".decls.len]Theme {
var t: Theme = undefined;
const source = @field(C, d.name).theme;
for (@typeInfo(Theme).@"struct".fields) |f| {
- @field(t, f.name) = if (@hasField(@TypeOf(source), f.name))
+ @field(t, f.name) = if (f.type == ?Decor and @hasField(@TypeOf(source), f.name)) decor: {
+ // A nested literal is folded field by field too.
+ var decor: Decor = .{};
+ const given = @field(source, f.name);
+ for (@typeInfo(Decor).@"struct".fields) |g| if (@hasField(@TypeOf(given), g.name)) {
+ @field(decor, g.name) = @field(given, g.name);
+ };
+ break :decor decor;
+ } else if (@hasField(@TypeOf(source), f.name))
@field(source, f.name)
else if (f.default_value_ptr) |ptr|
@as(*const f.type, @ptrCast(@alignCast(ptr))).*
@@ -187,15 +393,17 @@ pub const ChromeTheme = struct {
scroll_thumb: [3]u8,
pub fn fromTheme(th: *const Theme) ChromeTheme {
+ // The initial chrome is resolved at compile time: contrast takes pow.
+ @setEvalBranchQuota(200_000);
return .{
.tag_bg = th.tag_bg,
.tag_fg = th.tag_fg,
- .tag_active_bg = th.tag_active_bg orelse th.tag_bg,
+ .tag_active_bg = focusTint(th),
.tag_active_fg = th.tag_active_fg orelse th.tag_fg,
.tag_name_fg = th.tag_name_fg orelse th.tag_fg,
.tag_active_name_fg = th.tag_active_name_fg orelse (th.tag_name_fg orelse (th.tag_active_fg orelse th.tag_fg)),
- .border = th.border orelse th.scroll_track,
- .empty_col = th.empty_col orelse (th.border orelse th.scroll_track),
+ .border = separatorOf(th),
+ .empty_col = th.empty_col orelse separatorOf(th),
.search_bg = th.search_bg orelse th.sel_bg,
.search_fg = th.search_fg orelse th.sel_fg,
.diagnostic_error = th.diagnostic_error orelse (th.fg orelse th.tag_fg),
@@ -212,10 +420,14 @@ pub const ChromeTheme = struct {
};
}
+ /// Partway from one chrome to another, each colour through OKLab
+ /// (G10): a fade keeps its lightness even, never going muddy or dark
+ /// between two hues as a mix of sRGB bytes does.
pub fn interpolate(from: ChromeTheme, to: ChromeTheme, step: u16, steps: u16) ChromeTheme {
var out: ChromeTheme = undefined;
+ const t = @as(f32, @floatFromInt(@min(step, steps))) / @as(f32, @floatFromInt(@max(1, steps)));
inline for (@typeInfo(ChromeTheme).@"struct".fields) |field|
- @field(out, field.name) = animation.interpolateRgb(@field(from, field.name), @field(to, field.name), step, steps);
+ @field(out, field.name) = if (step == 0) @field(from, field.name) else if (step >= steps) @field(to, field.name) else mixOklab(@field(from, field.name), @field(to, field.name), t);
return out;
}
};
@@ -655,8 +867,20 @@ test "legacy ThemeFile documents inherit Pardes UI roles without new fields" {
@setEvalBranchQuota(10000);
var encoded: std.Io.Writer.Allocating = .init(std.testing.allocator);
defer encoded.deinit();
- try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = true }, &encoded.writer);
- const source = try std.testing.allocator.dupeZ(u8, encoded.written());
+ try std.zon.stringify.serialize(curated.helix.theme, .{ .whitespace = false }, &encoded.writer);
+ // helix without the focused tag's pair it has since gained: a document
+ // written before those fields existed.
+ var legacy: std.ArrayList(u8) = .empty;
+ defer legacy.deinit(std.testing.allocator);
+ var rest: []const u8 = encoded.written();
+ while (true) {
+ const at = @min(std.mem.indexOf(u8, rest, ".tag_active_bg") orelse rest.len, std.mem.indexOf(u8, rest, ".tag_active_fg") orelse rest.len);
+ try legacy.appendSlice(std.testing.allocator, rest[0..at]);
+ if (at == rest.len) break;
+ rest = rest[at + std.mem.indexOfScalar(u8, rest[at..], '}').? + 1 ..];
+ rest = std.mem.trimStart(u8, rest, " ,");
+ }
+ const source = try std.testing.allocator.dupeZ(u8, legacy.items);
defer std.testing.allocator.free(source);
const parsed = try std.zon.parse.fromSliceAlloc(Theme, std.testing.allocator, source, null, .{});
defer std.zon.parse.free(std.testing.allocator, parsed);