From 5526c68beec454cfe607a579723b97b4848e7d3a Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 07:25:32 -0300 Subject: 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/. --- src/animation.zig | 11 +++++++++- src/colors.zig | 65 ++++++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) (limited to 'src') diff --git a/src/animation.zig b/src/animation.zig index 855fef03..713a8a30 100644 --- a/src/animation.zig +++ b/src/animation.zig @@ -1132,6 +1132,9 @@ pub fn Fade(comptime Value: type) type { to: Value, displayed: Value, step: u16 = transition_steps, + /// Set off from partway through another fade: it eases out (moving + /// at once) instead of in and out, so it never stalls (§7.2). + retargeted: bool = false, pub fn init(value: Value) Self { return .{ .from = value, .to = value, .displayed = value }; @@ -1142,19 +1145,25 @@ pub fn Fade(comptime Value: type) type { } pub fn retarget(a: *Self, target: Value) void { + a.retargeted = a.isActive(); a.from = a.displayed; a.to = target; a.step = if (std.meta.eql(a.from, target)) transition_steps else 0; if (a.step == transition_steps) a.displayed = target; } + /// A state change eases in and out (§8.1); a retargeted one out. + const resolution: u16 = 1000; + pub fn advance(a: *Self) void { if (!a.isActive()) return; a.step += 1; + const t = @as(f32, @floatFromInt(a.step)) / @as(f32, @floatFromInt(transition_steps)); + const eased = sample(if (a.retargeted) .out_cubic else .smooth, t); a.displayed = if (a.step == transition_steps) a.to else - Value.interpolate(a.from, a.to, a.step, transition_steps); + Value.interpolate(a.from, a.to, @intFromFloat(@round(eased * resolution)), resolution); } pub fn snap(a: *Self, value: Value) void { 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; } }; -- cgit v1.3