diff options
| -rw-r--r-- | docs/effects.md | 10 | ||||
| -rw-r--r-- | src/animation.zig | 11 | ||||
| -rw-r--r-- | src/colors.zig | 65 |
3 files changed, 84 insertions, 2 deletions
diff --git a/docs/effects.md b/docs/effects.md index 61b969c2..0e0ad51a 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -43,6 +43,7 @@ why. The verdict is the user's. | G7 panel transitions | `PanelSlide`, `PanelZoom`, `PanelVertical`, `PanelDissolve` (off) with `Motion` | a moving pane casts a shadow; a retargeted move keeps its speed; the GUI's dissolve is a crossfade | .scratch/render/transitions/ (slide smooth, bouncy, retargeted; dissolve) | pending | | G8 notice chips | follows `Motion` and `Lift` | a notice floats in tier 4 on a lift (shadow) while Lift is on, drops in easing out and dissolves easing in, quicker than it came (150 ms against 180) | .scratch/render/notices/ (acme, forge) | pending | | G9 selection glow | `SelectionGlow on\|off` (off) | a soft halo of the selection's colour round each block of it in a body, fading in over 100 ms | .scratch/render/selection/ (forge, acme, lapis; lapis-glow-zoom.png) | pending | +| G10 theme crossfade | always (a theme change) | the chrome's colours fade through OKLab, easing in and out (out when retargeted) | .scratch/render/crossfade/ (forge, lapis, acme cut short by dusk) | pending | ## Motion flavours @@ -197,3 +198,12 @@ below min(its contrast, 4.5); it comes up over 100 ms with an ease-out panes move. The blocks are read from the cells painted in the theme's selection colour, a pixel shell's inference (§1.5, smell 4) until selections are regions. + +Notes on G10: a theme change already faded the chrome (tags, rules, grips, +rails) over 10 frames (160 ms, in §8.1's 150 to 250 for a state change), +mixing sRGB bytes linearly in time. Now each colour goes through OKLab, so +a fade between two hues keeps its lightness instead of dipping dark and +muddy, on a smooth ease-in-out; a change made mid-fade sets off from where +it shows and eases out, so it moves at once instead of stalling. The page, +its text and the syntax colours still change at once with the theme, as +before: fading them would re-resolve every cell each frame. 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; } }; |
