diff options
Diffstat (limited to 'src/colors.zig')
| -rw-r--r-- | src/colors.zig | 124 |
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); |
