summaryrefslogtreecommitdiff
path: root/src/colors.zig
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-29 07:25:32 -0300
committerGabriel Schneider <[email protected]>2026-10-01 00:12:16 -0300
commit5526c68beec454cfe607a579723b97b4848e7d3a (patch)
treeffc111103c9b086346a2dae4148a61fe3d368780 /src/colors.zig
parent99c088e277e7d79d7b4341d3f546c02e7254e107 (diff)
downloadpardes-5526c68beec454cfe607a579723b97b4848e7d3a.tar.gz
pardes-5526c68beec454cfe607a579723b97b4848e7d3a.zip
G10: the theme's chrome fades through OKLab, easing in and out
ChromeTheme.interpolate mixes each colour in OKLab (colors.mixOklab), so a fade between hues keeps its lightness instead of going dark and muddy mid way; animation.Fade eases it in and out (smooth), and one retargeted mid fade eases out from where it shows, so it never stalls. Still 10 frames (160 ms). The page, text and syntax colours change at once as before. Tests: OKLab round-trips its ends exactly and keeps a red-to-green mid-way brighter than the sRGB mix. Gates as G9 (no golden moved). Feel review: .scratch/render/crossfade/.
Diffstat (limited to 'src/colors.zig')
-rw-r--r--src/colors.zig65
1 files changed, 64 insertions, 1 deletions
diff --git a/src/colors.zig b/src/colors.zig
index b44e84f9..8319a112 100644
--- a/src/colors.zig
+++ b/src/colors.zig
@@ -117,6 +117,65 @@ test "a derived separator or tint keeps the theme's own when it already stands o
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);
@@ -361,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;
}
};