summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--docs/effects.md10
-rw-r--r--src/animation.zig11
-rw-r--r--src/colors.zig65
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;
}
};