mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-14 15:56:13 +03:00
Merge pull request #317 from mitchellh/kitty-gfx
Kitty Graphics Protocol Initial Support
This commit is contained in:
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@ -97,7 +97,3 @@ jobs:
|
||||
|
||||
- name: Test Dynamic Build
|
||||
run: nix develop -c zig build -Dstatic=false
|
||||
|
||||
- name: Test Wasm Build
|
||||
run: nix develop -c zig build wasm
|
||||
|
||||
|
1
TODO.md
1
TODO.md
@ -27,4 +27,3 @@ Major Features:
|
||||
|
||||
* Bell
|
||||
* Sixels: https://saitoha.github.io/libsixel/
|
||||
* Kitty graphics protocol: https://sw.kovidgoyal.net/kitty/graphics-protocol/
|
||||
|
11
build.zig
11
build.zig
@ -24,7 +24,6 @@ const libpng = @import("pkg/libpng/build.zig");
|
||||
const macos = @import("pkg/macos/build.zig");
|
||||
const objc = @import("vendor/zig-objc/build.zig");
|
||||
const pixman = @import("pkg/pixman/build.zig");
|
||||
const stb_image_resize = @import("pkg/stb_image_resize/build.zig");
|
||||
const utf8proc = @import("pkg/utf8proc/build.zig");
|
||||
const zlib = @import("pkg/zlib/build.zig");
|
||||
const tracylib = @import("pkg/tracy/build.zig");
|
||||
@ -670,6 +669,11 @@ fn addDeps(
|
||||
step.addLibraryPath(.{ .path = b.fmt("/usr/lib/{s}", .{triple}) });
|
||||
}
|
||||
|
||||
// C files
|
||||
step.linkLibC();
|
||||
step.addIncludePath(.{ .path = "src/stb" });
|
||||
step.addCSourceFiles(&.{"src/stb/stb.c"}, &.{});
|
||||
|
||||
// If we're building a lib we have some different deps
|
||||
const lib = step.kind == .lib;
|
||||
|
||||
@ -693,7 +697,6 @@ fn addDeps(
|
||||
}));
|
||||
step.addModule("xev", mod_libxev);
|
||||
step.addModule("pixman", pixman.module(b));
|
||||
step.addModule("stb_image_resize", stb_image_resize.module(b));
|
||||
step.addModule("utf8proc", utf8proc.module(b));
|
||||
|
||||
// Mac Stuff
|
||||
@ -713,10 +716,6 @@ fn addDeps(
|
||||
system_sdk.include(b, tracy_step, .{});
|
||||
}
|
||||
|
||||
// stb_image_resize
|
||||
const stb_image_resize_step = try stb_image_resize.link(b, step, .{});
|
||||
try static_libs.append(stb_image_resize_step.getEmittedBin());
|
||||
|
||||
// utf8proc
|
||||
const utf8proc_step = try utf8proc.link(b, step);
|
||||
try static_libs.append(utf8proc_step.getEmittedBin());
|
||||
|
@ -1,65 +0,0 @@
|
||||
const std = @import("std");
|
||||
|
||||
/// Directories with our includes.
|
||||
const root = thisDir();
|
||||
pub const include_paths = [_][]const u8{
|
||||
root,
|
||||
};
|
||||
|
||||
pub fn module(b: *std.Build) *std.build.Module {
|
||||
return b.createModule(.{
|
||||
.source_file = .{ .path = (comptime thisDir()) ++ "/main.zig" },
|
||||
});
|
||||
}
|
||||
|
||||
fn thisDir() []const u8 {
|
||||
return std.fs.path.dirname(@src().file) orelse ".";
|
||||
}
|
||||
|
||||
pub const Options = struct {};
|
||||
|
||||
pub fn link(
|
||||
b: *std.Build,
|
||||
step: *std.build.LibExeObjStep,
|
||||
opt: Options,
|
||||
) !*std.build.LibExeObjStep {
|
||||
const lib = try buildStbImageResize(b, step, opt);
|
||||
step.linkLibrary(lib);
|
||||
inline for (include_paths) |path| step.addIncludePath(.{ .path = path });
|
||||
return lib;
|
||||
}
|
||||
|
||||
pub fn buildStbImageResize(
|
||||
b: *std.Build,
|
||||
step: *std.build.LibExeObjStep,
|
||||
opt: Options,
|
||||
) !*std.build.LibExeObjStep {
|
||||
_ = opt;
|
||||
|
||||
const lib = b.addStaticLibrary(.{
|
||||
.name = "stb_image_resize",
|
||||
.target = step.target,
|
||||
.optimize = step.optimize,
|
||||
});
|
||||
|
||||
// Include
|
||||
inline for (include_paths) |path| lib.addIncludePath(.{ .path = path });
|
||||
|
||||
// Link
|
||||
lib.linkLibC();
|
||||
|
||||
// Compile
|
||||
var flags = std.ArrayList([]const u8).init(b.allocator);
|
||||
defer flags.deinit();
|
||||
try flags.appendSlice(&.{
|
||||
//"-fno-sanitize=undefined",
|
||||
});
|
||||
|
||||
// C files
|
||||
lib.addCSourceFile(.{
|
||||
.file = .{ .path = root ++ "/stb_image_resize.c" },
|
||||
.flags = flags.items,
|
||||
});
|
||||
|
||||
return lib;
|
||||
}
|
@ -1,2 +0,0 @@
|
||||
#define STB_IMAGE_RESIZE_IMPLEMENTATION
|
||||
#include <stb_image_resize.h>
|
@ -401,6 +401,7 @@ pub fn init(
|
||||
var io = try termio.Impl.init(alloc, .{
|
||||
.grid_size = grid_size,
|
||||
.screen_size = screen_size,
|
||||
.padding = padding,
|
||||
.full_config = config,
|
||||
.config = try termio.Impl.DerivedConfig.init(alloc, config),
|
||||
.resources_dir = app.resources_dir,
|
||||
@ -894,14 +895,12 @@ pub fn sizeCallback(self: *Surface, size: apprt.SurfaceSize) !void {
|
||||
|
||||
// Recalculate our grid size. Because Ghostty supports fluid resizing,
|
||||
// its possible the grid doesn't change at all even if the screen size changes.
|
||||
const new_grid_size = renderer.GridSize.init(
|
||||
// We have to update the IO thread no matter what because we send
|
||||
// pixel-level sizing to the subprocess.
|
||||
self.grid_size = renderer.GridSize.init(
|
||||
self.screen_size.subPadding(self.padding),
|
||||
self.cell_size,
|
||||
);
|
||||
if (self.grid_size.equals(new_grid_size)) return;
|
||||
|
||||
// Grid size changed, update our grid size and notify the terminal
|
||||
self.grid_size = new_grid_size;
|
||||
if (self.grid_size.columns < 5 and (self.padding.left > 0 or self.padding.right > 0)) {
|
||||
log.warn("WARNING: very small terminal grid detected with padding " ++
|
||||
"set. Is your padding reasonable?", .{});
|
||||
|
@ -186,6 +186,15 @@ pub const Config = struct {
|
||||
/// This does not affect data sent to the clipboard via "clipboard-write".
|
||||
@"clipboard-trim-trailing-spaces": bool = true,
|
||||
|
||||
/// The total amount of bytes that can be used for image data (i.e.
|
||||
/// the Kitty image protocol) per terminal scren. The maximum value
|
||||
/// is 4,294,967,295 (4GB). The default is 320MB. If this is set to zero,
|
||||
/// then all image protocols will be disabled.
|
||||
///
|
||||
/// This value is separate for primary and alternate screens so the
|
||||
/// effective limit per surface is double.
|
||||
@"image-storage-limit": u32 = 320 * 1000 * 1000,
|
||||
|
||||
/// Whether to automatically copy selected text to the clipboard. "true"
|
||||
/// will only copy on systems that support a selection clipboard.
|
||||
///
|
||||
|
@ -8,7 +8,7 @@ const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const freetype = @import("freetype");
|
||||
const harfbuzz = @import("harfbuzz");
|
||||
const resize = @import("stb_image_resize");
|
||||
const stb = @import("../../stb/main.zig");
|
||||
const assert = std.debug.assert;
|
||||
const testing = std.testing;
|
||||
const Allocator = std.mem.Allocator;
|
||||
@ -204,7 +204,7 @@ pub const Face = struct {
|
||||
result.buffer = buf.ptr;
|
||||
errdefer alloc.free(buf);
|
||||
|
||||
if (resize.stbir_resize_uint8(
|
||||
if (stb.stbir_resize_uint8(
|
||||
bm.buffer,
|
||||
@intCast(bm.width),
|
||||
@intCast(bm.rows),
|
||||
|
File diff suppressed because it is too large
Load Diff
138
src/renderer/metal/api.zig
Normal file
138
src/renderer/metal/api.zig
Normal file
@ -0,0 +1,138 @@
|
||||
//! This file contains the definitions of the Metal API that we use.
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlloadaction?language=objc
|
||||
pub const MTLLoadAction = enum(c_ulong) {
|
||||
dont_care = 0,
|
||||
load = 1,
|
||||
clear = 2,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlstoreaction?language=objc
|
||||
pub const MTLStoreAction = enum(c_ulong) {
|
||||
dont_care = 0,
|
||||
store = 1,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlstoragemode?language=objc
|
||||
pub const MTLStorageMode = enum(c_ulong) {
|
||||
shared = 0,
|
||||
managed = 1,
|
||||
private = 2,
|
||||
memoryless = 3,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlprimitivetype?language=objc
|
||||
pub const MTLPrimitiveType = enum(c_ulong) {
|
||||
point = 0,
|
||||
line = 1,
|
||||
line_strip = 2,
|
||||
triangle = 3,
|
||||
triangle_strip = 4,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlindextype?language=objc
|
||||
pub const MTLIndexType = enum(c_ulong) {
|
||||
uint16 = 0,
|
||||
uint32 = 1,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlvertexformat?language=objc
|
||||
pub const MTLVertexFormat = enum(c_ulong) {
|
||||
uchar4 = 3,
|
||||
float2 = 29,
|
||||
float4 = 31,
|
||||
int2 = 33,
|
||||
uint = 36,
|
||||
uint2 = 37,
|
||||
uchar = 45,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlvertexstepfunction?language=objc
|
||||
pub const MTLVertexStepFunction = enum(c_ulong) {
|
||||
constant = 0,
|
||||
per_vertex = 1,
|
||||
per_instance = 2,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlpixelformat?language=objc
|
||||
pub const MTLPixelFormat = enum(c_ulong) {
|
||||
r8unorm = 10,
|
||||
rgba8uint = 73,
|
||||
bgra8unorm = 80,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlpurgeablestate?language=objc
|
||||
pub const MTLPurgeableState = enum(c_ulong) {
|
||||
empty = 4,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlblendfactor?language=objc
|
||||
pub const MTLBlendFactor = enum(c_ulong) {
|
||||
zero = 0,
|
||||
one = 1,
|
||||
source_color = 2,
|
||||
one_minus_source_color = 3,
|
||||
source_alpha = 4,
|
||||
one_minus_source_alpha = 5,
|
||||
dest_color = 6,
|
||||
one_minus_dest_color = 7,
|
||||
dest_alpha = 8,
|
||||
one_minus_dest_alpha = 9,
|
||||
source_alpha_saturated = 10,
|
||||
blend_color = 11,
|
||||
one_minus_blend_color = 12,
|
||||
blend_alpha = 13,
|
||||
one_minus_blend_alpha = 14,
|
||||
source_1_color = 15,
|
||||
one_minus_source_1_color = 16,
|
||||
source_1_alpha = 17,
|
||||
one_minus_source_1_alpha = 18,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlblendoperation?language=objc
|
||||
pub const MTLBlendOperation = enum(c_ulong) {
|
||||
add = 0,
|
||||
subtract = 1,
|
||||
reverse_subtract = 2,
|
||||
min = 3,
|
||||
max = 4,
|
||||
};
|
||||
|
||||
/// https://developer.apple.com/documentation/metal/mtlresourceoptions?language=objc
|
||||
/// (incomplete, we only use this mode so we just hardcode it)
|
||||
pub const MTLResourceStorageModeShared: c_ulong = @intFromEnum(MTLStorageMode.shared) << 4;
|
||||
|
||||
pub const MTLClearColor = extern struct {
|
||||
red: f64,
|
||||
green: f64,
|
||||
blue: f64,
|
||||
alpha: f64,
|
||||
};
|
||||
|
||||
pub const MTLViewport = extern struct {
|
||||
x: f64,
|
||||
y: f64,
|
||||
width: f64,
|
||||
height: f64,
|
||||
znear: f64,
|
||||
zfar: f64,
|
||||
};
|
||||
|
||||
pub const MTLRegion = extern struct {
|
||||
origin: MTLOrigin,
|
||||
size: MTLSize,
|
||||
};
|
||||
|
||||
pub const MTLOrigin = extern struct {
|
||||
x: c_ulong,
|
||||
y: c_ulong,
|
||||
z: c_ulong,
|
||||
};
|
||||
|
||||
pub const MTLSize = extern struct {
|
||||
width: c_ulong,
|
||||
height: c_ulong,
|
||||
depth: c_ulong,
|
||||
};
|
||||
|
||||
pub extern "c" fn MTLCreateSystemDefaultDevice() ?*anyopaque;
|
91
src/renderer/metal/buffer.zig
Normal file
91
src/renderer/metal/buffer.zig
Normal file
@ -0,0 +1,91 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = std.debug.assert;
|
||||
const objc = @import("objc");
|
||||
|
||||
const mtl = @import("api.zig");
|
||||
|
||||
const log = std.log.scoped(.metal);
|
||||
|
||||
/// Metal data storage for a certain set of equal types. This is usually
|
||||
/// used for vertex buffers, etc. This helpful wrapper makes it easy to
|
||||
/// prealloc, shrink, grow, sync, buffers with Metal.
|
||||
pub fn Buffer(comptime T: type) type {
|
||||
return struct {
|
||||
const Self = @This();
|
||||
|
||||
buffer: objc.Object, // MTLBuffer
|
||||
|
||||
/// Initialize a buffer with the given length pre-allocated.
|
||||
pub fn init(device: objc.Object, len: usize) !Self {
|
||||
const buffer = device.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("newBufferWithLength:options:"),
|
||||
.{
|
||||
@as(c_ulong, @intCast(len * @sizeOf(T))),
|
||||
mtl.MTLResourceStorageModeShared,
|
||||
},
|
||||
);
|
||||
|
||||
return .{ .buffer = buffer };
|
||||
}
|
||||
|
||||
/// Init the buffer filled with the given data.
|
||||
pub fn initFill(device: objc.Object, data: []const T) !Self {
|
||||
const buffer = device.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("newBufferWithBytes:length:options:"),
|
||||
.{
|
||||
@as(*const anyopaque, @ptrCast(data.ptr)),
|
||||
@as(c_ulong, @intCast(data.len * @sizeOf(T))),
|
||||
mtl.MTLResourceStorageModeShared,
|
||||
},
|
||||
);
|
||||
|
||||
return .{ .buffer = buffer };
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Self) void {
|
||||
self.buffer.msgSend(void, objc.sel("release"), .{});
|
||||
}
|
||||
|
||||
/// Sync new contents to the buffer.
|
||||
pub fn sync(self: *Self, device: objc.Object, data: []const T) !void {
|
||||
// If we need more bytes than our buffer has, we need to reallocate.
|
||||
const req_bytes = data.len * @sizeOf(T);
|
||||
const avail_bytes = self.buffer.getProperty(c_ulong, "length");
|
||||
if (req_bytes > avail_bytes) {
|
||||
// Deallocate previous buffer
|
||||
self.buffer.msgSend(void, objc.sel("release"), .{});
|
||||
|
||||
// Allocate a new buffer with enough to hold double what we require.
|
||||
const size = req_bytes * 2;
|
||||
self.buffer = device.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("newBufferWithLength:options:"),
|
||||
.{
|
||||
@as(c_ulong, @intCast(size * @sizeOf(T))),
|
||||
mtl.MTLResourceStorageModeShared,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// We can fit within the buffer so we can just replace bytes.
|
||||
const dst = dst: {
|
||||
const ptr = self.buffer.msgSend(?[*]u8, objc.sel("contents"), .{}) orelse {
|
||||
log.warn("buffer contents ptr is null", .{});
|
||||
return error.MetalFailed;
|
||||
};
|
||||
|
||||
break :dst ptr[0..req_bytes];
|
||||
};
|
||||
|
||||
const src = src: {
|
||||
const ptr = @as([*]const u8, @ptrCast(data.ptr));
|
||||
break :src ptr[0..req_bytes];
|
||||
};
|
||||
|
||||
@memcpy(dst, src);
|
||||
}
|
||||
};
|
||||
}
|
241
src/renderer/metal/image.zig
Normal file
241
src/renderer/metal/image.zig
Normal file
@ -0,0 +1,241 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = std.debug.assert;
|
||||
const objc = @import("objc");
|
||||
|
||||
const mtl = @import("api.zig");
|
||||
|
||||
/// Represents a single image placement on the grid. A placement is a
|
||||
/// request to render an instance of an image.
|
||||
pub const Placement = struct {
|
||||
/// The image being rendered. This MUST be in the image map.
|
||||
image_id: u32,
|
||||
|
||||
/// The grid x/y where this placement is located.
|
||||
x: u32,
|
||||
y: u32,
|
||||
z: i32,
|
||||
|
||||
/// The width/height of the placed image.
|
||||
width: u32,
|
||||
height: u32,
|
||||
|
||||
/// The offset in pixels from the top left of the cell. This is
|
||||
/// clamped to the size of a cell.
|
||||
cell_offset_x: u32,
|
||||
cell_offset_y: u32,
|
||||
|
||||
/// The source rectangle of the placement.
|
||||
source_x: u32,
|
||||
source_y: u32,
|
||||
source_width: u32,
|
||||
source_height: u32,
|
||||
};
|
||||
|
||||
/// The map used for storing images.
|
||||
pub const ImageMap = std.AutoHashMapUnmanaged(u32, Image);
|
||||
|
||||
/// The state for a single image that is to be rendered. The image can be
|
||||
/// pending upload or ready to use with a texture.
|
||||
pub const Image = union(enum) {
|
||||
/// The image is pending upload to the GPU. The different keys are
|
||||
/// different formats since some formats aren't accepted by the GPU
|
||||
/// and require conversion.
|
||||
///
|
||||
/// This data is owned by this union so it must be freed once the
|
||||
/// image is uploaded.
|
||||
pending_rgb: Pending,
|
||||
pending_rgba: Pending,
|
||||
|
||||
/// The image is uploaded and ready to be used.
|
||||
ready: objc.Object, // MTLTexture
|
||||
|
||||
/// The image is uploaded but is scheduled to be unloaded.
|
||||
unload_pending: []u8,
|
||||
unload_ready: objc.Object, // MTLTexture
|
||||
|
||||
/// Pending image data that needs to be uploaded to the GPU.
|
||||
pub const Pending = struct {
|
||||
height: u32,
|
||||
width: u32,
|
||||
|
||||
/// Data is always expected to be (width * height * depth). Depth
|
||||
/// is based on the union key.
|
||||
data: [*]u8,
|
||||
|
||||
pub fn dataSlice(self: Pending, d: u32) []u8 {
|
||||
return self.data[0..self.len(d)];
|
||||
}
|
||||
|
||||
pub fn len(self: Pending, d: u32) u32 {
|
||||
return self.width * self.height * d;
|
||||
}
|
||||
};
|
||||
|
||||
pub fn deinit(self: Image, alloc: Allocator) void {
|
||||
switch (self) {
|
||||
.pending_rgb => |p| alloc.free(p.dataSlice(3)),
|
||||
.pending_rgba => |p| alloc.free(p.dataSlice(4)),
|
||||
.unload_pending => |data| alloc.free(data),
|
||||
|
||||
.ready,
|
||||
.unload_ready,
|
||||
=> |obj| obj.msgSend(void, objc.sel("release"), .{}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Mark this image for unload whatever state it is in.
|
||||
pub fn markForUnload(self: *Image) void {
|
||||
self.* = switch (self.*) {
|
||||
.unload_pending,
|
||||
.unload_ready,
|
||||
=> return,
|
||||
|
||||
.ready => |obj| .{ .unload_ready = obj },
|
||||
.pending_rgb => |p| .{ .unload_pending = p.dataSlice(3) },
|
||||
.pending_rgba => |p| .{ .unload_pending = p.dataSlice(4) },
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns true if this image is pending upload.
|
||||
pub fn isPending(self: Image) bool {
|
||||
return self.pending() != null;
|
||||
}
|
||||
|
||||
/// Returns true if this image is pending an unload.
|
||||
pub fn isUnloading(self: Image) bool {
|
||||
return switch (self) {
|
||||
.unload_pending,
|
||||
.unload_ready,
|
||||
=> true,
|
||||
|
||||
.ready,
|
||||
.pending_rgb,
|
||||
.pending_rgba,
|
||||
=> false,
|
||||
};
|
||||
}
|
||||
|
||||
/// Converts the image data to a format that can be uploaded to the GPU.
|
||||
/// If the data is already in a format that can be uploaded, this is a
|
||||
/// no-op.
|
||||
pub fn convert(self: *Image, alloc: Allocator) !void {
|
||||
switch (self.*) {
|
||||
.ready,
|
||||
.unload_pending,
|
||||
.unload_ready,
|
||||
=> unreachable, // invalid
|
||||
|
||||
.pending_rgba => {}, // ready
|
||||
|
||||
// RGB needs to be converted to RGBA because Metal textures
|
||||
// don't support RGB.
|
||||
.pending_rgb => |*p| {
|
||||
// Note: this is the slowest possible way to do this...
|
||||
const data = p.dataSlice(3);
|
||||
const pixels = data.len / 3;
|
||||
var rgba = try alloc.alloc(u8, pixels * 4);
|
||||
errdefer alloc.free(rgba);
|
||||
var i: usize = 0;
|
||||
while (i < pixels) : (i += 1) {
|
||||
const data_i = i * 3;
|
||||
const rgba_i = i * 4;
|
||||
rgba[rgba_i] = data[data_i];
|
||||
rgba[rgba_i + 1] = data[data_i + 1];
|
||||
rgba[rgba_i + 2] = data[data_i + 2];
|
||||
rgba[rgba_i + 3] = 255;
|
||||
}
|
||||
|
||||
alloc.free(data);
|
||||
p.data = rgba.ptr;
|
||||
self.* = .{ .pending_rgba = p.* };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/// Upload the pending image to the GPU and change the state of this
|
||||
/// image to ready.
|
||||
pub fn upload(
|
||||
self: *Image,
|
||||
alloc: Allocator,
|
||||
device: objc.Object,
|
||||
) !void {
|
||||
// Convert our data if we have to
|
||||
try self.convert(alloc);
|
||||
|
||||
// Get our pending info
|
||||
const p = self.pending().?;
|
||||
|
||||
// Create our texture
|
||||
const texture = try initTexture(p, device);
|
||||
errdefer texture.msgSend(void, objc.sel("release"), .{});
|
||||
|
||||
// Upload our data
|
||||
const d = self.depth();
|
||||
texture.msgSend(
|
||||
void,
|
||||
objc.sel("replaceRegion:mipmapLevel:withBytes:bytesPerRow:"),
|
||||
.{
|
||||
mtl.MTLRegion{
|
||||
.origin = .{ .x = 0, .y = 0, .z = 0 },
|
||||
.size = .{
|
||||
.width = @intCast(p.width),
|
||||
.height = @intCast(p.height),
|
||||
.depth = 1,
|
||||
},
|
||||
},
|
||||
@as(c_ulong, 0),
|
||||
@as(*const anyopaque, p.data),
|
||||
@as(c_ulong, d * p.width),
|
||||
},
|
||||
);
|
||||
|
||||
// Uploaded. We can now clear our data and change our state.
|
||||
self.deinit(alloc);
|
||||
self.* = .{ .ready = texture };
|
||||
}
|
||||
|
||||
/// Our pixel depth
|
||||
fn depth(self: Image) u32 {
|
||||
return switch (self) {
|
||||
.pending_rgb => 3,
|
||||
.pending_rgba => 4,
|
||||
else => unreachable,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns true if this image is in a pending state and requires upload.
|
||||
fn pending(self: Image) ?Pending {
|
||||
return switch (self) {
|
||||
.pending_rgb,
|
||||
.pending_rgba,
|
||||
=> |p| p,
|
||||
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
fn initTexture(p: Pending, device: objc.Object) !objc.Object {
|
||||
// Create our descriptor
|
||||
const desc = init: {
|
||||
const Class = objc.Class.getClass("MTLTextureDescriptor").?;
|
||||
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
|
||||
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
|
||||
break :init id_init;
|
||||
};
|
||||
|
||||
// Set our properties
|
||||
desc.setProperty("pixelFormat", @intFromEnum(mtl.MTLPixelFormat.rgba8uint));
|
||||
desc.setProperty("width", @as(c_ulong, @intCast(p.width)));
|
||||
desc.setProperty("height", @as(c_ulong, @intCast(p.height)));
|
||||
|
||||
// Initialize
|
||||
const id = device.msgSend(
|
||||
?*anyopaque,
|
||||
objc.sel("newTextureWithDescriptor:"),
|
||||
.{desc},
|
||||
) orelse return error.MetalFailed;
|
||||
|
||||
return objc.Object.fromId(id);
|
||||
}
|
||||
};
|
465
src/renderer/metal/shaders.zig
Normal file
465
src/renderer/metal/shaders.zig
Normal file
@ -0,0 +1,465 @@
|
||||
const std = @import("std");
|
||||
const Allocator = std.mem.Allocator;
|
||||
const assert = std.debug.assert;
|
||||
const macos = @import("macos");
|
||||
const objc = @import("objc");
|
||||
const math = @import("../../math.zig");
|
||||
|
||||
const mtl = @import("api.zig");
|
||||
|
||||
const log = std.log.scoped(.metal);
|
||||
|
||||
/// This contains the state for the shaders used by the Metal renderer.
|
||||
pub const Shaders = struct {
|
||||
library: objc.Object,
|
||||
cell_pipeline: objc.Object,
|
||||
image_pipeline: objc.Object,
|
||||
|
||||
pub fn init(device: objc.Object) !Shaders {
|
||||
const library = try initLibrary(device);
|
||||
errdefer library.msgSend(void, objc.sel("release"), .{});
|
||||
|
||||
const cell_pipeline = try initCellPipeline(device, library);
|
||||
errdefer cell_pipeline.msgSend(void, objc.sel("release"), .{});
|
||||
|
||||
const image_pipeline = try initImagePipeline(device, library);
|
||||
errdefer image_pipeline.msgSend(void, objc.sel("release"), .{});
|
||||
|
||||
return .{
|
||||
.library = library,
|
||||
.cell_pipeline = cell_pipeline,
|
||||
.image_pipeline = image_pipeline,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Shaders) void {
|
||||
self.cell_pipeline.msgSend(void, objc.sel("release"), .{});
|
||||
self.image_pipeline.msgSend(void, objc.sel("release"), .{});
|
||||
self.library.msgSend(void, objc.sel("release"), .{});
|
||||
}
|
||||
};
|
||||
|
||||
/// This is a single parameter for the terminal cell shader.
|
||||
pub const Cell = extern struct {
|
||||
mode: Mode,
|
||||
grid_pos: [2]f32,
|
||||
glyph_pos: [2]u32 = .{ 0, 0 },
|
||||
glyph_size: [2]u32 = .{ 0, 0 },
|
||||
glyph_offset: [2]i32 = .{ 0, 0 },
|
||||
color: [4]u8,
|
||||
cell_width: u8,
|
||||
|
||||
pub const Mode = enum(u8) {
|
||||
bg = 1,
|
||||
fg = 2,
|
||||
fg_color = 7,
|
||||
strikethrough = 8,
|
||||
};
|
||||
};
|
||||
|
||||
/// Single parameter for the image shader. See shader for field details.
|
||||
pub const Image = extern struct {
|
||||
grid_pos: [2]f32,
|
||||
cell_offset: [2]f32,
|
||||
source_rect: [4]f32,
|
||||
dest_size: [2]f32,
|
||||
};
|
||||
|
||||
/// The uniforms that are passed to the terminal cell shader.
|
||||
pub const Uniforms = extern struct {
|
||||
/// The projection matrix for turning world coordinates to normalized.
|
||||
/// This is calculated based on the size of the screen.
|
||||
projection_matrix: math.Mat,
|
||||
|
||||
/// Size of a single cell in pixels, unscaled.
|
||||
cell_size: [2]f32,
|
||||
|
||||
/// Metrics for underline/strikethrough
|
||||
strikethrough_position: f32,
|
||||
strikethrough_thickness: f32,
|
||||
};
|
||||
|
||||
/// Initialize the MTLLibrary. A MTLLibrary is a collection of shaders.
|
||||
fn initLibrary(device: objc.Object) !objc.Object {
|
||||
// Hardcoded since this file isn't meant to be reusable.
|
||||
const data = @embedFile("../shaders/cell.metal");
|
||||
const source = try macos.foundation.String.createWithBytes(
|
||||
data,
|
||||
.utf8,
|
||||
false,
|
||||
);
|
||||
defer source.release();
|
||||
|
||||
var err: ?*anyopaque = null;
|
||||
const library = device.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("newLibraryWithSource:options:error:"),
|
||||
.{
|
||||
source,
|
||||
@as(?*anyopaque, null),
|
||||
&err,
|
||||
},
|
||||
);
|
||||
try checkError(err);
|
||||
|
||||
return library;
|
||||
}
|
||||
|
||||
/// Initialize the cell render pipeline for our shader library.
|
||||
fn initCellPipeline(device: objc.Object, library: objc.Object) !objc.Object {
|
||||
// Get our vertex and fragment functions
|
||||
const func_vert = func_vert: {
|
||||
const str = try macos.foundation.String.createWithBytes(
|
||||
"uber_vertex",
|
||||
.utf8,
|
||||
false,
|
||||
);
|
||||
defer str.release();
|
||||
|
||||
const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
|
||||
break :func_vert objc.Object.fromId(ptr.?);
|
||||
};
|
||||
const func_frag = func_frag: {
|
||||
const str = try macos.foundation.String.createWithBytes(
|
||||
"uber_fragment",
|
||||
.utf8,
|
||||
false,
|
||||
);
|
||||
defer str.release();
|
||||
|
||||
const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
|
||||
break :func_frag objc.Object.fromId(ptr.?);
|
||||
};
|
||||
|
||||
// Create the vertex descriptor. The vertex descriptor describes the
|
||||
// data layout of the vertex inputs. We use indexed (or "instanced")
|
||||
// rendering, so this makes it so that each instance gets a single
|
||||
// Cell as input.
|
||||
const vertex_desc = vertex_desc: {
|
||||
const desc = init: {
|
||||
const Class = objc.Class.getClass("MTLVertexDescriptor").?;
|
||||
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
|
||||
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
|
||||
break :init id_init;
|
||||
};
|
||||
|
||||
// Our attributes are the fields of the input
|
||||
const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes"));
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 0)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.uchar));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "mode")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 1)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.float2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "grid_pos")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 2)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.uint2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "glyph_pos")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 3)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.uint2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "glyph_size")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 4)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.int2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "glyph_offset")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 5)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.uchar4));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "color")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 6)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.uchar));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Cell, "cell_width")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
|
||||
// The layout describes how and when we fetch the next vertex input.
|
||||
const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts"));
|
||||
{
|
||||
const layout = layouts.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 0)},
|
||||
);
|
||||
|
||||
// Access each Cell per instance, not per vertex.
|
||||
layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance));
|
||||
layout.setProperty("stride", @as(c_ulong, @sizeOf(Cell)));
|
||||
}
|
||||
|
||||
break :vertex_desc desc;
|
||||
};
|
||||
|
||||
// Create our descriptor
|
||||
const desc = init: {
|
||||
const Class = objc.Class.getClass("MTLRenderPipelineDescriptor").?;
|
||||
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
|
||||
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
|
||||
break :init id_init;
|
||||
};
|
||||
|
||||
// Set our properties
|
||||
desc.setProperty("vertexFunction", func_vert);
|
||||
desc.setProperty("fragmentFunction", func_frag);
|
||||
desc.setProperty("vertexDescriptor", vertex_desc);
|
||||
|
||||
// Set our color attachment
|
||||
const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
|
||||
{
|
||||
const attachment = attachments.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 0)},
|
||||
);
|
||||
|
||||
// Value is MTLPixelFormatBGRA8Unorm
|
||||
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
|
||||
|
||||
// Blending. This is required so that our text we render on top
|
||||
// of our drawable properly blends into the bg.
|
||||
attachment.setProperty("blendingEnabled", true);
|
||||
attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
|
||||
attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
|
||||
attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
|
||||
attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
|
||||
attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
|
||||
attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
|
||||
}
|
||||
|
||||
// Make our state
|
||||
var err: ?*anyopaque = null;
|
||||
const pipeline_state = device.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("newRenderPipelineStateWithDescriptor:error:"),
|
||||
.{ desc, &err },
|
||||
);
|
||||
try checkError(err);
|
||||
|
||||
return pipeline_state;
|
||||
}
|
||||
|
||||
/// Initialize the image render pipeline for our shader library.
|
||||
fn initImagePipeline(device: objc.Object, library: objc.Object) !objc.Object {
|
||||
// Get our vertex and fragment functions
|
||||
const func_vert = func_vert: {
|
||||
const str = try macos.foundation.String.createWithBytes(
|
||||
"image_vertex",
|
||||
.utf8,
|
||||
false,
|
||||
);
|
||||
defer str.release();
|
||||
|
||||
const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
|
||||
break :func_vert objc.Object.fromId(ptr.?);
|
||||
};
|
||||
const func_frag = func_frag: {
|
||||
const str = try macos.foundation.String.createWithBytes(
|
||||
"image_fragment",
|
||||
.utf8,
|
||||
false,
|
||||
);
|
||||
defer str.release();
|
||||
|
||||
const ptr = library.msgSend(?*anyopaque, objc.sel("newFunctionWithName:"), .{str});
|
||||
break :func_frag objc.Object.fromId(ptr.?);
|
||||
};
|
||||
|
||||
// Create the vertex descriptor. The vertex descriptor describes the
|
||||
// data layout of the vertex inputs. We use indexed (or "instanced")
|
||||
// rendering, so this makes it so that each instance gets a single
|
||||
// Image as input.
|
||||
const vertex_desc = vertex_desc: {
|
||||
const desc = init: {
|
||||
const Class = objc.Class.getClass("MTLVertexDescriptor").?;
|
||||
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
|
||||
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
|
||||
break :init id_init;
|
||||
};
|
||||
|
||||
// Our attributes are the fields of the input
|
||||
const attrs = objc.Object.fromId(desc.getProperty(?*anyopaque, "attributes"));
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 1)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.float2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Image, "grid_pos")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 2)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.float2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Image, "cell_offset")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 3)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.float4));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Image, "source_rect")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
{
|
||||
const attr = attrs.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 4)},
|
||||
);
|
||||
|
||||
attr.setProperty("format", @intFromEnum(mtl.MTLVertexFormat.float2));
|
||||
attr.setProperty("offset", @as(c_ulong, @offsetOf(Image, "dest_size")));
|
||||
attr.setProperty("bufferIndex", @as(c_ulong, 0));
|
||||
}
|
||||
|
||||
// The layout describes how and when we fetch the next vertex input.
|
||||
const layouts = objc.Object.fromId(desc.getProperty(?*anyopaque, "layouts"));
|
||||
{
|
||||
const layout = layouts.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 0)},
|
||||
);
|
||||
|
||||
// Access each Image per instance, not per vertex.
|
||||
layout.setProperty("stepFunction", @intFromEnum(mtl.MTLVertexStepFunction.per_instance));
|
||||
layout.setProperty("stride", @as(c_ulong, @sizeOf(Image)));
|
||||
}
|
||||
|
||||
break :vertex_desc desc;
|
||||
};
|
||||
|
||||
// Create our descriptor
|
||||
const desc = init: {
|
||||
const Class = objc.Class.getClass("MTLRenderPipelineDescriptor").?;
|
||||
const id_alloc = Class.msgSend(objc.Object, objc.sel("alloc"), .{});
|
||||
const id_init = id_alloc.msgSend(objc.Object, objc.sel("init"), .{});
|
||||
break :init id_init;
|
||||
};
|
||||
|
||||
// Set our properties
|
||||
desc.setProperty("vertexFunction", func_vert);
|
||||
desc.setProperty("fragmentFunction", func_frag);
|
||||
desc.setProperty("vertexDescriptor", vertex_desc);
|
||||
|
||||
// Set our color attachment
|
||||
const attachments = objc.Object.fromId(desc.getProperty(?*anyopaque, "colorAttachments"));
|
||||
{
|
||||
const attachment = attachments.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("objectAtIndexedSubscript:"),
|
||||
.{@as(c_ulong, 0)},
|
||||
);
|
||||
|
||||
// Value is MTLPixelFormatBGRA8Unorm
|
||||
attachment.setProperty("pixelFormat", @as(c_ulong, 80));
|
||||
|
||||
// Blending. This is required so that our text we render on top
|
||||
// of our drawable properly blends into the bg.
|
||||
attachment.setProperty("blendingEnabled", true);
|
||||
attachment.setProperty("rgbBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
|
||||
attachment.setProperty("alphaBlendOperation", @intFromEnum(mtl.MTLBlendOperation.add));
|
||||
attachment.setProperty("sourceRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
|
||||
attachment.setProperty("sourceAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one));
|
||||
attachment.setProperty("destinationRGBBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
|
||||
attachment.setProperty("destinationAlphaBlendFactor", @intFromEnum(mtl.MTLBlendFactor.one_minus_source_alpha));
|
||||
}
|
||||
|
||||
// Make our state
|
||||
var err: ?*anyopaque = null;
|
||||
const pipeline_state = device.msgSend(
|
||||
objc.Object,
|
||||
objc.sel("newRenderPipelineStateWithDescriptor:error:"),
|
||||
.{ desc, &err },
|
||||
);
|
||||
try checkError(err);
|
||||
|
||||
return pipeline_state;
|
||||
}
|
||||
|
||||
fn checkError(err_: ?*anyopaque) !void {
|
||||
const nserr = objc.Object.fromId(err_ orelse return);
|
||||
const str = @as(
|
||||
*macos.foundation.String,
|
||||
@ptrCast(nserr.getProperty(?*anyopaque, "localizedDescription").?),
|
||||
);
|
||||
|
||||
log.err("metal error={s}", .{str.cstringPtr(.ascii).?});
|
||||
return error.MetalFailed;
|
||||
}
|
||||
|
||||
// Intel macOS 13 doesn't like it when any field in a vertex buffer is not
|
||||
// aligned on the alignment of the struct. I don't understand it, I think
|
||||
// this must be some macOS 13 Metal GPU driver bug because it doesn't matter
|
||||
// on macOS 12 or Apple Silicon macOS 13.
|
||||
//
|
||||
// To be safe, we put this test in here.
|
||||
test "Cell offsets" {
|
||||
const testing = std.testing;
|
||||
const alignment = @alignOf(Cell);
|
||||
inline for (@typeInfo(Cell).Struct.fields) |field| {
|
||||
const offset = @offsetOf(Cell, field.name);
|
||||
try testing.expectEqual(0, @mod(offset, alignment));
|
||||
}
|
||||
}
|
@ -49,6 +49,11 @@ struct VertexOut {
|
||||
float2 tex_coord;
|
||||
};
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
// Terminal Grid Cell Shader
|
||||
//-------------------------------------------------------------------
|
||||
#pragma mark - Terminal Grid Cell Shader
|
||||
|
||||
vertex VertexOut uber_vertex(
|
||||
unsigned int vid [[ vertex_id ]],
|
||||
VertexIn input [[ stage_in ]],
|
||||
@ -179,3 +184,83 @@ fragment float4 uber_fragment(
|
||||
return in.color;
|
||||
}
|
||||
}
|
||||
|
||||
//-------------------------------------------------------------------
|
||||
// Image Shader
|
||||
//-------------------------------------------------------------------
|
||||
#pragma mark - Image Shader
|
||||
|
||||
struct ImageVertexIn {
|
||||
// The grid coordinates (x, y) where x < columns and y < rows where
|
||||
// the image will be rendered. It will be rendered from the top left.
|
||||
float2 grid_pos [[ attribute(1) ]];
|
||||
|
||||
// Offset in pixels from the top-left of the cell to make the top-left
|
||||
// corner of the image.
|
||||
float2 cell_offset [[ attribute(2) ]];
|
||||
|
||||
// The source rectangle of the texture to sample from.
|
||||
float4 source_rect [[ attribute(3) ]];
|
||||
|
||||
// The final width/height of the image in pixels.
|
||||
float2 dest_size [[ attribute(4) ]];
|
||||
};
|
||||
|
||||
struct ImageVertexOut {
|
||||
float4 position [[ position ]];
|
||||
float2 tex_coord;
|
||||
};
|
||||
|
||||
vertex ImageVertexOut image_vertex(
|
||||
unsigned int vid [[ vertex_id ]],
|
||||
ImageVertexIn input [[ stage_in ]],
|
||||
texture2d<uint> image [[ texture(0) ]],
|
||||
constant Uniforms &uniforms [[ buffer(1) ]]
|
||||
) {
|
||||
// The size of the image in pixels
|
||||
float2 image_size = float2(image.get_width(), image.get_height());
|
||||
|
||||
// Turn the image position into a vertex point depending on the
|
||||
// vertex ID. Since we use instanced drawing, we have 4 vertices
|
||||
// for each corner of the cell. We can use vertex ID to determine
|
||||
// which one we're looking at. Using this, we can use 1 or 0 to keep
|
||||
// or discard the value for the vertex.
|
||||
//
|
||||
// 0 = top-right
|
||||
// 1 = bot-right
|
||||
// 2 = bot-left
|
||||
// 3 = top-left
|
||||
float2 position;
|
||||
position.x = (vid == 0 || vid == 1) ? 1.0f : 0.0f;
|
||||
position.y = (vid == 0 || vid == 3) ? 0.0f : 1.0f;
|
||||
|
||||
// The texture coordinates start at our source x/y, then add the width/height
|
||||
// as enabled by our instance id, then normalize to [0, 1]
|
||||
float2 tex_coord = input.source_rect.xy;
|
||||
tex_coord += input.source_rect.zw * position;
|
||||
tex_coord /= image_size;
|
||||
|
||||
ImageVertexOut out;
|
||||
|
||||
// The position of our image starts at the top-left of the grid cell and
|
||||
// adds the source rect width/height components.
|
||||
float2 image_pos = (uniforms.cell_size * input.grid_pos) + input.cell_offset;
|
||||
image_pos += input.dest_size * position;
|
||||
|
||||
out.position = uniforms.projection_matrix * float4(image_pos.x, image_pos.y, 0.0f, 1.0f);
|
||||
out.tex_coord = tex_coord;
|
||||
return out;
|
||||
}
|
||||
|
||||
fragment float4 image_fragment(
|
||||
ImageVertexOut in [[ stage_in ]],
|
||||
texture2d<uint> image [[ texture(0) ]]
|
||||
) {
|
||||
constexpr sampler textureSampler(address::clamp_to_edge, filter::linear);
|
||||
|
||||
// Ehhhhh our texture is in RGBA8Uint but our color attachment is
|
||||
// BGRA8Unorm. So we need to convert it. We should really be converting
|
||||
// our texture to BGRA8Unorm.
|
||||
uint4 rgba = image.sample(textureSampler, in.tex_coord);
|
||||
return float4(rgba) / 255.0f;
|
||||
}
|
||||
|
@ -1,7 +1,4 @@
|
||||
pub usingnamespace @cImport({
|
||||
@cInclude("stb_image.h");
|
||||
@cInclude("stb_image_resize.h");
|
||||
});
|
||||
|
||||
test {
|
||||
// Needed to not crash on test
|
||||
}
|
13
src/stb/stb.c
Normal file
13
src/stb/stb.c
Normal file
@ -0,0 +1,13 @@
|
||||
// For STBI we only need PNG because the only use case we have right now
|
||||
// is the Kitty Graphics protocol which only supports PNG as a format
|
||||
// besides raw RGB/RGBA buffers.
|
||||
#define STBI_ONLY_PNG
|
||||
|
||||
// We don't want to support super large images.
|
||||
#define STBI_MAX_DIMENSIONS 131072
|
||||
|
||||
#define STB_IMAGE_IMPLEMENTATION
|
||||
#include <stb_image.h>
|
||||
|
||||
#define STB_IMAGE_RESIZE_IMPLEMENTATION
|
||||
#include <stb_image_resize.h>
|
7987
src/stb/stb_image.h
Normal file
7987
src/stb/stb_image.h
Normal file
File diff suppressed because it is too large
Load Diff
@ -48,6 +48,7 @@ pub const TransitionAction = enum {
|
||||
csi_dispatch,
|
||||
put,
|
||||
osc_put,
|
||||
apc_put,
|
||||
};
|
||||
|
||||
/// Action is the action that a caller of the parser is expected to
|
||||
@ -74,6 +75,11 @@ pub const Action = union(enum) {
|
||||
dcs_put: u8,
|
||||
dcs_unhook: void,
|
||||
|
||||
/// APC data
|
||||
apc_start: void,
|
||||
apc_put: u8,
|
||||
apc_end: void,
|
||||
|
||||
pub const CSI = struct {
|
||||
intermediates: []u8,
|
||||
params: []u16,
|
||||
@ -247,6 +253,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
|
||||
else
|
||||
null,
|
||||
.dcs_passthrough => Action{ .dcs_unhook = {} },
|
||||
.sos_pm_apc_string => Action{ .apc_end = {} },
|
||||
else => null,
|
||||
},
|
||||
|
||||
@ -269,6 +276,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
|
||||
.final = c,
|
||||
},
|
||||
},
|
||||
.sos_pm_apc_string => Action{ .apc_start = {} },
|
||||
.utf8 => utf8: {
|
||||
// When entering the UTF8 state, we need to grab the
|
||||
// last intermediate as our first byte and reset
|
||||
@ -426,9 +434,8 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
|
||||
.final = c,
|
||||
},
|
||||
},
|
||||
.put => Action{
|
||||
.dcs_put = c,
|
||||
},
|
||||
.put => Action{ .dcs_put = c },
|
||||
.apc_put => Action{ .apc_put = c },
|
||||
};
|
||||
}
|
||||
|
||||
|
@ -865,6 +865,9 @@ selection: ?Selection = null,
|
||||
/// The kitty keyboard settings.
|
||||
kitty_keyboard: kitty.KeyFlagStack = .{},
|
||||
|
||||
/// Kitty graphics protocol state.
|
||||
kitty_images: kitty.graphics.ImageStorage = .{},
|
||||
|
||||
/// Initialize a new screen.
|
||||
pub fn init(
|
||||
alloc: Allocator,
|
||||
@ -889,6 +892,7 @@ pub fn init(
|
||||
}
|
||||
|
||||
pub fn deinit(self: *Screen) void {
|
||||
self.kitty_images.deinit(self.alloc);
|
||||
self.storage.deinit(self.alloc);
|
||||
self.deinitGraphemes();
|
||||
}
|
||||
@ -1538,6 +1542,11 @@ pub const Scroll = union(enum) {
|
||||
/// want to do that yet (i.e. are they writing to the end of the screen
|
||||
/// or not).
|
||||
pub fn scroll(self: *Screen, behavior: Scroll) !void {
|
||||
// No matter what, scrolling marks our image state as dirty since
|
||||
// it could move placements. If there are no placements or no images
|
||||
// this is still a very cheap operation.
|
||||
self.kitty_images.dirty = true;
|
||||
|
||||
switch (behavior) {
|
||||
// Setting viewport offset to zero makes row 0 be at self.top
|
||||
// which is the top!
|
||||
@ -2104,6 +2113,9 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void {
|
||||
// No resize necessary
|
||||
if (self.rows == rows) return;
|
||||
|
||||
// No matter what we mark our image state as dirty
|
||||
self.kitty_images.dirty = true;
|
||||
|
||||
// If we have the same number of columns, text can't possibly
|
||||
// reflow in any way, so we do the quicker thing and do a resize
|
||||
// without reflow checks.
|
||||
@ -2111,6 +2123,9 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void {
|
||||
return;
|
||||
}
|
||||
|
||||
// No matter what we mark our image state as dirty
|
||||
self.kitty_images.dirty = true;
|
||||
|
||||
// If our columns increased, we alloc space for the new column width
|
||||
// and go through each row and reflow if necessary.
|
||||
if (cols > self.cols) {
|
||||
|
@ -15,6 +15,7 @@ const ansi = @import("ansi.zig");
|
||||
const modes = @import("modes.zig");
|
||||
const charsets = @import("charsets.zig");
|
||||
const csi = @import("csi.zig");
|
||||
const kitty = @import("kitty.zig");
|
||||
const sgr = @import("sgr.zig");
|
||||
const Tabstops = @import("Tabstops.zig");
|
||||
const trace = @import("tracy").trace;
|
||||
@ -62,6 +63,10 @@ tabstops: Tabstops,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
|
||||
/// The size of the screen in pixels. This is used for pty events and images
|
||||
width_px: u32 = 0,
|
||||
height_px: u32 = 0,
|
||||
|
||||
/// The current scrolling region.
|
||||
scrolling_region: ScrollingRegion,
|
||||
|
||||
@ -188,7 +193,11 @@ pub const AlternateScreenOptions = struct {
|
||||
/// * has its own cursor state (included saved cursor)
|
||||
/// * does not support scrollback
|
||||
///
|
||||
pub fn alternateScreen(self: *Terminal, options: AlternateScreenOptions) void {
|
||||
pub fn alternateScreen(
|
||||
self: *Terminal,
|
||||
alloc: Allocator,
|
||||
options: AlternateScreenOptions,
|
||||
) void {
|
||||
const tracy = trace(@src());
|
||||
defer tracy.end();
|
||||
|
||||
@ -215,12 +224,16 @@ pub fn alternateScreen(self: *Terminal, options: AlternateScreenOptions) void {
|
||||
self.screen.selection = null;
|
||||
|
||||
if (options.clear_on_enter) {
|
||||
self.eraseDisplay(.complete);
|
||||
self.eraseDisplay(alloc, .complete);
|
||||
}
|
||||
}
|
||||
|
||||
/// Switch back to the primary screen (reset alternate screen mode).
|
||||
pub fn primaryScreen(self: *Terminal, options: AlternateScreenOptions) void {
|
||||
pub fn primaryScreen(
|
||||
self: *Terminal,
|
||||
alloc: Allocator,
|
||||
options: AlternateScreenOptions,
|
||||
) void {
|
||||
const tracy = trace(@src());
|
||||
defer tracy.end();
|
||||
|
||||
@ -230,7 +243,7 @@ pub fn primaryScreen(self: *Terminal, options: AlternateScreenOptions) void {
|
||||
// TODO(mitchellh): what happens if we enter alternate screen multiple times?
|
||||
if (self.active_screen == .primary) return;
|
||||
|
||||
if (options.clear_on_exit) self.eraseDisplay(.complete);
|
||||
if (options.clear_on_exit) self.eraseDisplay(alloc, .complete);
|
||||
|
||||
// Switch the screens
|
||||
const old = self.screen;
|
||||
@ -277,7 +290,7 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void {
|
||||
try self.resize(alloc, 0, self.rows);
|
||||
|
||||
// TODO: do not clear screen flag mode
|
||||
self.eraseDisplay(.complete);
|
||||
self.eraseDisplay(alloc, .complete);
|
||||
self.setCursorPos(1, 1);
|
||||
|
||||
// TODO: left/right margins
|
||||
@ -296,6 +309,9 @@ pub fn resize(self: *Terminal, alloc: Allocator, cols_req: usize, rows: usize) !
|
||||
else
|
||||
cols_req;
|
||||
|
||||
// If our cols/rows didn't change then we're done
|
||||
if (self.cols == cols and self.rows == rows) return;
|
||||
|
||||
// Resize our tabstops
|
||||
// TODO: use resize, but it doesn't set new tabstops
|
||||
if (self.cols != cols) {
|
||||
@ -986,6 +1002,7 @@ pub fn setCursorColAbsolute(self: *Terminal, col_req: usize) void {
|
||||
/// TODO: test
|
||||
pub fn eraseDisplay(
|
||||
self: *Terminal,
|
||||
alloc: Allocator,
|
||||
mode: csi.EraseDisplay,
|
||||
) void {
|
||||
const tracy = trace(@src());
|
||||
@ -1002,6 +1019,9 @@ pub fn eraseDisplay(
|
||||
|
||||
// Unsets pending wrap state
|
||||
self.screen.cursor.pending_wrap = false;
|
||||
|
||||
// Clear all Kitty graphics state for this screen
|
||||
self.screen.kitty_images.delete(alloc, self, .{ .all = true });
|
||||
},
|
||||
|
||||
.below => {
|
||||
@ -1555,18 +1575,34 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 {
|
||||
return self.pwd.items;
|
||||
}
|
||||
|
||||
/// Execute a kitty graphics command. The buf is used to populate with
|
||||
/// the response that should be sent as an APC sequence. The response will
|
||||
/// be a full, valid APC sequence.
|
||||
///
|
||||
/// If an error occurs, the caller should response to the pty that a
|
||||
/// an error occurred otherwise the behavior of the graphics protocol is
|
||||
/// undefined.
|
||||
pub fn kittyGraphics(
|
||||
self: *Terminal,
|
||||
alloc: Allocator,
|
||||
cmd: *kitty.graphics.Command,
|
||||
) ?kitty.graphics.Response {
|
||||
return kitty.graphics.execute(alloc, self, cmd);
|
||||
}
|
||||
|
||||
/// Full reset
|
||||
pub fn fullReset(self: *Terminal) void {
|
||||
self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true });
|
||||
pub fn fullReset(self: *Terminal, alloc: Allocator) void {
|
||||
self.primaryScreen(alloc, .{ .clear_on_exit = true, .cursor_save = true });
|
||||
self.charset = .{};
|
||||
self.eraseDisplay(.scrollback);
|
||||
self.eraseDisplay(.complete);
|
||||
self.eraseDisplay(alloc, .scrollback);
|
||||
self.eraseDisplay(alloc, .complete);
|
||||
self.modes = .{};
|
||||
self.flags = .{};
|
||||
self.tabstops.reset(0);
|
||||
self.screen.cursor = .{};
|
||||
self.screen.saved_cursor = .{};
|
||||
self.screen.selection = null;
|
||||
self.screen.kitty_keyboard = .{};
|
||||
self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1 };
|
||||
self.previous_char = null;
|
||||
self.pwd.clearRetainingCapacity();
|
||||
@ -2561,7 +2597,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" {
|
||||
try testing.expect(t.cursorIsAtPrompt());
|
||||
|
||||
// Secondary screen is never a prompt
|
||||
t.alternateScreen(.{});
|
||||
t.alternateScreen(alloc, .{});
|
||||
try testing.expect(!t.cursorIsAtPrompt());
|
||||
t.markSemanticPrompt(.prompt);
|
||||
try testing.expect(!t.cursorIsAtPrompt());
|
||||
|
137
src/terminal/apc.zig
Normal file
137
src/terminal/apc.zig
Normal file
@ -0,0 +1,137 @@
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const kitty_gfx = @import("kitty/graphics.zig");
|
||||
|
||||
const log = std.log.scoped(.terminal_apc);
|
||||
|
||||
/// APC command handler. This should be hooked into a terminal.Stream handler.
|
||||
/// The start/feed/end functions are meant to be called from the terminal.Stream
|
||||
/// apcStart, apcPut, and apcEnd functions, respectively.
|
||||
pub const Handler = struct {
|
||||
state: State = .{ .inactive = {} },
|
||||
|
||||
pub fn deinit(self: *Handler) void {
|
||||
self.state.deinit();
|
||||
}
|
||||
|
||||
pub fn start(self: *Handler) void {
|
||||
self.state.deinit();
|
||||
self.state = .{ .identify = {} };
|
||||
}
|
||||
|
||||
pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void {
|
||||
switch (self.state) {
|
||||
.inactive => unreachable,
|
||||
|
||||
// We're ignoring this APC command, likely because we don't
|
||||
// recognize it so there is no need to store the data in memory.
|
||||
.ignore => return,
|
||||
|
||||
// We identify the APC command by the first byte.
|
||||
.identify => {
|
||||
switch (byte) {
|
||||
// Kitty graphics protocol
|
||||
'G' => self.state = .{ .kitty = kitty_gfx.CommandParser.init(alloc) },
|
||||
|
||||
// Unknown
|
||||
else => self.state = .{ .ignore = {} },
|
||||
}
|
||||
},
|
||||
|
||||
.kitty => |*p| p.feed(byte) catch |err| {
|
||||
log.warn("kitty graphics protocol error: {}", .{err});
|
||||
self.state = .{ .ignore = {} };
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn end(self: *Handler) ?Command {
|
||||
defer {
|
||||
self.state.deinit();
|
||||
self.state = .{ .inactive = {} };
|
||||
}
|
||||
|
||||
return switch (self.state) {
|
||||
.inactive => unreachable,
|
||||
.ignore, .identify => null,
|
||||
.kitty => |*p| kitty: {
|
||||
const command = p.complete() catch |err| {
|
||||
log.warn("kitty graphics protocol error: {}", .{err});
|
||||
break :kitty null;
|
||||
};
|
||||
|
||||
break :kitty .{ .kitty = command };
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const State = union(enum) {
|
||||
/// We're not in the middle of an APC command yet.
|
||||
inactive: void,
|
||||
|
||||
/// We got an unrecognized APC sequence or the APC sequence we
|
||||
/// recognized became invalid. We're just dropping bytes.
|
||||
ignore: void,
|
||||
|
||||
/// We're waiting to identify the APC sequence. This is done by
|
||||
/// inspecting the first byte of the sequence.
|
||||
identify: void,
|
||||
|
||||
/// Kitty graphics protocol
|
||||
kitty: kitty_gfx.CommandParser,
|
||||
|
||||
pub fn deinit(self: *State) void {
|
||||
switch (self.*) {
|
||||
.inactive, .ignore, .identify => {},
|
||||
.kitty => |*v| v.deinit(),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/// Possible APC commands.
|
||||
pub const Command = union(enum) {
|
||||
kitty: kitty_gfx.Command,
|
||||
|
||||
pub fn deinit(self: *Command, alloc: Allocator) void {
|
||||
switch (self.*) {
|
||||
.kitty => |*v| v.deinit(alloc),
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
test "unknown APC command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
for ("Xabcdef1234") |c| h.feed(alloc, c);
|
||||
try testing.expect(h.end() == null);
|
||||
}
|
||||
|
||||
test "garbage Kitty command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
for ("Gabcdef1234") |c| h.feed(alloc, c);
|
||||
try testing.expect(h.end() == null);
|
||||
}
|
||||
|
||||
test "valid Kitty command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var h: Handler = .{};
|
||||
h.start();
|
||||
const input = "Gf=24,s=10,v=20,hello=world";
|
||||
for (input) |c| h.feed(alloc, c);
|
||||
|
||||
var cmd = h.end().?;
|
||||
defer cmd.deinit(alloc);
|
||||
try testing.expect(cmd == .kitty);
|
||||
}
|
@ -1,154 +1,8 @@
|
||||
//! Types and functions related to Kitty protocols.
|
||||
//!
|
||||
//! Documentation for the Kitty keyboard protocol:
|
||||
//! https://sw.kovidgoyal.net/kitty/keyboard-protocol/#progressive-enhancement
|
||||
|
||||
const std = @import("std");
|
||||
pub const graphics = @import("kitty/graphics.zig");
|
||||
pub usingnamespace @import("kitty/key.zig");
|
||||
|
||||
/// Stack for the key flags. This implements the push/pop behavior
|
||||
/// of the CSI > u and CSI < u sequences. We implement the stack as
|
||||
/// fixed size to avoid heap allocation.
|
||||
pub const KeyFlagStack = struct {
|
||||
const len = 8;
|
||||
|
||||
flags: [len]KeyFlags = .{.{}} ** len,
|
||||
idx: u3 = 0,
|
||||
|
||||
/// Return the current stack value
|
||||
pub fn current(self: KeyFlagStack) KeyFlags {
|
||||
return self.flags[self.idx];
|
||||
}
|
||||
|
||||
/// Perform the "set" operation as described in the spec for
|
||||
/// the CSI = u sequence.
|
||||
pub fn set(
|
||||
self: *KeyFlagStack,
|
||||
mode: KeySetMode,
|
||||
v: KeyFlags,
|
||||
) void {
|
||||
switch (mode) {
|
||||
.set => self.flags[self.idx] = v,
|
||||
.@"or" => self.flags[self.idx] = @bitCast(
|
||||
self.flags[self.idx].int() | v.int(),
|
||||
),
|
||||
.not => self.flags[self.idx] = @bitCast(
|
||||
self.flags[self.idx].int() & ~v.int(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new set of flags onto the stack. If the stack is full
|
||||
/// then the oldest entry is evicted.
|
||||
pub fn push(self: *KeyFlagStack, flags: KeyFlags) void {
|
||||
// Overflow and wrap around if we're full, which evicts
|
||||
// the oldest entry.
|
||||
self.idx +%= 1;
|
||||
self.flags[self.idx] = flags;
|
||||
}
|
||||
|
||||
/// Pop `n` entries from the stack. This will just wrap around
|
||||
/// if `n` is greater than the amount in the stack.
|
||||
pub fn pop(self: *KeyFlagStack, n: usize) void {
|
||||
// If n is more than our length then we just reset the stack.
|
||||
// This also avoids a DoS vector where a malicious client
|
||||
// could send a huge number of pop commands to waste cpu.
|
||||
if (n >= self.flags.len) {
|
||||
self.idx = 0;
|
||||
self.flags = .{.{}} ** len;
|
||||
return;
|
||||
}
|
||||
|
||||
for (0..n) |_| {
|
||||
self.flags[self.idx] = .{};
|
||||
self.idx -%= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we the overflow works as expected
|
||||
test {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.idx = stack.flags.len - 1;
|
||||
stack.idx +%= 1;
|
||||
try testing.expect(stack.idx == 0);
|
||||
|
||||
stack.idx = 0;
|
||||
stack.idx -%= 1;
|
||||
try testing.expect(stack.idx == stack.flags.len - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/// The possible flags for the Kitty keyboard protocol.
|
||||
pub const KeyFlags = packed struct(u5) {
|
||||
disambiguate: bool = false,
|
||||
report_events: bool = false,
|
||||
report_alternates: bool = false,
|
||||
report_all: bool = false,
|
||||
report_associated: bool = false,
|
||||
|
||||
pub fn int(self: KeyFlags) u5 {
|
||||
return @bitCast(self);
|
||||
}
|
||||
|
||||
// Its easy to get packed struct ordering wrong so this test checks.
|
||||
test {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(u5, 0b1),
|
||||
(KeyFlags{ .disambiguate = true }).int(),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(u5, 0b10),
|
||||
(KeyFlags{ .report_events = true }).int(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/// The possible modes for setting the key flags.
|
||||
pub const KeySetMode = enum { set, @"or", not };
|
||||
|
||||
test "KeyFlagStack: push pop" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.push(.{ .disambiguate = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.pop(1);
|
||||
try testing.expectEqual(KeyFlags{}, stack.current());
|
||||
}
|
||||
|
||||
test "KeyFlagStack: pop big number" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.pop(100);
|
||||
try testing.expectEqual(KeyFlags{}, stack.current());
|
||||
}
|
||||
|
||||
test "KeyFlagStack: set" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.set(.set, .{ .disambiguate = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.set(.@"or", .{ .report_events = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{
|
||||
.disambiguate = true,
|
||||
.report_events = true,
|
||||
},
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.set(.not, .{ .report_events = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
}
|
||||
|
22
src/terminal/kitty/graphics.zig
Normal file
22
src/terminal/kitty/graphics.zig
Normal file
@ -0,0 +1,22 @@
|
||||
//! Kitty graphics protocol support.
|
||||
//!
|
||||
//! Documentation:
|
||||
//! https://sw.kovidgoyal.net/kitty/graphics-protocol
|
||||
//!
|
||||
//! Unimplemented features that are still todo:
|
||||
//! - shared memory transmit
|
||||
//! - virtual placement w/ unicode
|
||||
//! - animation
|
||||
//!
|
||||
//! Performance:
|
||||
//! The performance of this particular subsystem of Ghostty is not great.
|
||||
//! We can avoid a lot more allocations, we can replace some C code (which
|
||||
//! implicitly allocates) with native Zig, we can improve the data structures
|
||||
//! to avoid repeated lookups, etc. I tried to avoid pessimization but my
|
||||
//! aim to ship a v1 of this implementation came at some cost. I learned a lot
|
||||
//! though and I think we can go back through and fix this up.
|
||||
|
||||
pub usingnamespace @import("graphics_command.zig");
|
||||
pub usingnamespace @import("graphics_exec.zig");
|
||||
pub usingnamespace @import("graphics_image.zig");
|
||||
pub usingnamespace @import("graphics_storage.zig");
|
979
src/terminal/kitty/graphics_command.zig
Normal file
979
src/terminal/kitty/graphics_command.zig
Normal file
@ -0,0 +1,979 @@
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
/// The key-value pairs for the control information for a command. The
|
||||
/// keys are always single characters and the values are either single
|
||||
/// characters or 32-bit unsigned integers.
|
||||
///
|
||||
/// For the value of this: if the value is a single printable ASCII character
|
||||
/// it is the ASCII code. Otherwise, it is parsed as a 32-bit unsigned integer.
|
||||
const KV = std.AutoHashMapUnmanaged(u8, u32);
|
||||
|
||||
/// Command parser parses the Kitty graphics protocol escape sequence.
|
||||
pub const CommandParser = struct {
|
||||
/// The memory used by the parser is stored in an arena because it is
|
||||
/// all freed at the end of the command.
|
||||
arena: ArenaAllocator,
|
||||
|
||||
/// This is the list of KV pairs that we're building up.
|
||||
kv: KV = .{},
|
||||
|
||||
/// This is used as a buffer to store the key/value of a KV pair.
|
||||
/// The value of a KV pair is at most a 32-bit integer which at most
|
||||
/// is 10 characters (4294967295).
|
||||
kv_temp: [10]u8 = undefined,
|
||||
kv_temp_len: u4 = 0,
|
||||
kv_current: u8 = 0, // Current kv key
|
||||
|
||||
/// This is the list of bytes that contains both KV data and final
|
||||
/// data. You shouldn't access this directly.
|
||||
data: std.ArrayList(u8),
|
||||
|
||||
/// Internal state for parsing.
|
||||
state: State = .control_key,
|
||||
|
||||
const State = enum {
|
||||
/// Parsing k/v pairs. The "ignore" variants are in that state
|
||||
/// but ignore any data because we know they're invalid.
|
||||
control_key,
|
||||
control_key_ignore,
|
||||
control_value,
|
||||
control_value_ignore,
|
||||
|
||||
/// We're parsing the data blob.
|
||||
data,
|
||||
};
|
||||
|
||||
/// Initialize the parser. The allocator given will be used for both
|
||||
/// temporary data and long-lived values such as the final image blob.
|
||||
pub fn init(alloc: Allocator) CommandParser {
|
||||
var arena = ArenaAllocator.init(alloc);
|
||||
errdefer arena.deinit();
|
||||
return .{
|
||||
.arena = arena,
|
||||
.data = std.ArrayList(u8).init(alloc),
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: *CommandParser) void {
|
||||
// We don't free the hash map because its in the arena
|
||||
self.arena.deinit();
|
||||
self.data.deinit();
|
||||
}
|
||||
|
||||
/// Feed a single byte to the parser.
|
||||
///
|
||||
/// The first byte to start parsing should be the byte immediately following
|
||||
/// the "G" in the APC sequence, i.e. "\x1b_G123" the first byte should
|
||||
/// be "1".
|
||||
pub fn feed(self: *CommandParser, c: u8) !void {
|
||||
switch (self.state) {
|
||||
.control_key => switch (c) {
|
||||
// '=' means the key is complete and we're moving to the value.
|
||||
'=' => if (self.kv_temp_len != 1) {
|
||||
// All control keys are a single character right now so
|
||||
// if we're not a single character just ignore follow-up
|
||||
// data.
|
||||
self.state = .control_value_ignore;
|
||||
self.kv_temp_len = 0;
|
||||
} else {
|
||||
self.kv_current = self.kv_temp[0];
|
||||
self.kv_temp_len = 0;
|
||||
self.state = .control_value;
|
||||
},
|
||||
|
||||
else => try self.accumulateValue(c, .control_key_ignore),
|
||||
},
|
||||
|
||||
.control_key_ignore => switch (c) {
|
||||
'=' => self.state = .control_value_ignore,
|
||||
else => {},
|
||||
},
|
||||
|
||||
.control_value => switch (c) {
|
||||
',' => try self.finishValue(.control_key), // move to next key
|
||||
';' => try self.finishValue(.data), // move to data
|
||||
else => try self.accumulateValue(c, .control_value_ignore),
|
||||
},
|
||||
|
||||
.control_value_ignore => switch (c) {
|
||||
',' => self.state = .control_key_ignore,
|
||||
';' => self.state = .data,
|
||||
else => {},
|
||||
},
|
||||
|
||||
.data => try self.data.append(c),
|
||||
}
|
||||
|
||||
// We always add to our data list because this is our stable
|
||||
// array of bytes that we'll reference everywhere else.
|
||||
}
|
||||
|
||||
/// Complete the parsing. This must be called after all the
|
||||
/// bytes have been fed to the parser.
|
||||
///
|
||||
/// The allocator given will be used for the long-lived data
|
||||
/// of the final command.
|
||||
pub fn complete(self: *CommandParser) !Command {
|
||||
switch (self.state) {
|
||||
// We can't ever end in the control key state and be valid.
|
||||
// This means the command looked something like "a=1,b"
|
||||
.control_key, .control_key_ignore => return error.InvalidFormat,
|
||||
|
||||
// Some commands (i.e. placements) end without extra data so
|
||||
// we end in the value state. i.e. "a=1,b=2"
|
||||
.control_value => try self.finishValue(.data),
|
||||
.control_value_ignore => {},
|
||||
|
||||
// Most commands end in data, i.e. "a=1,b=2;1234"
|
||||
.data => {},
|
||||
}
|
||||
|
||||
// Determine our action, which is always a single character.
|
||||
const action: u8 = action: {
|
||||
const value = self.kv.get('a') orelse break :action 't';
|
||||
const c = std.math.cast(u8, value) orelse return error.InvalidFormat;
|
||||
break :action c;
|
||||
};
|
||||
const control: Command.Control = switch (action) {
|
||||
'q' => .{ .query = try Transmission.parse(self.kv) },
|
||||
't' => .{ .transmit = try Transmission.parse(self.kv) },
|
||||
'T' => .{ .transmit_and_display = .{
|
||||
.transmission = try Transmission.parse(self.kv),
|
||||
.display = try Display.parse(self.kv),
|
||||
} },
|
||||
'p' => .{ .display = try Display.parse(self.kv) },
|
||||
'd' => .{ .delete = try Delete.parse(self.kv) },
|
||||
'f' => .{ .transmit_animation_frame = try AnimationFrameLoading.parse(self.kv) },
|
||||
'a' => .{ .control_animation = try AnimationControl.parse(self.kv) },
|
||||
'c' => .{ .compose_animation = try AnimationFrameComposition.parse(self.kv) },
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
|
||||
// Determine our quiet value
|
||||
const quiet: Command.Quiet = if (self.kv.get('q')) |v| quiet: {
|
||||
break :quiet switch (v) {
|
||||
0 => .no,
|
||||
1 => .ok,
|
||||
2 => .failures,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
} else .no;
|
||||
|
||||
return .{
|
||||
.control = control,
|
||||
.quiet = quiet,
|
||||
.data = if (self.data.items.len == 0) "" else data: {
|
||||
break :data try self.data.toOwnedSlice();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
fn accumulateValue(self: *CommandParser, c: u8, overflow_state: State) !void {
|
||||
const idx = self.kv_temp_len;
|
||||
self.kv_temp_len += 1;
|
||||
if (self.kv_temp_len > self.kv_temp.len) {
|
||||
self.state = overflow_state;
|
||||
self.kv_temp_len = 0;
|
||||
return;
|
||||
}
|
||||
self.kv_temp[idx] = c;
|
||||
}
|
||||
|
||||
fn finishValue(self: *CommandParser, next_state: State) !void {
|
||||
const alloc = self.arena.allocator();
|
||||
|
||||
// We can move states right away, we don't use it.
|
||||
self.state = next_state;
|
||||
|
||||
// Check for ASCII chars first
|
||||
if (self.kv_temp_len == 1) {
|
||||
const c = self.kv_temp[0];
|
||||
if (c < '0' or c > '9') {
|
||||
try self.kv.put(alloc, self.kv_current, @intCast(c));
|
||||
self.kv_temp_len = 0;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Only "z" is currently signed. This is a bit of a kloodge; if more
|
||||
// fields become signed we can rethink this but for now we parse
|
||||
// "z" as i32 then bitcast it to u32 then bitcast it back later.
|
||||
if (self.kv_current == 'z') {
|
||||
const v = try std.fmt.parseInt(i32, self.kv_temp[0..self.kv_temp_len], 10);
|
||||
try self.kv.put(alloc, self.kv_current, @bitCast(v));
|
||||
} else {
|
||||
const v = try std.fmt.parseInt(u32, self.kv_temp[0..self.kv_temp_len], 10);
|
||||
try self.kv.put(alloc, self.kv_current, v);
|
||||
}
|
||||
|
||||
// Clear our temp buffer
|
||||
self.kv_temp_len = 0;
|
||||
}
|
||||
};
|
||||
|
||||
/// Represents a possible response to a command.
|
||||
pub const Response = struct {
|
||||
id: u32 = 0,
|
||||
image_number: u32 = 0,
|
||||
placement_id: u32 = 0,
|
||||
message: []const u8 = "OK",
|
||||
|
||||
pub fn encode(self: Response, writer: anytype) !void {
|
||||
// We only encode a result if we have either an id or an image number.
|
||||
if (self.id == 0 and self.image_number == 0) return;
|
||||
|
||||
try writer.writeAll("\x1b_G");
|
||||
if (self.id > 0) {
|
||||
try writer.print("i={}", .{self.id});
|
||||
}
|
||||
if (self.image_number > 0) {
|
||||
if (self.id > 0) try writer.writeByte(',');
|
||||
try writer.print("I={}", .{self.image_number});
|
||||
}
|
||||
if (self.placement_id > 0) {
|
||||
try writer.print(",p={}", .{self.placement_id});
|
||||
}
|
||||
try writer.writeByte(';');
|
||||
try writer.writeAll(self.message);
|
||||
try writer.writeAll("\x1b\\");
|
||||
}
|
||||
|
||||
/// Returns true if this response is not an error.
|
||||
pub fn ok(self: Response) bool {
|
||||
return std.mem.eql(u8, self.message, "OK");
|
||||
}
|
||||
};
|
||||
|
||||
pub const Command = struct {
|
||||
control: Control,
|
||||
quiet: Quiet = .no,
|
||||
data: []const u8 = "",
|
||||
|
||||
pub const Action = enum {
|
||||
query, // q
|
||||
transmit, // t
|
||||
transmit_and_display, // T
|
||||
display, // p
|
||||
delete, // d
|
||||
transmit_animation_frame, // f
|
||||
control_animation, // a
|
||||
compose_animation, // c
|
||||
};
|
||||
|
||||
pub const Quiet = enum {
|
||||
no, // 0
|
||||
ok, // 1
|
||||
failures, // 2
|
||||
};
|
||||
|
||||
pub const Control = union(Action) {
|
||||
query: Transmission,
|
||||
transmit: Transmission,
|
||||
transmit_and_display: struct {
|
||||
transmission: Transmission,
|
||||
display: Display,
|
||||
},
|
||||
display: Display,
|
||||
delete: Delete,
|
||||
transmit_animation_frame: AnimationFrameLoading,
|
||||
control_animation: AnimationControl,
|
||||
compose_animation: AnimationFrameComposition,
|
||||
};
|
||||
|
||||
/// Take ownership over the data in this command. If the returned value
|
||||
/// has a length of zero, then the data was empty and need not be freed.
|
||||
pub fn toOwnedData(self: *Command) []const u8 {
|
||||
const result = self.data;
|
||||
self.data = "";
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Returns the transmission data if it has any.
|
||||
pub fn transmission(self: Command) ?Transmission {
|
||||
return switch (self.control) {
|
||||
.query => |t| t,
|
||||
.transmit => |t| t,
|
||||
.transmit_and_display => |t| t.transmission,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Returns the display data if it has any.
|
||||
pub fn display(self: Command) ?Display {
|
||||
return switch (self.control) {
|
||||
.display => |d| d,
|
||||
.transmit_and_display => |t| t.display,
|
||||
else => null,
|
||||
};
|
||||
}
|
||||
|
||||
pub fn deinit(self: Command, alloc: Allocator) void {
|
||||
if (self.data.len > 0) alloc.free(self.data);
|
||||
}
|
||||
};
|
||||
|
||||
pub const Transmission = struct {
|
||||
format: Format = .rgb, // f
|
||||
medium: Medium = .direct, // t
|
||||
width: u32 = 0, // s
|
||||
height: u32 = 0, // v
|
||||
size: u32 = 0, // S
|
||||
offset: u32 = 0, // O
|
||||
image_id: u32 = 0, // i
|
||||
image_number: u32 = 0, // I
|
||||
placement_id: u32 = 0, // p
|
||||
compression: Compression = .none, // o
|
||||
more_chunks: bool = false, // m
|
||||
|
||||
pub const Format = enum {
|
||||
rgb, // 24
|
||||
rgba, // 32
|
||||
png, // 100
|
||||
};
|
||||
|
||||
pub const Medium = enum {
|
||||
direct, // d
|
||||
file, // f
|
||||
temporary_file, // t
|
||||
shared_memory, // s
|
||||
};
|
||||
|
||||
pub const Compression = enum {
|
||||
none,
|
||||
zlib_deflate, // z
|
||||
};
|
||||
|
||||
fn parse(kv: KV) !Transmission {
|
||||
var result: Transmission = .{};
|
||||
if (kv.get('f')) |v| {
|
||||
result.format = switch (v) {
|
||||
24 => .rgb,
|
||||
32 => .rgba,
|
||||
100 => .png,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('t')) |v| {
|
||||
const c = std.math.cast(u8, v) orelse return error.InvalidFormat;
|
||||
result.medium = switch (c) {
|
||||
'd' => .direct,
|
||||
'f' => .file,
|
||||
't' => .temporary_file,
|
||||
's' => .shared_memory,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('s')) |v| {
|
||||
result.width = v;
|
||||
}
|
||||
|
||||
if (kv.get('v')) |v| {
|
||||
result.height = v;
|
||||
}
|
||||
|
||||
if (kv.get('S')) |v| {
|
||||
result.size = v;
|
||||
}
|
||||
|
||||
if (kv.get('O')) |v| {
|
||||
result.offset = v;
|
||||
}
|
||||
|
||||
if (kv.get('i')) |v| {
|
||||
result.image_id = v;
|
||||
}
|
||||
|
||||
if (kv.get('I')) |v| {
|
||||
result.image_number = v;
|
||||
}
|
||||
|
||||
if (kv.get('p')) |v| {
|
||||
result.placement_id = v;
|
||||
}
|
||||
|
||||
if (kv.get('o')) |v| {
|
||||
const c = std.math.cast(u8, v) orelse return error.InvalidFormat;
|
||||
result.compression = switch (c) {
|
||||
'z' => .zlib_deflate,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('m')) |v| {
|
||||
result.more_chunks = v > 0;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Display = struct {
|
||||
image_id: u32 = 0, // i
|
||||
image_number: u32 = 0, // I
|
||||
placement_id: u32 = 0, // p
|
||||
x: u32 = 0, // x
|
||||
y: u32 = 0, // y
|
||||
width: u32 = 0, // w
|
||||
height: u32 = 0, // h
|
||||
x_offset: u32 = 0, // X
|
||||
y_offset: u32 = 0, // Y
|
||||
columns: u32 = 0, // c
|
||||
rows: u32 = 0, // r
|
||||
cursor_movement: CursorMovement = .after, // C
|
||||
virtual_placement: bool = false, // U
|
||||
z: i32 = 0, // z
|
||||
|
||||
pub const CursorMovement = enum {
|
||||
after, // 0
|
||||
none, // 1
|
||||
};
|
||||
|
||||
fn parse(kv: KV) !Display {
|
||||
var result: Display = .{};
|
||||
|
||||
if (kv.get('i')) |v| {
|
||||
result.image_id = v;
|
||||
}
|
||||
|
||||
if (kv.get('I')) |v| {
|
||||
result.image_number = v;
|
||||
}
|
||||
|
||||
if (kv.get('p')) |v| {
|
||||
result.placement_id = v;
|
||||
}
|
||||
|
||||
if (kv.get('x')) |v| {
|
||||
result.x = v;
|
||||
}
|
||||
|
||||
if (kv.get('y')) |v| {
|
||||
result.y = v;
|
||||
}
|
||||
|
||||
if (kv.get('w')) |v| {
|
||||
result.width = v;
|
||||
}
|
||||
|
||||
if (kv.get('h')) |v| {
|
||||
result.height = v;
|
||||
}
|
||||
|
||||
if (kv.get('X')) |v| {
|
||||
result.x_offset = v;
|
||||
}
|
||||
|
||||
if (kv.get('Y')) |v| {
|
||||
result.y_offset = v;
|
||||
}
|
||||
|
||||
if (kv.get('c')) |v| {
|
||||
result.columns = v;
|
||||
}
|
||||
|
||||
if (kv.get('r')) |v| {
|
||||
result.rows = v;
|
||||
}
|
||||
|
||||
if (kv.get('C')) |v| {
|
||||
result.cursor_movement = switch (v) {
|
||||
0 => .after,
|
||||
1 => .none,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('U')) |v| {
|
||||
result.virtual_placement = switch (v) {
|
||||
0 => false,
|
||||
1 => true,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('z')) |v| {
|
||||
// We can bitcast here because of how we parse it earlier.
|
||||
result.z = @bitCast(v);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
pub const AnimationFrameLoading = struct {
|
||||
x: u32 = 0, // x
|
||||
y: u32 = 0, // y
|
||||
create_frame: u32 = 0, // c
|
||||
edit_frame: u32 = 0, // r
|
||||
gap_ms: u32 = 0, // z
|
||||
composition_mode: CompositionMode = .alpha_blend, // X
|
||||
background: Background = .{}, // Y
|
||||
|
||||
pub const Background = packed struct(u32) {
|
||||
r: u8 = 0,
|
||||
g: u8 = 0,
|
||||
b: u8 = 0,
|
||||
a: u8 = 0,
|
||||
};
|
||||
|
||||
fn parse(kv: KV) !AnimationFrameLoading {
|
||||
var result: AnimationFrameLoading = .{};
|
||||
|
||||
if (kv.get('x')) |v| {
|
||||
result.x = v;
|
||||
}
|
||||
|
||||
if (kv.get('y')) |v| {
|
||||
result.y = v;
|
||||
}
|
||||
|
||||
if (kv.get('c')) |v| {
|
||||
result.create_frame = v;
|
||||
}
|
||||
|
||||
if (kv.get('r')) |v| {
|
||||
result.edit_frame = v;
|
||||
}
|
||||
|
||||
if (kv.get('z')) |v| {
|
||||
result.gap_ms = v;
|
||||
}
|
||||
|
||||
if (kv.get('X')) |v| {
|
||||
result.composition_mode = switch (v) {
|
||||
0 => .alpha_blend,
|
||||
1 => .overwrite,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('Y')) |v| {
|
||||
result.background = @bitCast(v);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
pub const AnimationFrameComposition = struct {
|
||||
frame: u32 = 0, // c
|
||||
edit_frame: u32 = 0, // r
|
||||
x: u32 = 0, // x
|
||||
y: u32 = 0, // y
|
||||
width: u32 = 0, // w
|
||||
height: u32 = 0, // h
|
||||
left_edge: u32 = 0, // X
|
||||
top_edge: u32 = 0, // Y
|
||||
composition_mode: CompositionMode = .alpha_blend, // C
|
||||
|
||||
fn parse(kv: KV) !AnimationFrameComposition {
|
||||
var result: AnimationFrameComposition = .{};
|
||||
|
||||
if (kv.get('c')) |v| {
|
||||
result.frame = v;
|
||||
}
|
||||
|
||||
if (kv.get('r')) |v| {
|
||||
result.edit_frame = v;
|
||||
}
|
||||
|
||||
if (kv.get('x')) |v| {
|
||||
result.x = v;
|
||||
}
|
||||
|
||||
if (kv.get('y')) |v| {
|
||||
result.y = v;
|
||||
}
|
||||
|
||||
if (kv.get('w')) |v| {
|
||||
result.width = v;
|
||||
}
|
||||
|
||||
if (kv.get('h')) |v| {
|
||||
result.height = v;
|
||||
}
|
||||
|
||||
if (kv.get('X')) |v| {
|
||||
result.left_edge = v;
|
||||
}
|
||||
|
||||
if (kv.get('Y')) |v| {
|
||||
result.top_edge = v;
|
||||
}
|
||||
|
||||
if (kv.get('C')) |v| {
|
||||
result.composition_mode = switch (v) {
|
||||
0 => .alpha_blend,
|
||||
1 => .overwrite,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
pub const AnimationControl = struct {
|
||||
action: AnimationAction = .invalid, // s
|
||||
frame: u32 = 0, // r
|
||||
gap_ms: u32 = 0, // z
|
||||
current_frame: u32 = 0, // c
|
||||
loops: u32 = 0, // v
|
||||
|
||||
pub const AnimationAction = enum {
|
||||
invalid, // 0
|
||||
stop, // 1
|
||||
run_wait, // 2
|
||||
run, // 3
|
||||
};
|
||||
|
||||
fn parse(kv: KV) !AnimationControl {
|
||||
var result: AnimationControl = .{};
|
||||
|
||||
if (kv.get('s')) |v| {
|
||||
result.action = switch (v) {
|
||||
0 => .invalid,
|
||||
1 => .stop,
|
||||
2 => .run_wait,
|
||||
3 => .run,
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
|
||||
if (kv.get('r')) |v| {
|
||||
result.frame = v;
|
||||
}
|
||||
|
||||
if (kv.get('z')) |v| {
|
||||
result.gap_ms = v;
|
||||
}
|
||||
|
||||
if (kv.get('c')) |v| {
|
||||
result.current_frame = v;
|
||||
}
|
||||
|
||||
if (kv.get('v')) |v| {
|
||||
result.loops = v;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
|
||||
pub const Delete = union(enum) {
|
||||
// a/A
|
||||
all: bool,
|
||||
|
||||
// i/I
|
||||
id: struct {
|
||||
delete: bool = false, // uppercase
|
||||
image_id: u32 = 0, // i
|
||||
placement_id: u32 = 0, // p
|
||||
},
|
||||
|
||||
// n/N
|
||||
newest: struct {
|
||||
delete: bool = false, // uppercase
|
||||
image_number: u32 = 0, // I
|
||||
placement_id: u32 = 0, // p
|
||||
},
|
||||
|
||||
// c/C,
|
||||
intersect_cursor: bool,
|
||||
|
||||
// f/F
|
||||
animation_frames: bool,
|
||||
|
||||
// p/P
|
||||
intersect_cell: struct {
|
||||
delete: bool = false, // uppercase
|
||||
x: u32 = 0, // x
|
||||
y: u32 = 0, // y
|
||||
},
|
||||
|
||||
// q/Q
|
||||
intersect_cell_z: struct {
|
||||
delete: bool = false, // uppercase
|
||||
x: u32 = 0, // x
|
||||
y: u32 = 0, // y
|
||||
z: i32 = 0, // z
|
||||
},
|
||||
|
||||
// x/X
|
||||
column: struct {
|
||||
delete: bool = false, // uppercase
|
||||
x: u32 = 0, // x
|
||||
},
|
||||
|
||||
// y/Y
|
||||
row: struct {
|
||||
delete: bool = false, // uppercase
|
||||
y: u32 = 0, // y
|
||||
},
|
||||
|
||||
// z/Z
|
||||
z: struct {
|
||||
delete: bool = false, // uppercase
|
||||
z: i32 = 0, // z
|
||||
},
|
||||
|
||||
fn parse(kv: KV) !Delete {
|
||||
const what: u8 = what: {
|
||||
const value = kv.get('d') orelse break :what 'a';
|
||||
const c = std.math.cast(u8, value) orelse return error.InvalidFormat;
|
||||
break :what c;
|
||||
};
|
||||
|
||||
return switch (what) {
|
||||
'a', 'A' => .{ .all = what == 'A' },
|
||||
|
||||
'i', 'I' => blk: {
|
||||
var result: Delete = .{ .id = .{ .delete = what == 'I' } };
|
||||
if (kv.get('i')) |v| {
|
||||
result.id.image_id = v;
|
||||
}
|
||||
if (kv.get('p')) |v| {
|
||||
result.id.placement_id = v;
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
'n', 'N' => blk: {
|
||||
var result: Delete = .{ .newest = .{ .delete = what == 'N' } };
|
||||
if (kv.get('I')) |v| {
|
||||
result.newest.image_number = v;
|
||||
}
|
||||
if (kv.get('p')) |v| {
|
||||
result.newest.placement_id = v;
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
'c', 'C' => .{ .intersect_cursor = what == 'C' },
|
||||
|
||||
'f', 'F' => .{ .animation_frames = what == 'F' },
|
||||
|
||||
'p', 'P' => blk: {
|
||||
var result: Delete = .{ .intersect_cell = .{ .delete = what == 'P' } };
|
||||
if (kv.get('x')) |v| {
|
||||
result.intersect_cell.x = v;
|
||||
}
|
||||
if (kv.get('y')) |v| {
|
||||
result.intersect_cell.y = v;
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
'q', 'Q' => blk: {
|
||||
var result: Delete = .{ .intersect_cell_z = .{ .delete = what == 'Q' } };
|
||||
if (kv.get('x')) |v| {
|
||||
result.intersect_cell_z.x = v;
|
||||
}
|
||||
if (kv.get('y')) |v| {
|
||||
result.intersect_cell_z.y = v;
|
||||
}
|
||||
if (kv.get('z')) |v| {
|
||||
// We can bitcast here because of how we parse it earlier.
|
||||
result.intersect_cell_z.z = @bitCast(v);
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
'x', 'X' => blk: {
|
||||
var result: Delete = .{ .column = .{ .delete = what == 'X' } };
|
||||
if (kv.get('x')) |v| {
|
||||
result.column.x = v;
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
'y', 'Y' => blk: {
|
||||
var result: Delete = .{ .row = .{ .delete = what == 'Y' } };
|
||||
if (kv.get('y')) |v| {
|
||||
result.row.y = v;
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
'z', 'Z' => blk: {
|
||||
var result: Delete = .{ .z = .{ .delete = what == 'Z' } };
|
||||
if (kv.get('z')) |v| {
|
||||
// We can bitcast here because of how we parse it earlier.
|
||||
result.z.z = @bitCast(v);
|
||||
}
|
||||
|
||||
break :blk result;
|
||||
},
|
||||
|
||||
else => return error.InvalidFormat,
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
pub const CompositionMode = enum {
|
||||
alpha_blend, // 0
|
||||
overwrite, // 1
|
||||
};
|
||||
|
||||
test "transmission command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "f=24,s=10,v=20";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .transmit);
|
||||
const v = command.control.transmit;
|
||||
try testing.expectEqual(Transmission.Format.rgb, v.format);
|
||||
try testing.expectEqual(@as(u32, 10), v.width);
|
||||
try testing.expectEqual(@as(u32, 20), v.height);
|
||||
}
|
||||
|
||||
test "query command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "i=31,s=1,v=1,a=q,t=d,f=24;AAAA";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .query);
|
||||
const v = command.control.query;
|
||||
try testing.expectEqual(Transmission.Medium.direct, v.medium);
|
||||
try testing.expectEqual(@as(u32, 1), v.width);
|
||||
try testing.expectEqual(@as(u32, 1), v.height);
|
||||
try testing.expectEqual(@as(u32, 31), v.image_id);
|
||||
try testing.expectEqualStrings("AAAA", command.data);
|
||||
}
|
||||
|
||||
test "display command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "a=p,U=1,i=31,c=80,r=120";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .display);
|
||||
const v = command.control.display;
|
||||
try testing.expectEqual(@as(u32, 80), v.columns);
|
||||
try testing.expectEqual(@as(u32, 120), v.rows);
|
||||
try testing.expectEqual(@as(u32, 31), v.image_id);
|
||||
}
|
||||
|
||||
test "delete command" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "a=d,d=p,x=3,y=4";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .delete);
|
||||
const v = command.control.delete;
|
||||
try testing.expect(v == .intersect_cell);
|
||||
const dv = v.intersect_cell;
|
||||
try testing.expect(!dv.delete);
|
||||
try testing.expectEqual(@as(u32, 3), dv.x);
|
||||
try testing.expectEqual(@as(u32, 4), dv.y);
|
||||
}
|
||||
|
||||
test "ignore unknown keys (long)" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "f=24,s=10,v=20,hello=world";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .transmit);
|
||||
const v = command.control.transmit;
|
||||
try testing.expectEqual(Transmission.Format.rgb, v.format);
|
||||
try testing.expectEqual(@as(u32, 10), v.width);
|
||||
try testing.expectEqual(@as(u32, 20), v.height);
|
||||
}
|
||||
|
||||
test "ignore very long values" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var p = CommandParser.init(alloc);
|
||||
defer p.deinit();
|
||||
|
||||
const input = "f=24,s=10,v=2000000000000000000000000000000000000000";
|
||||
for (input) |c| try p.feed(c);
|
||||
const command = try p.complete();
|
||||
defer command.deinit(alloc);
|
||||
|
||||
try testing.expect(command.control == .transmit);
|
||||
const v = command.control.transmit;
|
||||
try testing.expectEqual(Transmission.Format.rgb, v.format);
|
||||
try testing.expectEqual(@as(u32, 10), v.width);
|
||||
try testing.expectEqual(@as(u32, 0), v.height);
|
||||
}
|
||||
|
||||
test "response: encode nothing without ID or image number" {
|
||||
const testing = std.testing;
|
||||
var buf: [1024]u8 = undefined;
|
||||
var fbs = std.io.fixedBufferStream(&buf);
|
||||
|
||||
var r: Response = .{};
|
||||
try r.encode(fbs.writer());
|
||||
try testing.expectEqualStrings("", fbs.getWritten());
|
||||
}
|
||||
|
||||
test "response: encode with only image id" {
|
||||
const testing = std.testing;
|
||||
var buf: [1024]u8 = undefined;
|
||||
var fbs = std.io.fixedBufferStream(&buf);
|
||||
|
||||
var r: Response = .{ .id = 4 };
|
||||
try r.encode(fbs.writer());
|
||||
try testing.expectEqualStrings("\x1b_Gi=4;OK\x1b\\", fbs.getWritten());
|
||||
}
|
||||
|
||||
test "response: encode with only image number" {
|
||||
const testing = std.testing;
|
||||
var buf: [1024]u8 = undefined;
|
||||
var fbs = std.io.fixedBufferStream(&buf);
|
||||
|
||||
var r: Response = .{ .image_number = 4 };
|
||||
try r.encode(fbs.writer());
|
||||
try testing.expectEqualStrings("\x1b_GI=4;OK\x1b\\", fbs.getWritten());
|
||||
}
|
||||
|
||||
test "response: encode with image ID and number" {
|
||||
const testing = std.testing;
|
||||
var buf: [1024]u8 = undefined;
|
||||
var fbs = std.io.fixedBufferStream(&buf);
|
||||
|
||||
var r: Response = .{ .id = 12, .image_number = 4 };
|
||||
try r.encode(fbs.writer());
|
||||
try testing.expectEqualStrings("\x1b_Gi=12,I=4;OK\x1b\\", fbs.getWritten());
|
||||
}
|
345
src/terminal/kitty/graphics_exec.zig
Normal file
345
src/terminal/kitty/graphics_exec.zig
Normal file
@ -0,0 +1,345 @@
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
|
||||
const renderer = @import("../../renderer.zig");
|
||||
const point = @import("../point.zig");
|
||||
const Terminal = @import("../Terminal.zig");
|
||||
const command = @import("graphics_command.zig");
|
||||
const image = @import("graphics_image.zig");
|
||||
const Command = command.Command;
|
||||
const Response = command.Response;
|
||||
const LoadingImage = image.LoadingImage;
|
||||
const Image = image.Image;
|
||||
const ImageStorage = @import("graphics_storage.zig").ImageStorage;
|
||||
|
||||
const log = std.log.scoped(.kitty_gfx);
|
||||
|
||||
/// Execute a Kitty graphics command against the given terminal. This
|
||||
/// will never fail, but the response may indicate an error and the
|
||||
/// terminal state may not be updated to reflect the command. This will
|
||||
/// never put the terminal in an unrecoverable state, however.
|
||||
///
|
||||
/// The allocator must be the same allocator that was used to build
|
||||
/// the command.
|
||||
pub fn execute(
|
||||
alloc: Allocator,
|
||||
terminal: *Terminal,
|
||||
cmd: *Command,
|
||||
) ?Response {
|
||||
// If storage is disabled then we disable the full protocol. This means
|
||||
// we don't even respond to queries so the terminal completely acts as
|
||||
// if this feature is not supported.
|
||||
if (!terminal.screen.kitty_images.enabled()) {
|
||||
log.debug("kitty graphics requested but disabled", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only Metal supports rendering the images, right now.
|
||||
if (comptime renderer.Renderer != renderer.Metal) {
|
||||
log.warn("kitty graphics not supported on this renderer", .{});
|
||||
return null;
|
||||
}
|
||||
|
||||
log.debug("executing kitty graphics command: quiet={} control={}", .{
|
||||
cmd.quiet,
|
||||
cmd.control,
|
||||
});
|
||||
|
||||
const resp_: ?Response = switch (cmd.control) {
|
||||
.query => query(alloc, cmd),
|
||||
.transmit, .transmit_and_display => transmit(alloc, terminal, cmd),
|
||||
.display => display(alloc, terminal, cmd),
|
||||
.delete => delete(alloc, terminal, cmd),
|
||||
|
||||
.transmit_animation_frame,
|
||||
.control_animation,
|
||||
.compose_animation,
|
||||
=> .{ .message = "ERROR: unimplemented action" },
|
||||
};
|
||||
|
||||
// Handle the quiet settings
|
||||
if (resp_) |resp| {
|
||||
if (!resp.ok()) {
|
||||
log.warn("erroneous kitty graphics response: {s}", .{resp.message});
|
||||
}
|
||||
|
||||
return switch (cmd.quiet) {
|
||||
.no => resp,
|
||||
.ok => if (resp.ok()) null else resp,
|
||||
.failures => null,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/// Execute a "query" command.
|
||||
///
|
||||
/// This command is used to attempt to load an image and respond with
|
||||
/// success/error but does not persist any of the command to the terminal
|
||||
/// state.
|
||||
fn query(alloc: Allocator, cmd: *Command) Response {
|
||||
const t = cmd.control.query;
|
||||
|
||||
// Query requires image ID. We can't actually send a response without
|
||||
// an image ID either but we return an error and this will be logged
|
||||
// downstream.
|
||||
if (t.image_id == 0) {
|
||||
return .{ .message = "EINVAL: image ID required" };
|
||||
}
|
||||
|
||||
// Build a partial response to start
|
||||
var result: Response = .{
|
||||
.id = t.image_id,
|
||||
.image_number = t.image_number,
|
||||
.placement_id = t.placement_id,
|
||||
};
|
||||
|
||||
// Attempt to load the image. If we cannot, then set an appropriate error.
|
||||
var loading = LoadingImage.init(alloc, cmd) catch |err| {
|
||||
encodeError(&result, err);
|
||||
return result;
|
||||
};
|
||||
loading.deinit(alloc);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Transmit image data.
|
||||
///
|
||||
/// This loads the image, validates it, and puts it into the terminal
|
||||
/// screen storage. It does not display the image.
|
||||
fn transmit(
|
||||
alloc: Allocator,
|
||||
terminal: *Terminal,
|
||||
cmd: *Command,
|
||||
) Response {
|
||||
const t = cmd.transmission().?;
|
||||
var result: Response = .{
|
||||
.id = t.image_id,
|
||||
.image_number = t.image_number,
|
||||
.placement_id = t.placement_id,
|
||||
};
|
||||
if (t.image_id > 0 and t.image_number > 0) {
|
||||
return .{ .message = "EINVAL: image ID and number are mutually exclusive" };
|
||||
}
|
||||
|
||||
const load = loadAndAddImage(alloc, terminal, cmd) catch |err| {
|
||||
encodeError(&result, err);
|
||||
return result;
|
||||
};
|
||||
errdefer load.image.deinit(alloc);
|
||||
|
||||
// If we're also displaying, then do that now. This function does
|
||||
// both transmit and transmit and display. The display might also be
|
||||
// deferred if it is multi-chunk.
|
||||
if (load.display) |d| {
|
||||
assert(!load.more);
|
||||
var d_copy = d;
|
||||
d_copy.image_id = load.image.id;
|
||||
return display(alloc, terminal, &.{
|
||||
.control = .{ .display = d_copy },
|
||||
.quiet = cmd.quiet,
|
||||
});
|
||||
}
|
||||
|
||||
// If there are more chunks expected we do not respond.
|
||||
if (load.more) return .{};
|
||||
|
||||
// After the image is added, set the ID in case it changed
|
||||
result.id = load.image.id;
|
||||
|
||||
// If the original request had an image number, then we respond.
|
||||
// Otherwise, we don't respond.
|
||||
if (load.image.number == 0) return .{};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Display a previously transmitted image.
|
||||
fn display(
|
||||
alloc: Allocator,
|
||||
terminal: *Terminal,
|
||||
cmd: *const Command,
|
||||
) Response {
|
||||
const d = cmd.display().?;
|
||||
|
||||
// Display requires image ID or number.
|
||||
if (d.image_id == 0 and d.image_number == 0) {
|
||||
return .{ .message = "EINVAL: image ID or number required" };
|
||||
}
|
||||
|
||||
// Build up our response
|
||||
var result: Response = .{
|
||||
.id = d.image_id,
|
||||
.image_number = d.image_number,
|
||||
.placement_id = d.placement_id,
|
||||
};
|
||||
|
||||
// Verify the requested image exists if we have an ID
|
||||
const storage = &terminal.screen.kitty_images;
|
||||
const img_: ?Image = if (d.image_id != 0)
|
||||
storage.imageById(d.image_id)
|
||||
else
|
||||
storage.imageByNumber(d.image_number);
|
||||
const img = img_ orelse {
|
||||
result.message = "EINVAL: image not found";
|
||||
return result;
|
||||
};
|
||||
|
||||
// Make sure our response has the image id in case we looked up by number
|
||||
result.id = img.id;
|
||||
|
||||
// Determine the screen point for the placement.
|
||||
const placement_point = (point.Viewport{
|
||||
.x = terminal.screen.cursor.x,
|
||||
.y = terminal.screen.cursor.y,
|
||||
}).toScreen(&terminal.screen);
|
||||
|
||||
// Add the placement
|
||||
const p: ImageStorage.Placement = .{
|
||||
.point = placement_point,
|
||||
.x_offset = d.x_offset,
|
||||
.y_offset = d.y_offset,
|
||||
.source_x = d.x,
|
||||
.source_y = d.y,
|
||||
.source_width = d.width,
|
||||
.source_height = d.height,
|
||||
.columns = d.columns,
|
||||
.rows = d.rows,
|
||||
.z = d.z,
|
||||
};
|
||||
storage.addPlacement(alloc, img.id, d.placement_id, p) catch |err| {
|
||||
encodeError(&result, err);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Cursor needs to move after placement
|
||||
switch (d.cursor_movement) {
|
||||
.none => {},
|
||||
.after => {
|
||||
const rect = p.rect(img, terminal);
|
||||
|
||||
// We can do better by doing this with pure internal screen state
|
||||
// but this handles scroll regions.
|
||||
const height = rect.bottom_right.y - rect.top_left.y;
|
||||
for (0..height) |_| terminal.index() catch |err| {
|
||||
log.warn("failed to move cursor: {}", .{err});
|
||||
break;
|
||||
};
|
||||
|
||||
terminal.setCursorPos(
|
||||
terminal.screen.cursor.y,
|
||||
rect.bottom_right.x + 1,
|
||||
);
|
||||
},
|
||||
}
|
||||
|
||||
// Display does not result in a response on success
|
||||
return .{};
|
||||
}
|
||||
|
||||
/// Display a previously transmitted image.
|
||||
fn delete(
|
||||
alloc: Allocator,
|
||||
terminal: *Terminal,
|
||||
cmd: *Command,
|
||||
) Response {
|
||||
const storage = &terminal.screen.kitty_images;
|
||||
storage.delete(alloc, terminal, cmd.control.delete);
|
||||
|
||||
// Delete never responds on success
|
||||
return .{};
|
||||
}
|
||||
|
||||
fn loadAndAddImage(
|
||||
alloc: Allocator,
|
||||
terminal: *Terminal,
|
||||
cmd: *Command,
|
||||
) !struct {
|
||||
image: Image,
|
||||
more: bool = false,
|
||||
display: ?command.Display = null,
|
||||
} {
|
||||
const t = cmd.transmission().?;
|
||||
const storage = &terminal.screen.kitty_images;
|
||||
|
||||
// Determine our image. This also handles chunking and early exit.
|
||||
var loading: LoadingImage = if (storage.loading) |loading| loading: {
|
||||
// Note: we do NOT want to call "cmd.toOwnedData" here because
|
||||
// we're _copying_ the data. We want the command data to be freed.
|
||||
try loading.addData(alloc, cmd.data);
|
||||
|
||||
// If we have more then we're done
|
||||
if (t.more_chunks) return .{ .image = loading.image, .more = true };
|
||||
|
||||
// We have no more chunks. We're going to be completing the
|
||||
// image so we want to destroy the pointer to the loading
|
||||
// image and copy it out.
|
||||
defer {
|
||||
alloc.destroy(loading);
|
||||
storage.loading = null;
|
||||
}
|
||||
|
||||
break :loading loading.*;
|
||||
} else try LoadingImage.init(alloc, cmd);
|
||||
|
||||
// We only want to deinit on error. If we're chunking, then we don't
|
||||
// want to deinit at all. If we're not chunking, then we'll deinit
|
||||
// after we've copied the image out.
|
||||
errdefer loading.deinit(alloc);
|
||||
|
||||
// If the image has no ID, we assign one
|
||||
if (loading.image.id == 0) {
|
||||
loading.image.id = storage.next_id;
|
||||
storage.next_id +%= 1;
|
||||
}
|
||||
|
||||
// If this is chunked, this is the beginning of a new chunked transmission.
|
||||
// (We checked for an in-progress chunk above.)
|
||||
if (t.more_chunks) {
|
||||
// We allocate the pointer on the heap because its rare and we
|
||||
// don't want to always pay the memory cost to keep it around.
|
||||
const loading_ptr = try alloc.create(LoadingImage);
|
||||
errdefer alloc.destroy(loading_ptr);
|
||||
loading_ptr.* = loading;
|
||||
storage.loading = loading_ptr;
|
||||
return .{ .image = loading.image, .more = true };
|
||||
}
|
||||
|
||||
// Dump the image data before it is decompressed
|
||||
// loading.debugDump() catch unreachable;
|
||||
|
||||
// Validate and store our image
|
||||
var img = try loading.complete(alloc);
|
||||
errdefer img.deinit(alloc);
|
||||
try storage.addImage(alloc, img);
|
||||
|
||||
// Get our display settings
|
||||
const display_ = loading.display;
|
||||
|
||||
// Ensure we deinit the loading state because we're done. The image
|
||||
// won't be deinit because of "complete" above.
|
||||
loading.deinit(alloc);
|
||||
|
||||
return .{ .image = img, .display = display_ };
|
||||
}
|
||||
|
||||
const EncodeableError = Image.Error || Allocator.Error;
|
||||
|
||||
/// Encode an error code into a message for a response.
|
||||
fn encodeError(r: *Response, err: EncodeableError) void {
|
||||
switch (err) {
|
||||
error.OutOfMemory => r.message = "ENOMEM: out of memory",
|
||||
error.InternalError => r.message = "EINVAL: internal error",
|
||||
error.InvalidData => r.message = "EINVAL: invalid data",
|
||||
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
|
||||
error.FilePathTooLong => r.message = "EINVAL: file path too long",
|
||||
error.TemporaryFileNotInTempDir => r.message = "EINVAL: temporary file not in temp dir",
|
||||
error.UnsupportedFormat => r.message = "EINVAL: unsupported format",
|
||||
error.UnsupportedMedium => r.message = "EINVAL: unsupported medium",
|
||||
error.UnsupportedDepth => r.message = "EINVAL: unsupported pixel depth",
|
||||
error.DimensionsRequired => r.message = "EINVAL: dimensions required",
|
||||
error.DimensionsTooLarge => r.message = "EINVAL: dimensions too large",
|
||||
}
|
||||
}
|
764
src/terminal/kitty/graphics_image.zig
Normal file
764
src/terminal/kitty/graphics_image.zig
Normal file
@ -0,0 +1,764 @@
|
||||
const std = @import("std");
|
||||
const builtin = @import("builtin");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const command = @import("graphics_command.zig");
|
||||
const point = @import("../point.zig");
|
||||
const internal_os = @import("../../os/main.zig");
|
||||
const stb = @import("../../stb/main.zig");
|
||||
|
||||
const log = std.log.scoped(.kitty_gfx);
|
||||
|
||||
/// Maximum width or height of an image. Taken directly from Kitty.
|
||||
const max_dimension = 10000;
|
||||
|
||||
/// Maximum size in bytes, taken from Kitty.
|
||||
const max_size = 400 * 1024 * 1024; // 400MB
|
||||
|
||||
/// An image that is still being loaded. The image should be initialized
|
||||
/// using init on the first chunk and then addData for each subsequent
|
||||
/// chunk. Once all chunks have been added, complete should be called
|
||||
/// to finalize the image.
|
||||
pub const LoadingImage = struct {
|
||||
/// The in-progress image. The first chunk must have all the metadata
|
||||
/// so this comes from that initially.
|
||||
image: Image,
|
||||
|
||||
/// The data that is being built up.
|
||||
data: std.ArrayListUnmanaged(u8) = .{},
|
||||
|
||||
/// This is non-null when a transmit and display command is given
|
||||
/// so that we display the image after it is fully loaded.
|
||||
display: ?command.Display = null,
|
||||
|
||||
/// Initialize a chunked immage from the first image transmission.
|
||||
/// If this is a multi-chunk image, this should only be the FIRST
|
||||
/// chunk.
|
||||
pub fn init(alloc: Allocator, cmd: *command.Command) !LoadingImage {
|
||||
// Build our initial image from the properties sent via the control.
|
||||
// These can be overwritten by the data loading process. For example,
|
||||
// PNG loading sets the width/height from the data.
|
||||
const t = cmd.transmission().?;
|
||||
var result: LoadingImage = .{
|
||||
.image = .{
|
||||
.id = t.image_id,
|
||||
.number = t.image_number,
|
||||
.width = t.width,
|
||||
.height = t.height,
|
||||
.compression = t.compression,
|
||||
.format = t.format,
|
||||
},
|
||||
|
||||
.display = cmd.display(),
|
||||
};
|
||||
|
||||
// Special case for the direct medium, we just add it directly
|
||||
// which will handle copying the data, base64 decoding, etc.
|
||||
if (t.medium == .direct) {
|
||||
try result.addData(alloc, cmd.data);
|
||||
return result;
|
||||
}
|
||||
|
||||
// For every other medium, we'll need to at least base64 decode
|
||||
// the data to make it useful so let's do that. Also, all the data
|
||||
// has to be path data so we can put it in a stack-allocated buffer.
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const Base64Decoder = std.base64.standard.Decoder;
|
||||
const size = Base64Decoder.calcSizeForSlice(cmd.data) catch |err| {
|
||||
log.warn("failed to calculate base64 size for file path: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
if (size > buf.len) return error.FilePathTooLong;
|
||||
Base64Decoder.decode(&buf, cmd.data) catch |err| {
|
||||
log.warn("failed to decode base64 data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
var abs_buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const path = std.os.realpath(buf[0..size], &abs_buf) catch |err| {
|
||||
log.warn("failed to get absolute path: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
|
||||
// Depending on the medium, load the data from the path.
|
||||
switch (t.medium) {
|
||||
.direct => unreachable, // handled above
|
||||
.file => try result.readFile(.file, alloc, t, path),
|
||||
.temporary_file => try result.readFile(.temporary_file, alloc, t, path),
|
||||
.shared_memory => try result.readSharedMemory(alloc, t, path),
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Reads the data from a shared memory segment.
|
||||
fn readSharedMemory(
|
||||
self: *LoadingImage,
|
||||
alloc: Allocator,
|
||||
t: command.Transmission,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
// We require libc for this for shm_open
|
||||
if (comptime !builtin.link_libc) return error.UnsupportedMedium;
|
||||
|
||||
// Todo: support shared memory
|
||||
_ = self;
|
||||
_ = alloc;
|
||||
_ = t;
|
||||
_ = path;
|
||||
return error.UnsupportedMedium;
|
||||
}
|
||||
|
||||
/// Reads the data from a temporary file and returns it. This allocates
|
||||
/// and does not free any of the data, so the caller must free it.
|
||||
///
|
||||
/// This will also delete the temporary file if it is in a safe location.
|
||||
fn readFile(
|
||||
self: *LoadingImage,
|
||||
comptime medium: command.Transmission.Medium,
|
||||
alloc: Allocator,
|
||||
t: command.Transmission,
|
||||
path: []const u8,
|
||||
) !void {
|
||||
switch (medium) {
|
||||
.file, .temporary_file => {},
|
||||
else => @compileError("readFile only supports file and temporary_file"),
|
||||
}
|
||||
|
||||
// Verify file seems "safe". This is logic copied directly from Kitty,
|
||||
// mostly. This is really rough but it will catch obvious bad actors.
|
||||
if (std.mem.startsWith(u8, path, "/proc/") or
|
||||
std.mem.startsWith(u8, path, "/sys/") or
|
||||
(std.mem.startsWith(u8, path, "/dev/") and
|
||||
!std.mem.startsWith(u8, path, "/dev/shm/")))
|
||||
{
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
// Temporary file logic
|
||||
if (medium == .temporary_file) {
|
||||
if (!isPathInTempDir(path)) return error.TemporaryFileNotInTempDir;
|
||||
}
|
||||
defer if (medium == .temporary_file) {
|
||||
std.os.unlink(path) catch |err| {
|
||||
log.warn("failed to delete temporary file: {}", .{err});
|
||||
};
|
||||
};
|
||||
|
||||
var file = std.fs.cwd().openFile(path, .{}) catch |err| {
|
||||
log.warn("failed to open temporary file: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
defer file.close();
|
||||
|
||||
// File must be a regular file
|
||||
if (file.stat()) |stat| {
|
||||
if (stat.kind != .file) {
|
||||
log.warn("file is not a regular file kind={}", .{stat.kind});
|
||||
return error.InvalidData;
|
||||
}
|
||||
} else |err| {
|
||||
log.warn("failed to stat file: {}", .{err});
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
if (t.offset > 0) {
|
||||
file.seekTo(@intCast(t.offset)) catch |err| {
|
||||
log.warn("failed to seek to offset {}: {}", .{ t.offset, err });
|
||||
return error.InvalidData;
|
||||
};
|
||||
}
|
||||
|
||||
var buf_reader = std.io.bufferedReader(file.reader());
|
||||
const reader = buf_reader.reader();
|
||||
|
||||
// Read the file
|
||||
var managed = std.ArrayList(u8).init(alloc);
|
||||
errdefer managed.deinit();
|
||||
const size: usize = if (t.size > 0) @min(t.size, max_size) else max_size;
|
||||
reader.readAllArrayList(&managed, size) catch |err| {
|
||||
log.warn("failed to read temporary file: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
|
||||
// Set our data
|
||||
assert(self.data.items.len == 0);
|
||||
self.data = .{ .items = managed.items, .capacity = managed.capacity };
|
||||
}
|
||||
|
||||
/// Returns true if path appears to be in a temporary directory.
|
||||
/// Copies logic from Kitty.
|
||||
fn isPathInTempDir(path: []const u8) bool {
|
||||
if (std.mem.startsWith(u8, path, "/tmp")) return true;
|
||||
if (std.mem.startsWith(u8, path, "/dev/shm")) return true;
|
||||
if (internal_os.tmpDir()) |dir| {
|
||||
if (std.mem.startsWith(u8, path, dir)) return true;
|
||||
|
||||
// The temporary dir is sometimes a symlink. On macOS for
|
||||
// example /tmp is /private/var/...
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
if (std.os.realpath(dir, &buf)) |real_dir| {
|
||||
if (std.mem.startsWith(u8, path, real_dir)) return true;
|
||||
} else |_| {}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
pub fn deinit(self: *LoadingImage, alloc: Allocator) void {
|
||||
self.image.deinit(alloc);
|
||||
self.data.deinit(alloc);
|
||||
}
|
||||
|
||||
pub fn destroy(self: *LoadingImage, alloc: Allocator) void {
|
||||
self.deinit(alloc);
|
||||
alloc.destroy(self);
|
||||
}
|
||||
|
||||
/// Adds a chunk of base64-encoded data to the image. Use this if the
|
||||
/// image is coming in chunks (the "m" parameter in the protocol).
|
||||
pub fn addData(self: *LoadingImage, alloc: Allocator, data: []const u8) !void {
|
||||
// If no data, skip
|
||||
if (data.len == 0) return;
|
||||
|
||||
// Grow our array list by size capacity if it needs it
|
||||
const Base64Decoder = std.base64.standard.Decoder;
|
||||
const size = Base64Decoder.calcSizeForSlice(data) catch |err| {
|
||||
log.warn("failed to calculate size for base64 data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
};
|
||||
|
||||
// If our data would get too big, return an error
|
||||
if (self.data.items.len + size > max_size) {
|
||||
log.warn("image data too large max_size={}", .{max_size});
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
try self.data.ensureUnusedCapacity(alloc, size);
|
||||
|
||||
// We decode directly into the arraylist
|
||||
const start_i = self.data.items.len;
|
||||
self.data.items.len = start_i + size;
|
||||
const buf = self.data.items[start_i..];
|
||||
Base64Decoder.decode(buf, data) catch |err| switch (err) {
|
||||
// We have to ignore invalid padding because lots of encoders
|
||||
// add the wrong padding. Since we validate image data later
|
||||
// (PNG decode or simple dimensions check), we can ignore this.
|
||||
error.InvalidPadding => {},
|
||||
|
||||
else => {
|
||||
log.warn("failed to decode base64 data: {}", .{err});
|
||||
return error.InvalidData;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// Complete the chunked image, returning a completed image.
|
||||
pub fn complete(self: *LoadingImage, alloc: Allocator) !Image {
|
||||
const img = &self.image;
|
||||
|
||||
// Decompress the data if it is compressed.
|
||||
try self.decompress(alloc);
|
||||
|
||||
// Decode the png if we have to
|
||||
if (img.format == .png) try self.decodePng(alloc);
|
||||
|
||||
// Validate our dimensions.
|
||||
if (img.width == 0 or img.height == 0) return error.DimensionsRequired;
|
||||
if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge;
|
||||
|
||||
// Data length must be what we expect
|
||||
const bpp: u32 = switch (img.format) {
|
||||
.rgb => 3,
|
||||
.rgba => 4,
|
||||
.png => unreachable, // png should be decoded by here
|
||||
};
|
||||
const expected_len = img.width * img.height * bpp;
|
||||
const actual_len = self.data.items.len;
|
||||
if (actual_len != expected_len) {
|
||||
std.log.warn(
|
||||
"unexpected length image id={} width={} height={} bpp={} expected_len={} actual_len={}",
|
||||
.{ img.id, img.width, img.height, bpp, expected_len, actual_len },
|
||||
);
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
// Set our time
|
||||
self.image.transmit_time = std.time.Instant.now() catch |err| {
|
||||
log.warn("failed to get time: {}", .{err});
|
||||
return error.InternalError;
|
||||
};
|
||||
|
||||
// Everything looks good, copy the image data over.
|
||||
var result = self.image;
|
||||
result.data = try self.data.toOwnedSlice(alloc);
|
||||
errdefer result.deinit(alloc);
|
||||
self.image = .{};
|
||||
return result;
|
||||
}
|
||||
|
||||
/// Debug function to write the data to a file. This is useful for
|
||||
/// capturing some test data for unit tests.
|
||||
pub fn debugDump(self: LoadingImage) !void {
|
||||
if (comptime builtin.mode != .Debug) @compileError("debugDump in non-debug");
|
||||
|
||||
var buf: [1024]u8 = undefined;
|
||||
const filename = try std.fmt.bufPrint(
|
||||
&buf,
|
||||
"image-{s}-{s}-{d}x{d}-{}.data",
|
||||
.{
|
||||
@tagName(self.image.format),
|
||||
@tagName(self.image.compression),
|
||||
self.image.width,
|
||||
self.image.height,
|
||||
self.image.id,
|
||||
},
|
||||
);
|
||||
const cwd = std.fs.cwd();
|
||||
const f = try cwd.createFile(filename, .{});
|
||||
defer f.close();
|
||||
|
||||
const writer = f.writer();
|
||||
try writer.writeAll(self.data.items);
|
||||
}
|
||||
|
||||
/// Decompress the data in-place.
|
||||
fn decompress(self: *LoadingImage, alloc: Allocator) !void {
|
||||
return switch (self.image.compression) {
|
||||
.none => {},
|
||||
.zlib_deflate => self.decompressZlib(alloc),
|
||||
};
|
||||
}
|
||||
|
||||
fn decompressZlib(self: *LoadingImage, alloc: Allocator) !void {
|
||||
// Open our zlib stream
|
||||
var fbs = std.io.fixedBufferStream(self.data.items);
|
||||
var stream = std.compress.zlib.decompressStream(alloc, fbs.reader()) catch |err| {
|
||||
log.warn("zlib decompression failed: {}", .{err});
|
||||
return error.DecompressionFailed;
|
||||
};
|
||||
defer stream.deinit();
|
||||
|
||||
// Write it to an array list
|
||||
var list = std.ArrayList(u8).init(alloc);
|
||||
errdefer list.deinit();
|
||||
stream.reader().readAllArrayList(&list, max_size) catch |err| {
|
||||
log.warn("failed to read decompressed data: {}", .{err});
|
||||
return error.DecompressionFailed;
|
||||
};
|
||||
|
||||
// Empty our current data list, take ownership over managed array list
|
||||
self.data.deinit(alloc);
|
||||
self.data = .{ .items = list.items, .capacity = list.capacity };
|
||||
|
||||
// Make sure we note that our image is no longer compressed
|
||||
self.image.compression = .none;
|
||||
}
|
||||
|
||||
/// Decode the data as PNG. This will also updated the image dimensions.
|
||||
fn decodePng(self: *LoadingImage, alloc: Allocator) !void {
|
||||
assert(self.image.format == .png);
|
||||
|
||||
// Decode PNG
|
||||
var width: c_int = 0;
|
||||
var height: c_int = 0;
|
||||
var bpp: c_int = 0;
|
||||
const data = stb.stbi_load_from_memory(
|
||||
self.data.items.ptr,
|
||||
@intCast(self.data.items.len),
|
||||
&width,
|
||||
&height,
|
||||
&bpp,
|
||||
0,
|
||||
) orelse return error.InvalidData;
|
||||
defer stb.stbi_image_free(data);
|
||||
const len: usize = @intCast(width * height * bpp);
|
||||
if (len > max_size) {
|
||||
log.warn("png image too large size={} max_size={}", .{ len, max_size });
|
||||
return error.InvalidData;
|
||||
}
|
||||
|
||||
// Validate our bpp
|
||||
if (bpp != 3 and bpp != 4) return error.UnsupportedDepth;
|
||||
|
||||
// Replace our data
|
||||
self.data.deinit(alloc);
|
||||
self.data = .{};
|
||||
try self.data.ensureUnusedCapacity(alloc, len);
|
||||
try self.data.appendSlice(alloc, data[0..len]);
|
||||
|
||||
// Store updated image dimensions
|
||||
self.image.width = @intCast(width);
|
||||
self.image.height = @intCast(height);
|
||||
self.image.format = switch (bpp) {
|
||||
3 => .rgb,
|
||||
4 => .rgba,
|
||||
else => unreachable, // validated above
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/// Image represents a single fully loaded image.
|
||||
pub const Image = struct {
|
||||
id: u32 = 0,
|
||||
number: u32 = 0,
|
||||
width: u32 = 0,
|
||||
height: u32 = 0,
|
||||
format: command.Transmission.Format = .rgb,
|
||||
compression: command.Transmission.Compression = .none,
|
||||
data: []const u8 = "",
|
||||
transmit_time: std.time.Instant = undefined,
|
||||
|
||||
pub const Error = error{
|
||||
InternalError,
|
||||
InvalidData,
|
||||
DecompressionFailed,
|
||||
DimensionsRequired,
|
||||
DimensionsTooLarge,
|
||||
FilePathTooLong,
|
||||
TemporaryFileNotInTempDir,
|
||||
UnsupportedFormat,
|
||||
UnsupportedMedium,
|
||||
UnsupportedDepth,
|
||||
};
|
||||
|
||||
pub fn deinit(self: *Image, alloc: Allocator) void {
|
||||
if (self.data.len > 0) alloc.free(self.data);
|
||||
}
|
||||
|
||||
/// Mostly for logging
|
||||
pub fn withoutData(self: *const Image) Image {
|
||||
var copy = self.*;
|
||||
copy.data = "";
|
||||
return copy;
|
||||
}
|
||||
};
|
||||
|
||||
/// The rect taken up by some image placement, in grid cells. This will
|
||||
/// be rounded up to the nearest grid cell since we can't place images
|
||||
/// in partial grid cells.
|
||||
pub const Rect = struct {
|
||||
top_left: point.ScreenPoint = .{},
|
||||
bottom_right: point.ScreenPoint = .{},
|
||||
|
||||
/// True if the rect contains a given screen point.
|
||||
pub fn contains(self: Rect, p: point.ScreenPoint) bool {
|
||||
return p.y >= self.top_left.y and
|
||||
p.y <= self.bottom_right.y and
|
||||
p.x >= self.top_left.x and
|
||||
p.x <= self.bottom_right.x;
|
||||
}
|
||||
};
|
||||
|
||||
/// Easy base64 encoding function.
|
||||
fn testB64(alloc: Allocator, data: []const u8) ![]const u8 {
|
||||
const B64Encoder = std.base64.standard.Encoder;
|
||||
var b64 = try alloc.alloc(u8, B64Encoder.calcSize(data.len));
|
||||
errdefer alloc.free(b64);
|
||||
return B64Encoder.encode(b64, data);
|
||||
}
|
||||
|
||||
/// Easy base64 decoding function.
|
||||
fn testB64Decode(alloc: Allocator, data: []const u8) ![]const u8 {
|
||||
const B64Decoder = std.base64.standard.Decoder;
|
||||
var result = try alloc.alloc(u8, try B64Decoder.calcSizeForSlice(data));
|
||||
errdefer alloc.free(result);
|
||||
try B64Decoder.decode(result, data);
|
||||
return result;
|
||||
}
|
||||
|
||||
// This specifically tests we ALLOW invalid RGB data because Kitty
|
||||
// documents that this should work.
|
||||
test "image load with invalid RGB data" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
// <ESC>_Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA<ESC>\
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.width = 1,
|
||||
.height = 1,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try alloc.dupe(u8, "AAAA"),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
}
|
||||
|
||||
test "image load with image too wide" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.width = max_dimension + 1,
|
||||
.height = 1,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try alloc.dupe(u8, "AAAA"),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc));
|
||||
}
|
||||
|
||||
test "image load with image too tall" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.height = max_dimension + 1,
|
||||
.width = 1,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try alloc.dupe(u8, "AAAA"),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc));
|
||||
}
|
||||
|
||||
test "image load: rgb, zlib compressed, direct" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .direct,
|
||||
.compression = .zlib_deflate,
|
||||
.height = 96,
|
||||
.width = 128,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try alloc.dupe(
|
||||
u8,
|
||||
@embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data"),
|
||||
),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
|
||||
// should be decompressed
|
||||
try testing.expect(img.compression == .none);
|
||||
}
|
||||
|
||||
test "image load: rgb, not compressed, direct" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .direct,
|
||||
.compression = .none,
|
||||
.width = 20,
|
||||
.height = 15,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try alloc.dupe(
|
||||
u8,
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
|
||||
),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
|
||||
// should be decompressed
|
||||
try testing.expect(img.compression == .none);
|
||||
}
|
||||
|
||||
test "image load: rgb, zlib compressed, direct, chunked" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data");
|
||||
|
||||
// Setup our initial chunk
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .direct,
|
||||
.compression = .zlib_deflate,
|
||||
.height = 96,
|
||||
.width = 128,
|
||||
.image_id = 31,
|
||||
.more_chunks = true,
|
||||
} },
|
||||
.data = try alloc.dupe(u8, data[0..1024]),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
|
||||
// Read our remaining chunks
|
||||
var fbs = std.io.fixedBufferStream(data[1024..]);
|
||||
var buf: [1024]u8 = undefined;
|
||||
while (fbs.reader().readAll(&buf)) |size| {
|
||||
try loading.addData(alloc, buf[0..size]);
|
||||
if (size < buf.len) break;
|
||||
} else |err| return err;
|
||||
|
||||
// Complete
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
try testing.expect(img.compression == .none);
|
||||
}
|
||||
|
||||
test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
const data = @embedFile("testdata/image-rgb-zlib_deflate-128x96-2147483647.data");
|
||||
|
||||
// Setup our initial chunk
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .direct,
|
||||
.compression = .zlib_deflate,
|
||||
.height = 96,
|
||||
.width = 128,
|
||||
.image_id = 31,
|
||||
.more_chunks = true,
|
||||
} },
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
|
||||
// Read our remaining chunks
|
||||
var fbs = std.io.fixedBufferStream(data);
|
||||
var buf: [1024]u8 = undefined;
|
||||
while (fbs.reader().readAll(&buf)) |size| {
|
||||
try loading.addData(alloc, buf[0..size]);
|
||||
if (size < buf.len) break;
|
||||
} else |err| return err;
|
||||
|
||||
// Complete
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
try testing.expect(img.compression == .none);
|
||||
}
|
||||
|
||||
test "image load: rgb, not compressed, temporary file" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp_dir = try internal_os.TempDir.init();
|
||||
defer tmp_dir.deinit();
|
||||
const data = try testB64Decode(
|
||||
alloc,
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
|
||||
);
|
||||
defer alloc.free(data);
|
||||
try tmp_dir.dir.writeFile("image.data", data);
|
||||
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const path = try tmp_dir.dir.realpath("image.data", &buf);
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .temporary_file,
|
||||
.compression = .none,
|
||||
.width = 20,
|
||||
.height = 15,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try testB64(alloc, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
try testing.expect(img.compression == .none);
|
||||
|
||||
// Temporary file should be gone
|
||||
try testing.expectError(error.FileNotFound, tmp_dir.dir.access(path, .{}));
|
||||
}
|
||||
|
||||
test "image load: rgb, not compressed, regular file" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp_dir = try internal_os.TempDir.init();
|
||||
defer tmp_dir.deinit();
|
||||
const data = try testB64Decode(
|
||||
alloc,
|
||||
@embedFile("testdata/image-rgb-none-20x15-2147483647.data"),
|
||||
);
|
||||
defer alloc.free(data);
|
||||
try tmp_dir.dir.writeFile("image.data", data);
|
||||
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const path = try tmp_dir.dir.realpath("image.data", &buf);
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .rgb,
|
||||
.medium = .file,
|
||||
.compression = .none,
|
||||
.width = 20,
|
||||
.height = 15,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try testB64(alloc, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
try testing.expect(img.compression == .none);
|
||||
try tmp_dir.dir.access(path, .{});
|
||||
}
|
||||
|
||||
test "image load: png, not compressed, regular file" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
|
||||
var tmp_dir = try internal_os.TempDir.init();
|
||||
defer tmp_dir.deinit();
|
||||
const data = @embedFile("testdata/image-png-none-50x76-2147483647-raw.data");
|
||||
try tmp_dir.dir.writeFile("image.data", data);
|
||||
|
||||
var buf: [std.fs.MAX_PATH_BYTES]u8 = undefined;
|
||||
const path = try tmp_dir.dir.realpath("image.data", &buf);
|
||||
|
||||
var cmd: command.Command = .{
|
||||
.control = .{ .transmit = .{
|
||||
.format = .png,
|
||||
.medium = .file,
|
||||
.compression = .none,
|
||||
.width = 0,
|
||||
.height = 0,
|
||||
.image_id = 31,
|
||||
} },
|
||||
.data = try testB64(alloc, path),
|
||||
};
|
||||
defer cmd.deinit(alloc);
|
||||
var loading = try LoadingImage.init(alloc, &cmd);
|
||||
defer loading.deinit(alloc);
|
||||
var img = try loading.complete(alloc);
|
||||
defer img.deinit(alloc);
|
||||
try testing.expect(img.compression == .none);
|
||||
try testing.expect(img.format == .rgb);
|
||||
try tmp_dir.dir.access(path, .{});
|
||||
}
|
746
src/terminal/kitty/graphics_storage.zig
Normal file
746
src/terminal/kitty/graphics_storage.zig
Normal file
@ -0,0 +1,746 @@
|
||||
const std = @import("std");
|
||||
const assert = std.debug.assert;
|
||||
const Allocator = std.mem.Allocator;
|
||||
const ArenaAllocator = std.heap.ArenaAllocator;
|
||||
|
||||
const terminal = @import("../main.zig");
|
||||
const point = @import("../point.zig");
|
||||
const command = @import("graphics_command.zig");
|
||||
const Screen = @import("../Screen.zig");
|
||||
const LoadingImage = @import("graphics_image.zig").LoadingImage;
|
||||
const Image = @import("graphics_image.zig").Image;
|
||||
const Rect = @import("graphics_image.zig").Rect;
|
||||
const Command = command.Command;
|
||||
const ScreenPoint = point.ScreenPoint;
|
||||
|
||||
const log = std.log.scoped(.kitty_gfx);
|
||||
|
||||
/// An image storage is associated with a terminal screen (i.e. main
|
||||
/// screen, alt screen) and contains all the transmitted images and
|
||||
/// placements.
|
||||
pub const ImageStorage = struct {
|
||||
const ImageMap = std.AutoHashMapUnmanaged(u32, Image);
|
||||
const PlacementMap = std.AutoHashMapUnmanaged(PlacementKey, Placement);
|
||||
|
||||
/// Dirty is set to true if placements or images change. This is
|
||||
/// purely informational for the renderer and doesn't affect the
|
||||
/// correctness of the program. The renderer must set this to false
|
||||
/// if it cares about this value.
|
||||
dirty: bool = false,
|
||||
|
||||
/// This is the next automatically assigned ID. We start mid-way
|
||||
/// through the u32 range to avoid collisions with buggy programs.
|
||||
next_id: u32 = 2147483647,
|
||||
|
||||
/// The set of images that are currently known.
|
||||
images: ImageMap = .{},
|
||||
|
||||
/// The set of placements for loaded images.
|
||||
placements: PlacementMap = .{},
|
||||
|
||||
/// Non-null if there is an in-progress loading image.
|
||||
loading: ?*LoadingImage = null,
|
||||
|
||||
/// The total bytes of image data that have been loaded and the limit.
|
||||
/// If the limit is reached, the oldest images will be evicted to make
|
||||
/// space. Unused images take priority.
|
||||
total_bytes: usize = 0,
|
||||
total_limit: usize = 320 * 1000 * 1000, // 320MB
|
||||
|
||||
pub fn deinit(self: *ImageStorage, alloc: Allocator) void {
|
||||
if (self.loading) |loading| loading.destroy(alloc);
|
||||
|
||||
var it = self.images.iterator();
|
||||
while (it.next()) |kv| kv.value_ptr.deinit(alloc);
|
||||
self.images.deinit(alloc);
|
||||
|
||||
self.placements.deinit(alloc);
|
||||
}
|
||||
|
||||
/// Kitty image protocol is enabled if we have a non-zero limit.
|
||||
pub fn enabled(self: *const ImageStorage) bool {
|
||||
return self.total_limit != 0;
|
||||
}
|
||||
|
||||
/// Sets the limit in bytes for the total amount of image data that
|
||||
/// can be loaded. If this limit is lower, this will do an eviction
|
||||
/// if necessary. If the value is zero, then Kitty image protocol will
|
||||
/// be disabled.
|
||||
pub fn setLimit(self: *ImageStorage, alloc: Allocator, limit: usize) !void {
|
||||
// Special case disabling by quickly deleting all
|
||||
if (limit == 0) {
|
||||
self.deinit(alloc);
|
||||
self.* = .{};
|
||||
}
|
||||
|
||||
// If we re lowering our limit, check if we need to evict.
|
||||
if (limit < self.total_bytes) {
|
||||
const req_bytes = self.total_bytes - limit;
|
||||
log.info("evicting images to lower limit, evicting={}", .{req_bytes});
|
||||
if (!try self.evictImage(alloc, req_bytes)) {
|
||||
log.warn("failed to evict enough images for required bytes", .{});
|
||||
}
|
||||
}
|
||||
|
||||
self.total_limit = limit;
|
||||
}
|
||||
|
||||
/// Add an already-loaded image to the storage. This will automatically
|
||||
/// free any existing image with the same ID.
|
||||
pub fn addImage(self: *ImageStorage, alloc: Allocator, img: Image) Allocator.Error!void {
|
||||
// If the image itself is over the limit, then error immediately
|
||||
if (img.data.len > self.total_limit) return error.OutOfMemory;
|
||||
|
||||
// If this would put us over the limit, then evict.
|
||||
const total_bytes = self.total_bytes + img.data.len;
|
||||
if (total_bytes > self.total_limit) {
|
||||
const req_bytes = total_bytes - self.total_limit;
|
||||
log.info("evicting images to make space for {} bytes", .{req_bytes});
|
||||
if (!try self.evictImage(alloc, req_bytes)) {
|
||||
log.warn("failed to evict enough images for required bytes", .{});
|
||||
return error.OutOfMemory;
|
||||
}
|
||||
}
|
||||
|
||||
// Do the gop op first so if it fails we don't get a partial state
|
||||
const gop = try self.images.getOrPut(alloc, img.id);
|
||||
|
||||
log.debug("addImage image={}", .{img: {
|
||||
var copy = img;
|
||||
copy.data = "";
|
||||
break :img copy;
|
||||
}});
|
||||
|
||||
// Write our new image
|
||||
if (gop.found_existing) {
|
||||
self.total_bytes -= gop.value_ptr.data.len;
|
||||
gop.value_ptr.deinit(alloc);
|
||||
}
|
||||
|
||||
gop.value_ptr.* = img;
|
||||
self.total_bytes += img.data.len;
|
||||
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Add a placement for a given image. The caller must verify in advance
|
||||
/// the image exists to prevent memory corruption.
|
||||
pub fn addPlacement(
|
||||
self: *ImageStorage,
|
||||
alloc: Allocator,
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
p: Placement,
|
||||
) !void {
|
||||
assert(self.images.get(image_id) != null);
|
||||
log.debug("placement image_id={} placement_id={} placement={}\n", .{
|
||||
image_id,
|
||||
placement_id,
|
||||
p,
|
||||
});
|
||||
|
||||
const key: PlacementKey = .{ .image_id = image_id, .placement_id = placement_id };
|
||||
const gop = try self.placements.getOrPut(alloc, key);
|
||||
gop.value_ptr.* = p;
|
||||
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// Get an image by its ID. If the image doesn't exist, null is returned.
|
||||
pub fn imageById(self: *const ImageStorage, image_id: u32) ?Image {
|
||||
return self.images.get(image_id);
|
||||
}
|
||||
|
||||
/// Get an image by its number. If the image doesn't exist, return null.
|
||||
pub fn imageByNumber(self: *const ImageStorage, image_number: u32) ?Image {
|
||||
var newest: ?Image = null;
|
||||
|
||||
var it = self.images.iterator();
|
||||
while (it.next()) |kv| {
|
||||
if (kv.value_ptr.number == image_number) {
|
||||
if (newest == null or
|
||||
kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt)
|
||||
{
|
||||
newest = kv.value_ptr.*;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return newest;
|
||||
}
|
||||
|
||||
/// Delete placements, images.
|
||||
pub fn delete(
|
||||
self: *ImageStorage,
|
||||
alloc: Allocator,
|
||||
t: *const terminal.Terminal,
|
||||
cmd: command.Delete,
|
||||
) void {
|
||||
switch (cmd) {
|
||||
.all => |delete_images| if (delete_images) {
|
||||
// We just reset our entire state.
|
||||
self.deinit(alloc);
|
||||
self.* = .{ .dirty = true };
|
||||
} else {
|
||||
// Delete all our placements
|
||||
self.placements.deinit(alloc);
|
||||
self.placements = .{};
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.id => |v| self.deleteById(
|
||||
alloc,
|
||||
v.image_id,
|
||||
v.placement_id,
|
||||
v.delete,
|
||||
),
|
||||
|
||||
.newest => |v| newest: {
|
||||
const img = self.imageByNumber(v.image_number) orelse break :newest;
|
||||
self.deleteById(alloc, img.id, v.placement_id, v.delete);
|
||||
},
|
||||
|
||||
.intersect_cursor => |delete_images| {
|
||||
const target = (point.Viewport{
|
||||
.x = t.screen.cursor.x,
|
||||
.y = t.screen.cursor.y,
|
||||
}).toScreen(&t.screen);
|
||||
self.deleteIntersecting(alloc, t, target, delete_images, {}, null);
|
||||
},
|
||||
|
||||
.intersect_cell => |v| {
|
||||
const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen);
|
||||
self.deleteIntersecting(alloc, t, target, v.delete, {}, null);
|
||||
},
|
||||
|
||||
.intersect_cell_z => |v| {
|
||||
const target = (point.Viewport{ .x = v.x, .y = v.y }).toScreen(&t.screen);
|
||||
self.deleteIntersecting(alloc, t, target, v.delete, v.z, struct {
|
||||
fn filter(ctx: i32, p: Placement) bool {
|
||||
return p.z == ctx;
|
||||
}
|
||||
}.filter);
|
||||
},
|
||||
|
||||
.column => |v| {
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const img = self.imageById(entry.key_ptr.image_id) orelse continue;
|
||||
const rect = entry.value_ptr.rect(img, t);
|
||||
if (rect.top_left.x <= v.x and rect.bottom_right.x >= v.x) {
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
if (v.delete) self.deleteIfUnused(alloc, img.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.row => |v| {
|
||||
// Get the screenpoint y
|
||||
const y = (point.Viewport{ .x = 0, .y = v.y }).toScreen(&t.screen).y;
|
||||
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const img = self.imageById(entry.key_ptr.image_id) orelse continue;
|
||||
const rect = entry.value_ptr.rect(img, t);
|
||||
if (rect.top_left.y <= y and rect.bottom_right.y >= y) {
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
if (v.delete) self.deleteIfUnused(alloc, img.id);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
.z => |v| {
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.value_ptr.z == v.z) {
|
||||
const image_id = entry.key_ptr.image_id;
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
if (v.delete) self.deleteIfUnused(alloc, image_id);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// We don't support animation frames yet so they are successfully
|
||||
// deleted!
|
||||
.animation_frames => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn deleteById(
|
||||
self: *ImageStorage,
|
||||
alloc: Allocator,
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
delete_unused: bool,
|
||||
) void {
|
||||
// If no placement, we delete all placements with the ID
|
||||
if (placement_id == 0) {
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
if (entry.key_ptr.image_id == image_id) {
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
_ = self.placements.remove(.{
|
||||
.image_id = image_id,
|
||||
.placement_id = placement_id,
|
||||
});
|
||||
}
|
||||
|
||||
// If this is specified, then we also delete the image
|
||||
// if it is no longer in use.
|
||||
if (delete_unused) self.deleteIfUnused(alloc, image_id);
|
||||
}
|
||||
|
||||
/// Delete an image if it is unused.
|
||||
fn deleteIfUnused(self: *ImageStorage, alloc: Allocator, image_id: u32) void {
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |kv| {
|
||||
if (kv.key_ptr.image_id == image_id) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// If we get here, we can delete the image.
|
||||
if (self.images.getEntry(image_id)) |entry| {
|
||||
self.total_bytes -= entry.value_ptr.data.len;
|
||||
entry.value_ptr.deinit(alloc);
|
||||
self.images.removeByPtr(entry.key_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Deletes all placements intersecting a screen point.
|
||||
fn deleteIntersecting(
|
||||
self: *ImageStorage,
|
||||
alloc: Allocator,
|
||||
t: *const terminal.Terminal,
|
||||
p: point.ScreenPoint,
|
||||
delete_unused: bool,
|
||||
filter_ctx: anytype,
|
||||
comptime filter: ?fn (@TypeOf(filter_ctx), Placement) bool,
|
||||
) void {
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| {
|
||||
const img = self.imageById(entry.key_ptr.image_id) orelse continue;
|
||||
const rect = entry.value_ptr.rect(img, t);
|
||||
if (rect.contains(p)) {
|
||||
if (filter) |f| if (!f(filter_ctx, entry.value_ptr.*)) continue;
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
if (delete_unused) self.deleteIfUnused(alloc, img.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Evict image to make space. This will evict the oldest image,
|
||||
/// prioritizing unused images first, as recommended by the published
|
||||
/// Kitty spec.
|
||||
///
|
||||
/// This will evict as many images as necessary to make space for
|
||||
/// req bytes.
|
||||
fn evictImage(self: *ImageStorage, alloc: Allocator, req: usize) !bool {
|
||||
assert(req <= self.total_limit);
|
||||
|
||||
// Ironically we allocate to evict. We should probably redesign the
|
||||
// data structures to avoid this but for now allocating a little
|
||||
// bit is fine compared to the megabytes we're looking to save.
|
||||
const Candidate = struct {
|
||||
id: u32,
|
||||
time: std.time.Instant,
|
||||
used: bool,
|
||||
};
|
||||
|
||||
var candidates = std.ArrayList(Candidate).init(alloc);
|
||||
defer candidates.deinit();
|
||||
|
||||
var it = self.images.iterator();
|
||||
while (it.next()) |kv| {
|
||||
const img = kv.value_ptr;
|
||||
|
||||
// This is a huge waste. See comment above about redesigning
|
||||
// our data structures to avoid this. Eviction should be very
|
||||
// rare though and we never have that many images/placements
|
||||
// so hopefully this will last a long time.
|
||||
const used = used: {
|
||||
var p_it = self.placements.iterator();
|
||||
while (p_it.next()) |p_kv| {
|
||||
if (p_kv.key_ptr.image_id == img.id) {
|
||||
break :used true;
|
||||
}
|
||||
}
|
||||
|
||||
break :used false;
|
||||
};
|
||||
|
||||
try candidates.append(.{
|
||||
.id = img.id,
|
||||
.time = img.transmit_time,
|
||||
.used = used,
|
||||
});
|
||||
}
|
||||
|
||||
// Sort
|
||||
std.mem.sortUnstable(
|
||||
Candidate,
|
||||
candidates.items,
|
||||
{},
|
||||
struct {
|
||||
fn lessThan(
|
||||
ctx: void,
|
||||
lhs: Candidate,
|
||||
rhs: Candidate,
|
||||
) bool {
|
||||
_ = ctx;
|
||||
|
||||
// If they're usage matches, then its based on time.
|
||||
if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) {
|
||||
.lt => true,
|
||||
.gt => false,
|
||||
.eq => lhs.id < rhs.id,
|
||||
};
|
||||
|
||||
// If not used, then its a better candidate
|
||||
return !lhs.used;
|
||||
}
|
||||
}.lessThan,
|
||||
);
|
||||
|
||||
// They're in order of best to evict.
|
||||
var evicted: usize = 0;
|
||||
for (candidates.items) |c| {
|
||||
// Delete all the placements for this image and the image.
|
||||
var p_it = self.placements.iterator();
|
||||
while (p_it.next()) |entry| {
|
||||
if (entry.key_ptr.image_id == c.id) {
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
if (self.images.getEntry(c.id)) |entry| {
|
||||
log.info("evicting image id={} bytes={}", .{ c.id, entry.value_ptr.data.len });
|
||||
|
||||
evicted += entry.value_ptr.data.len;
|
||||
self.total_bytes -= entry.value_ptr.data.len;
|
||||
|
||||
entry.value_ptr.deinit(alloc);
|
||||
self.images.removeByPtr(entry.key_ptr);
|
||||
|
||||
if (evicted > req) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// Every placement is uniquely identified by the image ID and the
|
||||
/// placement ID. If an image ID isn't specified it is assumed to be 0.
|
||||
/// Likewise, if a placement ID isn't specified it is assumed to be 0.
|
||||
pub const PlacementKey = struct {
|
||||
image_id: u32,
|
||||
placement_id: u32,
|
||||
};
|
||||
|
||||
pub const Placement = struct {
|
||||
/// The location of the image on the screen.
|
||||
point: ScreenPoint,
|
||||
|
||||
/// Offset of the x/y from the top-left of the cell.
|
||||
x_offset: u32 = 0,
|
||||
y_offset: u32 = 0,
|
||||
|
||||
/// Source rectangle for the image to pull from
|
||||
source_x: u32 = 0,
|
||||
source_y: u32 = 0,
|
||||
source_width: u32 = 0,
|
||||
source_height: u32 = 0,
|
||||
|
||||
/// The columns/rows this image occupies.
|
||||
columns: u32 = 0,
|
||||
rows: u32 = 0,
|
||||
|
||||
/// The z-index for this placement.
|
||||
z: i32 = 0,
|
||||
|
||||
/// Returns a selection of the entire rectangle this placement
|
||||
/// occupies within the screen.
|
||||
pub fn rect(
|
||||
self: Placement,
|
||||
image: Image,
|
||||
t: *const terminal.Terminal,
|
||||
) Rect {
|
||||
// If we have columns/rows specified we can simplify this whole thing.
|
||||
if (self.columns > 0 and self.rows > 0) {
|
||||
return .{
|
||||
.top_left = self.point,
|
||||
.bottom_right = .{
|
||||
.x = @min(self.point.x + self.columns, t.cols - 1),
|
||||
.y = self.point.y + self.rows,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Calculate our cell size.
|
||||
const terminal_width_f64: f64 = @floatFromInt(t.width_px);
|
||||
const terminal_height_f64: f64 = @floatFromInt(t.height_px);
|
||||
const grid_columns_f64: f64 = @floatFromInt(t.cols);
|
||||
const grid_rows_f64: f64 = @floatFromInt(t.rows);
|
||||
const cell_width_f64 = terminal_width_f64 / grid_columns_f64;
|
||||
const cell_height_f64 = terminal_height_f64 / grid_rows_f64;
|
||||
|
||||
// Our image width
|
||||
const width_px = if (self.source_width > 0) self.source_width else image.width;
|
||||
const height_px = if (self.source_height > 0) self.source_height else image.height;
|
||||
|
||||
// Calculate our image size in grid cells
|
||||
const width_f64: f64 = @floatFromInt(width_px);
|
||||
const height_f64: f64 = @floatFromInt(height_px);
|
||||
const width_cells: u32 = @intFromFloat(@ceil(width_f64 / cell_width_f64));
|
||||
const height_cells: u32 = @intFromFloat(@ceil(height_f64 / cell_height_f64));
|
||||
|
||||
return .{
|
||||
.top_left = self.point,
|
||||
.bottom_right = .{
|
||||
.x = @min(self.point.x + width_cells, t.cols - 1),
|
||||
.y = self.point.y + height_cells,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
test "storage: delete all placements and images" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 3, 3);
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .all = true });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 0), s.images.count());
|
||||
try testing.expectEqual(@as(usize, 0), s.placements.count());
|
||||
}
|
||||
|
||||
test "storage: delete all placements" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 3, 3);
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .all = false });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 0), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 3), s.images.count());
|
||||
}
|
||||
|
||||
test "storage: delete all placements by image id" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 3, 3);
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .id = .{ .image_id = 2 } });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 1), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 3), s.images.count());
|
||||
}
|
||||
|
||||
test "storage: delete all placements by image id and unused images" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 3, 3);
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .id = .{ .delete = true, .image_id = 2 } });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 1), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 2), s.images.count());
|
||||
}
|
||||
|
||||
test "storage: delete placement by specific id" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 3, 3);
|
||||
defer t.deinit(alloc);
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .point = .{ .x = 1, .y = 1 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .id = .{
|
||||
.delete = true,
|
||||
.image_id = 1,
|
||||
.placement_id = 2,
|
||||
} });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 2), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 3), s.images.count());
|
||||
}
|
||||
|
||||
test "storage: delete intersecting cursor" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 100, 100);
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 });
|
||||
try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } });
|
||||
|
||||
t.screen.cursor.x = 12;
|
||||
t.screen.cursor.y = 12;
|
||||
|
||||
s.delete(alloc, &t, .{ .intersect_cursor = false });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 1), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 2), s.images.count());
|
||||
|
||||
// verify the placement is what we expect
|
||||
try testing.expect(s.placements.get(.{ .image_id = 1, .placement_id = 2 }) != null);
|
||||
}
|
||||
|
||||
test "storage: delete intersecting cursor plus unused" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 100, 100);
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 });
|
||||
try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } });
|
||||
|
||||
t.screen.cursor.x = 12;
|
||||
t.screen.cursor.y = 12;
|
||||
|
||||
s.delete(alloc, &t, .{ .intersect_cursor = true });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 1), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 2), s.images.count());
|
||||
|
||||
// verify the placement is what we expect
|
||||
try testing.expect(s.placements.get(.{ .image_id = 1, .placement_id = 2 }) != null);
|
||||
}
|
||||
|
||||
test "storage: delete intersecting cursor hits multiple" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 100, 100);
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 });
|
||||
try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } });
|
||||
|
||||
t.screen.cursor.x = 26;
|
||||
t.screen.cursor.y = 26;
|
||||
|
||||
s.delete(alloc, &t, .{ .intersect_cursor = true });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 0), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 1), s.images.count());
|
||||
}
|
||||
|
||||
test "storage: delete by column" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 100, 100);
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 });
|
||||
try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .column = .{
|
||||
.delete = false,
|
||||
.x = 60,
|
||||
} });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 1), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 2), s.images.count());
|
||||
|
||||
// verify the placement is what we expect
|
||||
try testing.expect(s.placements.get(.{ .image_id = 1, .placement_id = 1 }) != null);
|
||||
}
|
||||
|
||||
test "storage: delete by row" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 100, 100);
|
||||
defer t.deinit(alloc);
|
||||
t.width_px = 100;
|
||||
t.height_px = 100;
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc);
|
||||
try s.addImage(alloc, .{ .id = 1, .width = 50, .height = 50 });
|
||||
try s.addImage(alloc, .{ .id = 2, .width = 25, .height = 25 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .point = .{ .x = 0, .y = 0 } });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .point = .{ .x = 25, .y = 25 } });
|
||||
|
||||
s.delete(alloc, &t, .{ .row = .{
|
||||
.delete = false,
|
||||
.y = 60,
|
||||
} });
|
||||
try testing.expect(s.dirty);
|
||||
try testing.expectEqual(@as(usize, 1), s.placements.count());
|
||||
try testing.expectEqual(@as(usize, 2), s.images.count());
|
||||
|
||||
// verify the placement is what we expect
|
||||
try testing.expect(s.placements.get(.{ .image_id = 1, .placement_id = 1 }) != null);
|
||||
}
|
151
src/terminal/kitty/key.zig
Normal file
151
src/terminal/kitty/key.zig
Normal file
@ -0,0 +1,151 @@
|
||||
//! Kitty keyboard protocol support.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Stack for the key flags. This implements the push/pop behavior
|
||||
/// of the CSI > u and CSI < u sequences. We implement the stack as
|
||||
/// fixed size to avoid heap allocation.
|
||||
pub const KeyFlagStack = struct {
|
||||
const len = 8;
|
||||
|
||||
flags: [len]KeyFlags = .{.{}} ** len,
|
||||
idx: u3 = 0,
|
||||
|
||||
/// Return the current stack value
|
||||
pub fn current(self: KeyFlagStack) KeyFlags {
|
||||
return self.flags[self.idx];
|
||||
}
|
||||
|
||||
/// Perform the "set" operation as described in the spec for
|
||||
/// the CSI = u sequence.
|
||||
pub fn set(
|
||||
self: *KeyFlagStack,
|
||||
mode: KeySetMode,
|
||||
v: KeyFlags,
|
||||
) void {
|
||||
switch (mode) {
|
||||
.set => self.flags[self.idx] = v,
|
||||
.@"or" => self.flags[self.idx] = @bitCast(
|
||||
self.flags[self.idx].int() | v.int(),
|
||||
),
|
||||
.not => self.flags[self.idx] = @bitCast(
|
||||
self.flags[self.idx].int() & ~v.int(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new set of flags onto the stack. If the stack is full
|
||||
/// then the oldest entry is evicted.
|
||||
pub fn push(self: *KeyFlagStack, flags: KeyFlags) void {
|
||||
// Overflow and wrap around if we're full, which evicts
|
||||
// the oldest entry.
|
||||
self.idx +%= 1;
|
||||
self.flags[self.idx] = flags;
|
||||
}
|
||||
|
||||
/// Pop `n` entries from the stack. This will just wrap around
|
||||
/// if `n` is greater than the amount in the stack.
|
||||
pub fn pop(self: *KeyFlagStack, n: usize) void {
|
||||
// If n is more than our length then we just reset the stack.
|
||||
// This also avoids a DoS vector where a malicious client
|
||||
// could send a huge number of pop commands to waste cpu.
|
||||
if (n >= self.flags.len) {
|
||||
self.idx = 0;
|
||||
self.flags = .{.{}} ** len;
|
||||
return;
|
||||
}
|
||||
|
||||
for (0..n) |_| {
|
||||
self.flags[self.idx] = .{};
|
||||
self.idx -%= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we the overflow works as expected
|
||||
test {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.idx = stack.flags.len - 1;
|
||||
stack.idx +%= 1;
|
||||
try testing.expect(stack.idx == 0);
|
||||
|
||||
stack.idx = 0;
|
||||
stack.idx -%= 1;
|
||||
try testing.expect(stack.idx == stack.flags.len - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/// The possible flags for the Kitty keyboard protocol.
|
||||
pub const KeyFlags = packed struct(u5) {
|
||||
disambiguate: bool = false,
|
||||
report_events: bool = false,
|
||||
report_alternates: bool = false,
|
||||
report_all: bool = false,
|
||||
report_associated: bool = false,
|
||||
|
||||
pub fn int(self: KeyFlags) u5 {
|
||||
return @bitCast(self);
|
||||
}
|
||||
|
||||
// Its easy to get packed struct ordering wrong so this test checks.
|
||||
test {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(u5, 0b1),
|
||||
(KeyFlags{ .disambiguate = true }).int(),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(u5, 0b10),
|
||||
(KeyFlags{ .report_events = true }).int(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/// The possible modes for setting the key flags.
|
||||
pub const KeySetMode = enum { set, @"or", not };
|
||||
|
||||
test "KeyFlagStack: push pop" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.push(.{ .disambiguate = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.pop(1);
|
||||
try testing.expectEqual(KeyFlags{}, stack.current());
|
||||
}
|
||||
|
||||
test "KeyFlagStack: pop big number" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.pop(100);
|
||||
try testing.expectEqual(KeyFlags{}, stack.current());
|
||||
}
|
||||
|
||||
test "KeyFlagStack: set" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.set(.set, .{ .disambiguate = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.set(.@"or", .{ .report_events = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{
|
||||
.disambiguate = true,
|
||||
.report_events = true,
|
||||
},
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.set(.not, .{ .report_events = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
}
|
BIN
src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.data
vendored
Normal file
BIN
src/terminal/kitty/testdata/image-png-none-50x76-2147483647-raw.data
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 86 B |
1
src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647.data
vendored
Normal file
1
src/terminal/kitty/testdata/image-rgb-none-20x15-2147483647.data
vendored
Normal file
@ -0,0 +1 @@
|
||||
DRoeCxgcCxcjEh4qDBgkCxcjChYiCxcjCRclBRMhBxIXHysvTVNRbHJwcXB2Li0zCBYXEyEiCxkaDBobChcbCBUZDxsnBBAcEBwoChYiCxcjDBgkDhwqBxUjDBccm6aqy9HP1NrYzs3UsK+2IjAxCBYXCBYXBxUWFBoaDxUVICYqIyktERcZDxUXDxUVEhgYDhUTCxIQGh8XusC4zM7FvL61q6elmZWTTVtcDBobDRscCxkaKS8vaW9vxMnOur/EiY+RaW5wICYmW2FhfYOBQEZEnqSc4ebeqauilZaOsa2rm5eVcH5/GigpChgZCBYX0NHP3d7c3tzbx8XExsTEvry8wL241dLN0tDF0tDF29nM4d/StbKpzMrAUk5DZmJXeYSGKTU3ER0fDRkb1tfVysvJ0tDPsa+tr6ytop+gmZaRqaahuritw8G2urirqKaZiYZ9paKZZmJXamZbOkZIDhocBxMVBBASxMDBtrKzqqanoZ2ejYeLeHF2eXFvhn58npePta6ml5CKgXp0W1hPaWZdZWdSYmRPFiYADR0AFCQAEyMAt7O0lJCRf3t8eHR1Zl9kY1xhYVpYbGRieXJqeHFpdW1oc2tmcG1kX1xTbW9ajY96jp55kaF8kKB7kaF8sK6rcnFtX11cXFpZW1pWWFdTXVpTXltUaGJgY11bY11da2Vla25dam1ccHtTnqmBorVtp7pypLdvobRsh4aCaGdjWFZVXFpZYWBcZ2ZiaGVeZGFaY11bYlxaV1FRZ2FhdHdmbG9egItjo66GpLdvq752rL93rsF5kpKIZ2ddWFxTW19WbnZdipJ6cnhaaW9RaGhgV1ZPY2Jga2poanFQd35dk6Vpn7B0oLFvorNxm6xqmKlnv760enpwVlpRW19Wc3til5+Hl55/k5p7iIiAcnJqd3Z0bm1rcHdWh45tipxgladrkaJglKVjkaJgkqNh09DJiYZ/YmZdY2deeYZYjJlrj51ijpxhztHClJaIdHNvdHNvanNHi5RpmaxnjKBbmqhrmadqkJ5hi5lcxsO8jImCaGtiYmZdg5Bikp9xjJpfjpxh1djJqq2eamllZ2Zid4BVmKF2kqZhh5tWlaNmlaNmjpxfjJpdw729rqiodnZ0cHBuiplij55nj6FVjJ5SzdC9t7qncW1sXlpZh45iqbCEmKllmapmmqlqnq1unaxtoK9w
|
1
src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data
vendored
Normal file
1
src/terminal/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -5,6 +5,7 @@ const stream = @import("stream.zig");
|
||||
const ansi = @import("ansi.zig");
|
||||
const csi = @import("csi.zig");
|
||||
const sgr = @import("sgr.zig");
|
||||
pub const apc = @import("apc.zig");
|
||||
pub const point = @import("point.zig");
|
||||
pub const color = @import("color.zig");
|
||||
pub const kitty = @import("kitty.zig");
|
||||
|
@ -125,10 +125,10 @@ fn genTable() Table {
|
||||
const source = State.sos_pm_apc_string;
|
||||
|
||||
// events
|
||||
single(&result, 0x19, source, source, .ignore);
|
||||
range(&result, 0, 0x17, source, source, .ignore);
|
||||
range(&result, 0x1C, 0x1F, source, source, .ignore);
|
||||
range(&result, 0x20, 0x7F, source, source, .ignore);
|
||||
single(&result, 0x19, source, source, .apc_put);
|
||||
range(&result, 0, 0x17, source, source, .apc_put);
|
||||
range(&result, 0x1C, 0x1F, source, source, .apc_put);
|
||||
range(&result, 0x20, 0x7F, source, source, .apc_put);
|
||||
}
|
||||
|
||||
// escape
|
||||
|
@ -64,8 +64,17 @@ pub fn Stream(comptime Handler: type) type {
|
||||
.esc_dispatch => |esc| try self.escDispatch(esc),
|
||||
.osc_dispatch => |cmd| try self.oscDispatch(cmd),
|
||||
.dcs_hook => |dcs| log.warn("unhandled DCS hook: {}", .{dcs}),
|
||||
.dcs_put => |code| log.warn("unhandled DCS put: {}", .{code}),
|
||||
.dcs_put => |code| log.warn("unhandled DCS put: {x}", .{code}),
|
||||
.dcs_unhook => log.warn("unhandled DCS unhook", .{}),
|
||||
.apc_start => if (@hasDecl(T, "apcStart")) {
|
||||
try self.handler.apcStart();
|
||||
} else log.warn("unimplemented APC start", .{}),
|
||||
.apc_put => |code| if (@hasDecl(T, "apcPut")) {
|
||||
try self.handler.apcPut(code);
|
||||
} else log.warn("unimplemented APC put: {x}", .{code}),
|
||||
.apc_end => if (@hasDecl(T, "apcEnd")) {
|
||||
try self.handler.apcEnd();
|
||||
} else log.warn("unimplemented APC end", .{}),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -47,10 +47,6 @@ subprocess: Subprocess,
|
||||
/// just stores internal state about a grid.
|
||||
terminal: terminal.Terminal,
|
||||
|
||||
/// The stream parser. This parses the stream of escape codes and so on
|
||||
/// from the child process and calls callbacks in the stream handler.
|
||||
terminal_stream: terminal.Stream(StreamHandler),
|
||||
|
||||
/// The shared render state
|
||||
renderer_state: *renderer.State,
|
||||
|
||||
@ -75,6 +71,7 @@ data: ?*EventData,
|
||||
/// pass around Config pointers which makes memory management a pain.
|
||||
pub const DerivedConfig = struct {
|
||||
palette: terminal.color.Palette,
|
||||
image_storage_limit: usize,
|
||||
|
||||
pub fn init(
|
||||
alloc_gpa: Allocator,
|
||||
@ -84,6 +81,7 @@ pub const DerivedConfig = struct {
|
||||
|
||||
return .{
|
||||
.palette = config.palette.value,
|
||||
.image_storage_limit = config.@"image-storage-limit",
|
||||
};
|
||||
}
|
||||
|
||||
@ -108,13 +106,20 @@ pub fn init(alloc: Allocator, opts: termio.Options) !Exec {
|
||||
errdefer term.deinit(alloc);
|
||||
term.color_palette = opts.config.palette;
|
||||
|
||||
// Set the image size limits
|
||||
try term.screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit);
|
||||
try term.secondary_screen.kitty_images.setLimit(alloc, opts.config.image_storage_limit);
|
||||
|
||||
var subprocess = try Subprocess.init(alloc, opts);
|
||||
errdefer subprocess.deinit();
|
||||
|
||||
// Initial width/height based on subprocess
|
||||
term.width_px = subprocess.screen_size.width;
|
||||
term.height_px = subprocess.screen_size.height;
|
||||
|
||||
return Exec{
|
||||
.alloc = alloc,
|
||||
.terminal = term,
|
||||
.terminal_stream = undefined,
|
||||
.subprocess = subprocess,
|
||||
.renderer_state = opts.renderer_state,
|
||||
.renderer_wakeup = opts.renderer_wakeup,
|
||||
@ -247,6 +252,16 @@ pub fn changeConfig(self: *Exec, config: *DerivedConfig) !void {
|
||||
// Update the palette. Note this will only apply to new colors drawn
|
||||
// since we decode all palette colors to RGB on usage.
|
||||
self.terminal.color_palette = config.palette;
|
||||
|
||||
// Set the image size limits
|
||||
try self.terminal.screen.kitty_images.setLimit(
|
||||
self.alloc,
|
||||
config.image_storage_limit,
|
||||
);
|
||||
try self.terminal.secondary_screen.kitty_images.setLimit(
|
||||
self.alloc,
|
||||
config.image_storage_limit,
|
||||
);
|
||||
}
|
||||
|
||||
/// Resize the terminal.
|
||||
@ -256,7 +271,7 @@ pub fn resize(
|
||||
screen_size: renderer.ScreenSize,
|
||||
padding: renderer.Padding,
|
||||
) !void {
|
||||
// Update the size of our pty
|
||||
// Update the size of our pty.
|
||||
const padded_size = screen_size.subPadding(padding);
|
||||
try self.subprocess.resize(grid_size, padded_size);
|
||||
|
||||
@ -269,7 +284,15 @@ pub fn resize(
|
||||
defer self.renderer_state.mutex.unlock();
|
||||
|
||||
// Update the size of our terminal state
|
||||
try self.terminal.resize(self.alloc, grid_size.columns, grid_size.rows);
|
||||
try self.terminal.resize(
|
||||
self.alloc,
|
||||
grid_size.columns,
|
||||
grid_size.rows,
|
||||
);
|
||||
|
||||
// Update our pixel sizes
|
||||
self.terminal.width_px = padded_size.width;
|
||||
self.terminal.height_px = padded_size.height;
|
||||
}
|
||||
}
|
||||
|
||||
@ -441,6 +464,9 @@ const EventData = struct {
|
||||
|
||||
// Stop our process watcher
|
||||
self.process.deinit();
|
||||
|
||||
// Clear any StreamHandler state
|
||||
self.terminal_stream.handler.deinit();
|
||||
}
|
||||
|
||||
/// This queues a render operation with the renderer thread. The render
|
||||
@ -658,6 +684,9 @@ const Subprocess = struct {
|
||||
log.warn("shell could not be detected, no automatic shell integration will be injected", .{});
|
||||
}
|
||||
|
||||
// Our screen size should be our padded size
|
||||
const padded_size = opts.screen_size.subPadding(opts.padding);
|
||||
|
||||
return .{
|
||||
.arena = arena,
|
||||
.env = env,
|
||||
@ -665,7 +694,7 @@ const Subprocess = struct {
|
||||
.path = final_path,
|
||||
.args = args,
|
||||
.grid_size = opts.grid_size,
|
||||
.screen_size = opts.screen_size,
|
||||
.screen_size = padded_size,
|
||||
};
|
||||
}
|
||||
|
||||
@ -1046,11 +1075,21 @@ const StreamHandler = struct {
|
||||
grid_size: *renderer.GridSize,
|
||||
terminal: *terminal.Terminal,
|
||||
|
||||
/// The APC command handler maintains the APC state. APC is like
|
||||
/// CSI or OSC, but it is a private escape sequence that is used
|
||||
/// to send commands to the terminal emulator. This is used by
|
||||
/// the kitty graphics protocol.
|
||||
apc: terminal.apc.Handler = .{},
|
||||
|
||||
/// This is set to true when a message was written to the writer
|
||||
/// mailbox. This can be used by callers to determine if they need
|
||||
/// to wake up the writer.
|
||||
writer_messaged: bool = false,
|
||||
|
||||
pub fn deinit(self: *StreamHandler) void {
|
||||
self.apc.deinit();
|
||||
}
|
||||
|
||||
inline fn queueRender(self: *StreamHandler) !void {
|
||||
try self.ev.queueRender();
|
||||
}
|
||||
@ -1060,6 +1099,35 @@ const StreamHandler = struct {
|
||||
self.writer_messaged = true;
|
||||
}
|
||||
|
||||
pub fn apcStart(self: *StreamHandler) !void {
|
||||
self.apc.start();
|
||||
}
|
||||
|
||||
pub fn apcPut(self: *StreamHandler, byte: u8) !void {
|
||||
self.apc.feed(self.alloc, byte);
|
||||
}
|
||||
|
||||
pub fn apcEnd(self: *StreamHandler) !void {
|
||||
var cmd = self.apc.end() orelse return;
|
||||
defer cmd.deinit(self.alloc);
|
||||
|
||||
// log.warn("APC command: {}", .{cmd});
|
||||
switch (cmd) {
|
||||
.kitty => |*kitty_cmd| {
|
||||
if (self.terminal.kittyGraphics(self.alloc, kitty_cmd)) |resp| {
|
||||
var buf: [1024]u8 = undefined;
|
||||
var buf_stream = std.io.fixedBufferStream(&buf);
|
||||
try resp.encode(buf_stream.writer());
|
||||
const final = buf_stream.getWritten();
|
||||
if (final.len > 2) {
|
||||
// log.warn("kitty graphics response: {s}", .{std.fmt.fmtSliceHexLower(final)});
|
||||
self.messageWriter(try termio.Message.writeReq(self.alloc, final));
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
pub fn print(self: *StreamHandler, ch: u21) !void {
|
||||
try self.terminal.print(ch);
|
||||
}
|
||||
@ -1144,7 +1212,7 @@ const StreamHandler = struct {
|
||||
try self.queueRender();
|
||||
}
|
||||
|
||||
self.terminal.eraseDisplay(mode);
|
||||
self.terminal.eraseDisplay(self.alloc, mode);
|
||||
}
|
||||
|
||||
pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine) !void {
|
||||
@ -1239,9 +1307,9 @@ const StreamHandler = struct {
|
||||
};
|
||||
|
||||
if (enabled)
|
||||
self.terminal.alternateScreen(opts)
|
||||
self.terminal.alternateScreen(self.alloc, opts)
|
||||
else
|
||||
self.terminal.primaryScreen(opts);
|
||||
self.terminal.primaryScreen(self.alloc, opts);
|
||||
|
||||
// Schedule a render since we changed screens
|
||||
try self.queueRender();
|
||||
@ -1409,7 +1477,7 @@ const StreamHandler = struct {
|
||||
pub fn fullReset(
|
||||
self: *StreamHandler,
|
||||
) !void {
|
||||
self.terminal.fullReset();
|
||||
self.terminal.fullReset(self.alloc);
|
||||
}
|
||||
|
||||
pub fn queryKittyKeyboard(self: *StreamHandler) !void {
|
||||
|
@ -12,6 +12,9 @@ grid_size: renderer.GridSize,
|
||||
/// The size of the viewport in pixels.
|
||||
screen_size: renderer.ScreenSize,
|
||||
|
||||
/// The padding of the viewport.
|
||||
padding: renderer.Padding,
|
||||
|
||||
/// The full app configuration. This is only available during initialization.
|
||||
/// The memory it points to is NOT stable after the init call so any values
|
||||
/// in here must be copied.
|
||||
|
Reference in New Issue
Block a user