Something went wrong. Try again.
A rainbow login banner drawn in a real font and shown with the kitty graphics protocol, written in Zig.
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie <jeff@ocjtech.us>// SPDX-License-Identifier: MIT
//! The rainbow: hue swept diagonally across the banner through OKLCH, at a//! lightness and chroma held constant so that no part of the sweep looks//! brighter than the rest, as a sweep through HSV's yellow and cyan does.//!//! OKLCH is Ottosson's Oklab in polar form. Not every hue reaches the same//! chroma inside sRGB, so a color the screen cannot show has its chroma//! reduced until it can, keeping its lightness and hue.
const std = @import("std");const z2d = @import("z2d");
/// Where the sweep sits in Oklab: light enough to read on a dark/// background, and as saturated as most hues can go at that lightness.pub const lightness: f32 = 0.78;pub const chroma: f32 = 0.16;
pub const Options = struct { /// The direction the hue advances in, in degrees counterclockwise from /// left to right, so 45 runs from the bottom left to the top right. angle: f32 = 45, /// How many times the hue goes round the whole spectrum across the /// banner. cycles: f32 = 1, /// The hue, in degrees, at the start of the sweep. start: f32 = 0,};
/// A straight (not premultiplied) sRGB color, each channel 0 to 255.pub const Rgb = struct { r: u8, g: u8, b: u8 };
/// Recolors every pixel of `sfc` with the rainbow, keeping its alpha: the/// glyphs' coverage decides where the banner is, and the rainbow alone/// decides its color.pub fn paint(sfc: *z2d.Surface, options: Options) void { const w = sfc.getWidth(); const h = sfc.getHeight(); const pixels = sfc.image_surface_rgba.buf;
// The hue at a pixel is where the pixel falls along the sweep's // direction, as a fraction of how far the banner reaches along it. y is // down on the surface, so it is negated to make the angle // counterclockwise. const rad = std.math.degreesToRadians(options.angle); const dx = @cos(rad); const dy = -@sin(rad); const fw: f32 = @floatFromInt(w - 1); const fh: f32 = @floatFromInt(h - 1); const corners = [_]f32{ 0, fw * dx, fh * dy, fw * dx + fh * dy }; const lo = std.mem.min(f32, &corners); const hi = std.mem.max(f32, &corners); const span = if (hi - lo > 0) hi - lo else 1;
var table: [hue_steps]Rgb = undefined; for (&table, 0..) |*c, i| c.* = hueColor(@as(f32, @floatFromInt(i)) * 360 / hue_steps);
var y: i32 = 0; while (y < h) : (y += 1) { var x: i32 = 0; while (x < w) : (x += 1) { const i: usize = @intCast(y * w + x); const a = pixels[i].a; if (a == 0) { pixels[i] = .{ .r = 0, .g = 0, .b = 0, .a = 0 }; continue; } const along = (@as(f32, @floatFromInt(x)) * dx + @as(f32, @floatFromInt(y)) * dy - lo) / span; const hue = options.start + along * options.cycles * 360; const c = table[hueIndex(hue)]; pixels[i] = .{ .r = premultiply(c.r, a), .g = premultiply(c.g, a), .b = premultiply(c.b, a), .a = a, }; } }}
/// Fine enough that neighbouring entries differ by less than one step in/// any channel.const hue_steps = 1440;
fn hueIndex(hue: f32) usize { const wrapped = @mod(hue, 360); const i: usize = @intFromFloat(@round(wrapped * hue_steps / 360)); return i % hue_steps;}
fn premultiply(c: u8, a: u8) u8 { return @intCast((@as(u16, c) * a + 127) / 255);}
/// The sweep's color at `hue` degrees.pub fn hueColor(hue: f32) Rgb { return oklch(lightness, chroma, hue);}
/// An OKLCH color in sRGB, with its chroma reduced as far as it must be to/// fit.pub fn oklch(l: f32, c: f32, hue: f32) Rgb { const rad = std.math.degreesToRadians(hue); const cos_h = @cos(rad); const sin_h = @sin(rad); var rgb = oklabToLinear(l, c * cos_h, c * sin_h); if (!inGamut(rgb)) { // Bisect the chroma: the largest that is still in gamut. var in: f32 = 0; var out: f32 = c; for (0..24) |_| { const mid = (in + out) / 2; if (inGamut(oklabToLinear(l, mid * cos_h, mid * sin_h))) in = mid else out = mid; } rgb = oklabToLinear(l, in * cos_h, in * sin_h); } return .{ .r = encode(rgb[0]), .g = encode(rgb[1]), .b = encode(rgb[2]) };}
/// Oklab to linear sRGB, by Ottosson's matrices.fn oklabToLinear(l: f32, a: f32, b: f32) [3]f32 { const l_ = l + 0.3963377774 * a + 0.2158037573 * b; const m_ = l - 0.1055613458 * a - 0.0638541728 * b; const s_ = l - 0.0894841775 * a - 1.2914855480 * b; const ll = l_ * l_ * l_; const mm = m_ * m_ * m_; const ss = s_ * s_ * s_; return .{ 4.0767416621 * ll - 3.3077115913 * mm + 0.2309699292 * ss, -1.2684380046 * ll + 2.6097574011 * mm - 0.3413193965 * ss, -0.0041960863 * ll - 0.7034186147 * mm + 1.7076147010 * ss, };}
fn inGamut(rgb: [3]f32) bool { const eps = 1e-4; for (rgb) |v| if (v < -eps or v > 1 + eps) return false; return true;}
/// Linear light to an 8-bit sRGB channel, by the sRGB transfer function.fn encode(v: f32) u8 { const c = std.math.clamp(v, 0, 1); const s = if (c <= 0.0031308) 12.92 * c else 1.055 * std.math.pow(f32, c, 1.0 / 2.4) - 0.055; return @intFromFloat(@round(s * 255));}
const testing = std.testing;
test "oklch of white and black" { try testing.expectEqual(Rgb{ .r = 255, .g = 255, .b = 255 }, oklch(1, 0, 0)); try testing.expectEqual(Rgb{ .r = 0, .g = 0, .b = 0 }, oklch(0, 0, 0));}
test "oklch matches a known color" { // CSS Color 4's example: oklch(62.8% 0.2577 29.23) is sRGB red. const red = oklch(0.628, 0.2577, 29.23); try testing.expect(red.r >= 254); try testing.expect(red.g <= 2); try testing.expect(red.b <= 2);}
test "every hue of the sweep is in gamut and of even lightness" { var i: usize = 0; while (i < 360) : (i += 5) { const c = hueColor(@floatFromInt(i)); // Back to linear light, then to Oklab's L, which must be where the // sweep put it: reducing chroma never moves the lightness. const lin = [3]f32{ decode(c.r), decode(c.g), decode(c.b) }; const l = 0.4122214708 * lin[0] + 0.5363325363 * lin[1] + 0.0514459929 * lin[2]; const m = 0.2119034982 * lin[0] + 0.6806995451 * lin[1] + 0.1073969566 * lin[2]; const s = 0.0883024619 * lin[0] + 0.2817188376 * lin[1] + 0.6299787005 * lin[2]; const big_l = 0.2104542553 * std.math.cbrt(l) + 0.7936177850 * std.math.cbrt(m) - 0.0040720468 * std.math.cbrt(s); try testing.expectApproxEqAbs(lightness, big_l, 0.01); }}
fn decode(c: u8) f32 { const s = @as(f32, @floatFromInt(c)) / 255; return if (s <= 0.04045) s / 12.92 else std.math.pow(f32, (s + 0.055) / 1.055, 2.4);}
test "paint keeps coverage and colors by position" { const gpa = testing.allocator; var sfc = try z2d.Surface.init(.image_surface_rgba, gpa, 4, 1); defer sfc.deinit(gpa); const buf = sfc.image_surface_rgba.buf; buf[0] = .{ .r = 255, .g = 255, .b = 255, .a = 255 }; buf[1] = .{ .r = 0, .g = 0, .b = 0, .a = 0 }; buf[2] = .{ .r = 128, .g = 128, .b = 128, .a = 128 }; buf[3] = .{ .r = 255, .g = 255, .b = 255, .a = 255 }; // Horizontal, half a cycle: the first pixel is hue 0, the last hue 180. paint(&sfc, .{ .angle = 0, .cycles = 0.5, .start = 0 }); const first = hueColor(0); const last = hueColor(180); try testing.expectEqual(@as(u8, 255), buf[0].a); try testing.expectEqual(first.r, buf[0].r); try testing.expectEqual(@as(u8, 0), buf[1].a); try testing.expectEqual(@as(u8, 128), buf[2].a); try testing.expect(buf[2].r <= 128 and buf[2].g <= 128 and buf[2].b <= 128); try testing.expectEqual(last.g, buf[3].g);}