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