summaryrefslogtreecommitdiff
path: root/src/colors.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-28 23:50:56 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commitfde030a699b3d6667d1fa21ae192b5011e3f373f (patch)
treeaae9bd94e7889cea53c82e1775290adcd94cd7e7 /src/colors.zig
parent34b631e75dd646809961cae0ab215b6c43643a13 (diff)
downloadpardes-fde030a699b3d6667d1fa21ae192b5011e3f373f.tar.gz
pardes-fde030a699b3d6667d1fa21ae192b5011e3f373f.zip
Rules and chrome scale with the display's density; dark pages get visible separators and focus tints
Density: dp() in gui.zig draws every size given in pixels in logical pixels (SDL's window display scale, rounded, at least one), as acme scales Border, ButtonBorder and Scrollgap: the rules (rule_px), the tag rule, the grip's ring, its dirty gap and mode bars, the rail's gap to the text and its minimum, the thumb's inset, the rim, a bar cursor's minimum width, underline and strikethrough thickness (the atlas is dropped on a scale change), the rule-grab margin, and the Crt's scanline and mask periods (pardesScale in the post prefix). Cell- and tagline-measured sizes stay: the rail's width, Lift's blur and offsets, every motion; iResolution, fragCoord and iMouse stay in the display's pixels, as ghostty's. PARDES_TEST_SCALE forces a density in a harness (a test window is 1x otherwise, whatever display it lands on) and sizes the test font with it. Colours: a separator that stands off its page by less than 1.5:1 is lifted toward the text to 1.6:1 (helix, atelier, paper; a theme with no page, dark, is judged against #121212), and empty_col follows it. forge's own rule already stands at 3.83:1; what hid it was density: 2 device pixels on a 3x screen, now 6 (dp). A focused tag's tint closer than 1.5:1 to the plain tag on a dark page (1.25:1 on a light one) is pushed further the way it leans, as far as its text keeps its own contrast or 4.5: forge 1.23 -> 1.51 (#2d3440 -> #374253), and likewise dusk, orchard, ink, lagoon, clay, harvest, spectrum, solarium, forge_soft; daybreak, atelier, paper to 1.25. dark and helix gain a tint of their own (dark: #484566 under #c8c8c8, 1.54:1; helix: ramp 237 under 250, 1.65:1). Tag caret: past a full row's end, the G3 glide box sits on the tag's right edge, where the layer's caret and the pixel shell's are. Tests: every native theme's separator >= 1.5 and every dark one has a tint >= 1.25 with readable text; forge keeps its own border; the tag caret glide box at a full row; at 2x the spine, tag rule, ring, rail start and gap, and bar cursor double. Legacy-ThemeFile test strips helix's new pair. Goldens: snapshot goldens change in colour rows only, the tints (orchard #283a2f -> #314738 at boot, forge, helix and dark in theme/themesel). GUI goldens: the harness's single Del of the renamed wide pane is refused since ysqnkyoy (a rename counted as unsaved; fixed on the 9P line in wkzlsqvy), so scenes 09-17 were recorded with that pane open and the file was stale since the merge (08 and 18 also moved with ykvnltvs's full-row tag wrap). The goldens here were recorded with that pane closed (as wkzlsqvy will close it); against the parent's binary under the same conditions they differ only in the tints: 01-13 forge's (#2d3440 -> #374253, and the glyph edges on it), 14 ink's; 15-18 are byte-identical. Until wkzlsqvy is merged, a run here fails 09-17. Each commit reproduces its own recorded goldens on this machine (checked at kvtmqkyn). A test window is 1x unless PARDES_TEST_SCALE says otherwise, whatever display it opens on. Prebuilt shaders refreshed from their sources. Captures: .scratch/render/dark-borders/{before,after,compare,scale2,scale3}.
Diffstat (limited to 'src/colors.zig')
-rw-r--r--src/colors.zig124
1 files changed, 119 insertions, 5 deletions
diff --git a/src/colors.zig b/src/colors.zig
index 224591d3..4a18bba9 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -17,6 +17,106 @@ 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);
+}
+
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);
@@ -187,15 +287,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),
@@ -655,8 +757,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);