terminal/kitty: switch to wuffs for pixel work

This commit is contained in:
Mitchell Hashimoto
2026-08-21 06:54:27 -07:00
parent f3e98fb72b
commit 322d7ae789
4 changed files with 105 additions and 101 deletions

View File

@@ -55,6 +55,35 @@ pub fn bgraToRgba(alloc: Allocator, src: []const u8) Error![]u8 {
);
}
/// Composite `src` over `dst` in place. Both are straight
/// (non-premultiplied) alpha RGBA of the same length. A transparent
/// destination pixel takes the source pixel exactly; wuffs composites
/// everything else in 16-bit integer space.
pub fn rgbaSrcOver(dst: []u8, src: []const u8) void {
assert(dst.len == src.len);
assert(dst.len % 4 == 0);
var swizzler: c.wuffs_base__pixel_swizzler = undefined;
const status = c.wuffs_base__pixel_swizzler__prepare(
&swizzler,
c.wuffs_base__make_pixel_format(c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL),
c.wuffs_base__empty_slice_u8(),
c.wuffs_base__make_pixel_format(c.WUFFS_BASE__PIXEL_FORMAT__RGBA_NONPREMUL),
c.wuffs_base__empty_slice_u8(),
c.WUFFS_BASE__PIXEL_BLEND__SRC_OVER,
);
// This format pair and blend mode is a supported swizzle, so
// preparation can only fail on a programming error.
assert(c.wuffs_base__status__is_ok(&status));
_ = c.wuffs_base__pixel_swizzler__swizzle_interleaved_from_slice(
&swizzler,
c.wuffs_base__make_slice_u8(dst.ptr, dst.len),
c.wuffs_base__empty_slice_u8(),
c.wuffs_base__make_slice_u8(@constCast(src.ptr), src.len),
);
}
test "gaToRgba" {
const rgba = try gaToRgba(std.testing.allocator, &.{ 7, 100, 8, 200 });
defer std.testing.allocator.free(rgba);
@@ -65,6 +94,30 @@ test "gaToRgba" {
}, rgba);
}
test "rgbaSrcOver" {
// 50% white over opaque black, opaque over anything, transparent
// source over anything, and anything over a transparent
// destination (exact source passthrough).
var dst = [_]u8{
0, 0, 0, 255,
10, 20, 30, 40,
10, 20, 30, 40,
0, 0, 0, 0,
};
rgbaSrcOver(&dst, &.{
255, 255, 255, 128,
100, 110, 120, 255,
100, 110, 120, 0,
200, 100, 50, 128,
});
try std.testing.expectEqualSlices(u8, &.{
128, 128, 128, 255,
100, 110, 120, 255,
10, 20, 30, 40,
200, 100, 50, 128,
}, &dst);
}
fn swizzle(
alloc: Allocator,
src: []const u8,

View File

@@ -137,13 +137,18 @@ fn initVt(
// We need uucode for grapheme break support
vt.addImport("uucode", deps.uucode_mod);
// We need for Kitty graphics. If Kitty graphics is disabled then
// z2d isn't referenced and it produces no code, so its safe.
if (b.lazyDependency("z2d", .{
.target = cfg.target,
.optimize = cfg.optimize,
})) |dep| {
vt.addImport("z2d", dep.module("z2d"));
// We need wuffs for Kitty graphics pixel operations (format
// conversion and alpha blending). Unlike pure Zig dependencies
// its C code is compiled whenever the module is in the build
// graph regardless of analysis, so only wire it in when Kitty
// graphics is actually enabled.
if (vt_options.kittyGraphics(cfg.target.result)) {
if (b.lazyDependency("wuffs", .{
.target = cfg.target,
.optimize = cfg.optimize,
})) |dep| {
vt.addImport("wuffs", dep.module("wuffs"));
}
}
// If SIMD is enabled, add all our SIMD dependencies.

View File

@@ -257,6 +257,17 @@ pub const Options = struct {
}
};
/// Whether the Kitty graphics feature is effectively enabled for
/// the given target. Kitty graphics requires the ability to get
/// timestamps and there is no way to do that on freestanding
/// targets, so it is always disabled there regardless of the
/// feature setting.
pub fn kittyGraphics(self: Options, target: std.Target) bool {
if (target.cpu.arch == .wasm32 and target.os.tag == .freestanding)
return false;
return self.features.kitty_graphics;
}
/// Add the required build options for the terminal module.
///
/// The memory referenced by self is expected to stick around (it isn't
@@ -281,12 +292,10 @@ pub const Options = struct {
inline for (@typeInfo(Features).@"struct".fields) |field| {
var value = @field(self.features, field.name);
// Kitty graphics requires the ability to get timestamps and
// there is no way to do that on freestanding targets, so it
// is always disabled there regardless of the feature setting.
// Kitty graphics is force-disabled on some targets; see
// kittyGraphics for details.
if (comptime std.mem.eql(u8, field.name, "kitty_graphics")) {
if (target.cpu.arch == .wasm32 and target.os.tag == .freestanding)
value = false;
value = self.kittyGraphics(target);
}
opts.addOption(bool, field.name, value);

View File

@@ -3,75 +3,39 @@
//! These are the primitives behind animation frame loading (a=f) and
//! frame composition (a=c). See graphics_animation.zig for the
//! animation model built on top of them.
//!
//! All buffers are straight (non-premultiplied) alpha RGBA, as the
//! protocol and our consumers (renderer, C API) expect.
//! Since z2d composites in premultiplied alpha, blending round-trips
//! each pixel through multiply/demultiply.
//!
//! Note, there's a lot here that is suboptimal (non-vectorized) and we
//! should use something like wuffs probably too, but wuffs has a libc
//! dependency we don't want to force. We can also optimize this later
//! once we prove it all works.
const std = @import("std");
const Allocator = std.mem.Allocator;
const z2d = @import("z2d");
const wuffs = @import("wuffs");
const command = @import("graphics_command.zig");
/// Convert pixel data in the given format to a freshly allocated RGBA
/// buffer. The caller owns the result; the input is not freed.
///
/// These stay hand-rolled rather than using wuffs' swizzler because
/// wuffs requires libc and libghostty-vt must remain buildable fully
/// freestanding (see the module doc). They produce identical values.
/// The input length must be a multiple of the format's bytes per
/// pixel; image loading validates data length against the image
/// dimensions before storing it.
pub fn rgbaFromFormat(
alloc: Allocator,
format: command.Transmission.Format,
data: []const u8,
) Allocator.Error![]u8 {
switch (format) {
const result = switch (format) {
.rgba => return try alloc.dupe(u8, data),
.rgb => {
const pixels = data.len / 3;
const result = try alloc.alloc(u8, pixels * 4);
for (0..pixels) |i| {
result[i * 4 + 0] = data[i * 3 + 0];
result[i * 4 + 1] = data[i * 3 + 1];
result[i * 4 + 2] = data[i * 3 + 2];
result[i * 4 + 3] = 255;
}
return result;
},
.gray => {
const result = try alloc.alloc(u8, data.len * 4);
for (data, 0..) |v, i| {
result[i * 4 + 0] = v;
result[i * 4 + 1] = v;
result[i * 4 + 2] = v;
result[i * 4 + 3] = 255;
}
return result;
},
.gray_alpha => {
const pixels = data.len / 2;
const result = try alloc.alloc(u8, pixels * 4);
for (0..pixels) |i| {
const v = data[i * 2];
result[i * 4 + 0] = v;
result[i * 4 + 1] = v;
result[i * 4 + 2] = v;
result[i * 4 + 3] = data[i * 2 + 1];
}
return result;
},
.rgb => wuffs.swizzle.rgbToRgba(alloc, data),
.gray => wuffs.swizzle.gToRgba(alloc, data),
.gray_alpha => wuffs.swizzle.gaToRgba(alloc, data),
// PNG is decoded to RGBA during image loading.
.png => unreachable,
}
};
return result catch |err| switch (err) {
error.OutOfMemory => error.OutOfMemory,
// These are fixed, supported swizzles; wuffs cannot fail to
// prepare them and nothing else in the conversion errors.
error.WuffsError, error.Overflow => unreachable,
};
}
/// Fill an RGBA buffer with the given background color.
@@ -154,36 +118,10 @@ pub fn composeCanvasRect(
fn composeRow(dst: []u8, src: []const u8, mode: command.CompositionMode) void {
switch (mode) {
.overwrite => @memcpy(dst, src),
.alpha_blend => {
var i: usize = 0;
while (i < dst.len) : (i += 4) {
blendPixel(dst[i..][0..4], src[i..][0..4]);
}
},
.alpha_blend => wuffs.swizzle.rgbaSrcOver(dst, src),
}
}
/// Source-over blend of one straight-alpha RGBA pixel onto another,
/// via z2d's compositor. z2d composites in premultiplied alpha, so
/// the pixels round-trip through multiply/demultiply; see the module
/// doc for how that compares to Kitty.
fn blendPixel(dst: *[4]u8, src: *const [4]u8) void {
// A fully transparent source pixel leaves the destination
// untouched, exactly like Kitty. This also keeps the no-op case
// free of the premultiply round-trip's rounding.
if (src[3] == 0) return;
const src_px: z2d.pixel.RGBA = .{ .r = src[0], .g = src[1], .b = src[2], .a = src[3] };
const dst_px: z2d.pixel.RGBA = .{ .r = dst[0], .g = dst[1], .b = dst[2], .a = dst[3] };
const out = z2d.compositor.runPixel(
.integer,
dst_px.multiply().asPixel(),
src_px.multiply().asPixel(),
.src_over,
).rgba.demultiply();
dst.* = .{ out.r, out.g, out.b, out.a };
}
test "rgba conversion" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -255,34 +193,33 @@ test "alpha blend source-over semantics" {
// Opaque source overwrites exactly.
{
var dst = [4]u8{ 10, 20, 30, 40 };
blendPixel(&dst, &.{ 100, 110, 120, 255 });
composeRect(&dst, 1, 1, &.{ 100, 110, 120, 255 }, 1, 1, 0, 0, .alpha_blend);
try testing.expectEqualSlices(u8, &.{ 100, 110, 120, 255 }, &dst);
}
// Fully transparent source leaves the destination untouched
// exactly (no premultiply round-trip; matches Kitty).
// (like Kitty; see the module doc on rounding).
{
var dst = [4]u8{ 10, 20, 30, 40 };
blendPixel(&dst, &.{ 100, 110, 120, 0 });
composeRect(&dst, 1, 1, &.{ 100, 110, 120, 0 }, 1, 1, 0, 0, .alpha_blend);
try testing.expectEqualSlices(u8, &.{ 10, 20, 30, 40 }, &dst);
}
// 50% source over opaque destination.
{
var dst = [4]u8{ 0, 0, 0, 255 };
blendPixel(&dst, &.{ 255, 255, 255, 128 });
composeRect(&dst, 1, 1, &.{ 255, 255, 255, 128 }, 1, 1, 0, 0, .alpha_blend);
try testing.expectEqual(@as(u8, 255), dst[3]);
try testing.expectEqual(@as(u8, 128), dst[0]);
}
// Blending over a transparent destination yields the source,
// minus one bit of rounding on the color channels from z2d's
// premultiply round-trip (Kitty's float math yields the source
// exactly here; see the module doc).
// Blending over a transparent destination yields the source
// exactly, matching Kitty: wuffs passes the source through
// untouched when the destination is transparent.
{
var dst = [4]u8{ 0, 0, 0, 0 };
blendPixel(&dst, &.{ 200, 100, 50, 128 });
try testing.expectEqualSlices(u8, &.{ 199, 99, 49, 128 }, &dst);
composeRect(&dst, 1, 1, &.{ 200, 100, 50, 128 }, 1, 1, 0, 0, .alpha_blend);
try testing.expectEqualSlices(u8, &.{ 200, 100, 50, 128 }, &dst);
}
}