From 322d7ae789b06e4b3987b8cdd33c864b5bdb0412 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 21 Aug 2026 06:54:27 -0700 Subject: [PATCH] terminal/kitty: switch to wuffs for pixel work --- pkg/wuffs/src/swizzle.zig | 53 ++++++++++++ src/build/GhosttyZig.zig | 19 +++-- src/terminal/build_options.zig | 19 +++-- src/terminal/kitty/graphics_pixel.zig | 115 ++++++-------------------- 4 files changed, 105 insertions(+), 101 deletions(-) diff --git a/pkg/wuffs/src/swizzle.zig b/pkg/wuffs/src/swizzle.zig index c1e64b91c..d873efea2 100644 --- a/pkg/wuffs/src/swizzle.zig +++ b/pkg/wuffs/src/swizzle.zig @@ -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, diff --git a/src/build/GhosttyZig.zig b/src/build/GhosttyZig.zig index eb39f7a96..46ab05d56 100644 --- a/src/build/GhosttyZig.zig +++ b/src/build/GhosttyZig.zig @@ -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. diff --git a/src/terminal/build_options.zig b/src/terminal/build_options.zig index 124929d28..725fccfd0 100644 --- a/src/terminal/build_options.zig +++ b/src/terminal/build_options.zig @@ -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); diff --git a/src/terminal/kitty/graphics_pixel.zig b/src/terminal/kitty/graphics_pixel.zig index 1b543c3a9..1f4468739 100644 --- a/src/terminal/kitty/graphics_pixel.zig +++ b/src/terminal/kitty/graphics_pixel.zig @@ -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); } }