diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/animation.zig | 11 | ||||
| -rw-r--r-- | src/colors.zig | 65 |
2 files changed, 74 insertions, 2 deletions
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; } }; |
