Merge pull request #317 from mitchellh/kitty-gfx

Kitty Graphics Protocol Initial Support
This commit is contained in:
Mitchell Hashimoto
2023-08-24 08:50:13 -07:00
committed by GitHub
37 changed files with 12796 additions and 840 deletions

View File

@ -97,7 +97,3 @@ jobs:
- name: Test Dynamic Build - name: Test Dynamic Build
run: nix develop -c zig build -Dstatic=false run: nix develop -c zig build -Dstatic=false
- name: Test Wasm Build
run: nix develop -c zig build wasm

View File

@ -27,4 +27,3 @@ Major Features:
* Bell * Bell
* Sixels: https://saitoha.github.io/libsixel/ * Sixels: https://saitoha.github.io/libsixel/
* Kitty graphics protocol: https://sw.kovidgoyal.net/kitty/graphics-protocol/

View File

@ -24,7 +24,6 @@ const libpng = @import("pkg/libpng/build.zig");
const macos = @import("pkg/macos/build.zig"); const macos = @import("pkg/macos/build.zig");
const objc = @import("vendor/zig-objc/build.zig"); const objc = @import("vendor/zig-objc/build.zig");
const pixman = @import("pkg/pixman/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 utf8proc = @import("pkg/utf8proc/build.zig");
const zlib = @import("pkg/zlib/build.zig"); const zlib = @import("pkg/zlib/build.zig");
const tracylib = @import("pkg/tracy/build.zig"); const tracylib = @import("pkg/tracy/build.zig");
@ -670,6 +669,11 @@ fn addDeps(
step.addLibraryPath(.{ .path = b.fmt("/usr/lib/{s}", .{triple}) }); 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 // If we're building a lib we have some different deps
const lib = step.kind == .lib; const lib = step.kind == .lib;
@ -693,7 +697,6 @@ fn addDeps(
})); }));
step.addModule("xev", mod_libxev); step.addModule("xev", mod_libxev);
step.addModule("pixman", pixman.module(b)); step.addModule("pixman", pixman.module(b));
step.addModule("stb_image_resize", stb_image_resize.module(b));
step.addModule("utf8proc", utf8proc.module(b)); step.addModule("utf8proc", utf8proc.module(b));
// Mac Stuff // Mac Stuff
@ -713,10 +716,6 @@ fn addDeps(
system_sdk.include(b, tracy_step, .{}); 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 // utf8proc
const utf8proc_step = try utf8proc.link(b, step); const utf8proc_step = try utf8proc.link(b, step);
try static_libs.append(utf8proc_step.getEmittedBin()); try static_libs.append(utf8proc_step.getEmittedBin());

View File

@ -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;
}

View File

@ -1,2 +0,0 @@
#define STB_IMAGE_RESIZE_IMPLEMENTATION
#include <stb_image_resize.h>

View File

@ -401,6 +401,7 @@ pub fn init(
var io = try termio.Impl.init(alloc, .{ var io = try termio.Impl.init(alloc, .{
.grid_size = grid_size, .grid_size = grid_size,
.screen_size = screen_size, .screen_size = screen_size,
.padding = padding,
.full_config = config, .full_config = config,
.config = try termio.Impl.DerivedConfig.init(alloc, config), .config = try termio.Impl.DerivedConfig.init(alloc, config),
.resources_dir = app.resources_dir, .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, // Recalculate our grid size. Because Ghostty supports fluid resizing,
// its possible the grid doesn't change at all even if the screen size changes. // 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.screen_size.subPadding(self.padding),
self.cell_size, 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)) { 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 " ++ log.warn("WARNING: very small terminal grid detected with padding " ++
"set. Is your padding reasonable?", .{}); "set. Is your padding reasonable?", .{});

View File

@ -186,6 +186,15 @@ pub const Config = struct {
/// This does not affect data sent to the clipboard via "clipboard-write". /// This does not affect data sent to the clipboard via "clipboard-write".
@"clipboard-trim-trailing-spaces": bool = true, @"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" /// Whether to automatically copy selected text to the clipboard. "true"
/// will only copy on systems that support a selection clipboard. /// will only copy on systems that support a selection clipboard.
/// ///

View File

@ -8,7 +8,7 @@ const std = @import("std");
const builtin = @import("builtin"); const builtin = @import("builtin");
const freetype = @import("freetype"); const freetype = @import("freetype");
const harfbuzz = @import("harfbuzz"); const harfbuzz = @import("harfbuzz");
const resize = @import("stb_image_resize"); const stb = @import("../../stb/main.zig");
const assert = std.debug.assert; const assert = std.debug.assert;
const testing = std.testing; const testing = std.testing;
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
@ -204,7 +204,7 @@ pub const Face = struct {
result.buffer = buf.ptr; result.buffer = buf.ptr;
errdefer alloc.free(buf); errdefer alloc.free(buf);
if (resize.stbir_resize_uint8( if (stb.stbir_resize_uint8(
bm.buffer, bm.buffer,
@intCast(bm.width), @intCast(bm.width),
@intCast(bm.rows), @intCast(bm.rows),

File diff suppressed because it is too large Load Diff

138
src/renderer/metal/api.zig Normal file
View 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;

View 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);
}
};
}

View 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);
}
};

View 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));
}
}

View File

@ -49,6 +49,11 @@ struct VertexOut {
float2 tex_coord; float2 tex_coord;
}; };
//-------------------------------------------------------------------
// Terminal Grid Cell Shader
//-------------------------------------------------------------------
#pragma mark - Terminal Grid Cell Shader
vertex VertexOut uber_vertex( vertex VertexOut uber_vertex(
unsigned int vid [[ vertex_id ]], unsigned int vid [[ vertex_id ]],
VertexIn input [[ stage_in ]], VertexIn input [[ stage_in ]],
@ -179,3 +184,83 @@ fragment float4 uber_fragment(
return in.color; 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;
}

View File

@ -1,7 +1,4 @@
pub usingnamespace @cImport({ pub usingnamespace @cImport({
@cInclude("stb_image.h");
@cInclude("stb_image_resize.h"); @cInclude("stb_image_resize.h");
}); });
test {
// Needed to not crash on test
}

13
src/stb/stb.c Normal file
View 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

File diff suppressed because it is too large Load Diff

View File

@ -48,6 +48,7 @@ pub const TransitionAction = enum {
csi_dispatch, csi_dispatch,
put, put,
osc_put, osc_put,
apc_put,
}; };
/// Action is the action that a caller of the parser is expected to /// 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_put: u8,
dcs_unhook: void, dcs_unhook: void,
/// APC data
apc_start: void,
apc_put: u8,
apc_end: void,
pub const CSI = struct { pub const CSI = struct {
intermediates: []u8, intermediates: []u8,
params: []u16, params: []u16,
@ -247,6 +253,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
else else
null, null,
.dcs_passthrough => Action{ .dcs_unhook = {} }, .dcs_passthrough => Action{ .dcs_unhook = {} },
.sos_pm_apc_string => Action{ .apc_end = {} },
else => null, else => null,
}, },
@ -269,6 +276,7 @@ pub fn next(self: *Parser, c: u8) [3]?Action {
.final = c, .final = c,
}, },
}, },
.sos_pm_apc_string => Action{ .apc_start = {} },
.utf8 => utf8: { .utf8 => utf8: {
// When entering the UTF8 state, we need to grab the // When entering the UTF8 state, we need to grab the
// last intermediate as our first byte and reset // last intermediate as our first byte and reset
@ -426,9 +434,8 @@ fn doAction(self: *Parser, action: TransitionAction, c: u8) ?Action {
.final = c, .final = c,
}, },
}, },
.put => Action{ .put => Action{ .dcs_put = c },
.dcs_put = c, .apc_put => Action{ .apc_put = c },
},
}; };
} }

View File

@ -865,6 +865,9 @@ selection: ?Selection = null,
/// The kitty keyboard settings. /// The kitty keyboard settings.
kitty_keyboard: kitty.KeyFlagStack = .{}, kitty_keyboard: kitty.KeyFlagStack = .{},
/// Kitty graphics protocol state.
kitty_images: kitty.graphics.ImageStorage = .{},
/// Initialize a new screen. /// Initialize a new screen.
pub fn init( pub fn init(
alloc: Allocator, alloc: Allocator,
@ -889,6 +892,7 @@ pub fn init(
} }
pub fn deinit(self: *Screen) void { pub fn deinit(self: *Screen) void {
self.kitty_images.deinit(self.alloc);
self.storage.deinit(self.alloc); self.storage.deinit(self.alloc);
self.deinitGraphemes(); 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 /// want to do that yet (i.e. are they writing to the end of the screen
/// or not). /// or not).
pub fn scroll(self: *Screen, behavior: Scroll) !void { 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) { switch (behavior) {
// Setting viewport offset to zero makes row 0 be at self.top // Setting viewport offset to zero makes row 0 be at self.top
// which is the top! // which is the top!
@ -2104,6 +2113,9 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void {
// No resize necessary // No resize necessary
if (self.rows == rows) return; 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 // 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 // reflow in any way, so we do the quicker thing and do a resize
// without reflow checks. // without reflow checks.
@ -2111,6 +2123,9 @@ pub fn resize(self: *Screen, rows: usize, cols: usize) !void {
return; 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 // If our columns increased, we alloc space for the new column width
// and go through each row and reflow if necessary. // and go through each row and reflow if necessary.
if (cols > self.cols) { if (cols > self.cols) {

View File

@ -15,6 +15,7 @@ const ansi = @import("ansi.zig");
const modes = @import("modes.zig"); const modes = @import("modes.zig");
const charsets = @import("charsets.zig"); const charsets = @import("charsets.zig");
const csi = @import("csi.zig"); const csi = @import("csi.zig");
const kitty = @import("kitty.zig");
const sgr = @import("sgr.zig"); const sgr = @import("sgr.zig");
const Tabstops = @import("Tabstops.zig"); const Tabstops = @import("Tabstops.zig");
const trace = @import("tracy").trace; const trace = @import("tracy").trace;
@ -62,6 +63,10 @@ tabstops: Tabstops,
rows: usize, rows: usize,
cols: 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. /// The current scrolling region.
scrolling_region: ScrollingRegion, scrolling_region: ScrollingRegion,
@ -188,7 +193,11 @@ pub const AlternateScreenOptions = struct {
/// * has its own cursor state (included saved cursor) /// * has its own cursor state (included saved cursor)
/// * does not support scrollback /// * 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()); const tracy = trace(@src());
defer tracy.end(); defer tracy.end();
@ -215,12 +224,16 @@ pub fn alternateScreen(self: *Terminal, options: AlternateScreenOptions) void {
self.screen.selection = null; self.screen.selection = null;
if (options.clear_on_enter) { if (options.clear_on_enter) {
self.eraseDisplay(.complete); self.eraseDisplay(alloc, .complete);
} }
} }
/// Switch back to the primary screen (reset alternate screen mode). /// 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()); const tracy = trace(@src());
defer tracy.end(); 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? // TODO(mitchellh): what happens if we enter alternate screen multiple times?
if (self.active_screen == .primary) return; 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 // Switch the screens
const old = self.screen; 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); try self.resize(alloc, 0, self.rows);
// TODO: do not clear screen flag mode // TODO: do not clear screen flag mode
self.eraseDisplay(.complete); self.eraseDisplay(alloc, .complete);
self.setCursorPos(1, 1); self.setCursorPos(1, 1);
// TODO: left/right margins // TODO: left/right margins
@ -296,6 +309,9 @@ pub fn resize(self: *Terminal, alloc: Allocator, cols_req: usize, rows: usize) !
else else
cols_req; 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 // Resize our tabstops
// TODO: use resize, but it doesn't set new tabstops // TODO: use resize, but it doesn't set new tabstops
if (self.cols != cols) { if (self.cols != cols) {
@ -986,6 +1002,7 @@ pub fn setCursorColAbsolute(self: *Terminal, col_req: usize) void {
/// TODO: test /// TODO: test
pub fn eraseDisplay( pub fn eraseDisplay(
self: *Terminal, self: *Terminal,
alloc: Allocator,
mode: csi.EraseDisplay, mode: csi.EraseDisplay,
) void { ) void {
const tracy = trace(@src()); const tracy = trace(@src());
@ -1002,6 +1019,9 @@ pub fn eraseDisplay(
// Unsets pending wrap state // Unsets pending wrap state
self.screen.cursor.pending_wrap = false; self.screen.cursor.pending_wrap = false;
// Clear all Kitty graphics state for this screen
self.screen.kitty_images.delete(alloc, self, .{ .all = true });
}, },
.below => { .below => {
@ -1555,18 +1575,34 @@ pub fn getPwd(self: *const Terminal) ?[]const u8 {
return self.pwd.items; 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 /// Full reset
pub fn fullReset(self: *Terminal) void { pub fn fullReset(self: *Terminal, alloc: Allocator) void {
self.primaryScreen(.{ .clear_on_exit = true, .cursor_save = true }); self.primaryScreen(alloc, .{ .clear_on_exit = true, .cursor_save = true });
self.charset = .{}; self.charset = .{};
self.eraseDisplay(.scrollback); self.eraseDisplay(alloc, .scrollback);
self.eraseDisplay(.complete); self.eraseDisplay(alloc, .complete);
self.modes = .{}; self.modes = .{};
self.flags = .{}; self.flags = .{};
self.tabstops.reset(0); self.tabstops.reset(0);
self.screen.cursor = .{}; self.screen.cursor = .{};
self.screen.saved_cursor = .{}; self.screen.saved_cursor = .{};
self.screen.selection = null; self.screen.selection = null;
self.screen.kitty_keyboard = .{};
self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1 }; self.scrolling_region = .{ .top = 0, .bottom = self.rows - 1 };
self.previous_char = null; self.previous_char = null;
self.pwd.clearRetainingCapacity(); self.pwd.clearRetainingCapacity();
@ -2561,7 +2597,7 @@ test "Terminal: cursorIsAtPrompt alternate screen" {
try testing.expect(t.cursorIsAtPrompt()); try testing.expect(t.cursorIsAtPrompt());
// Secondary screen is never a prompt // Secondary screen is never a prompt
t.alternateScreen(.{}); t.alternateScreen(alloc, .{});
try testing.expect(!t.cursorIsAtPrompt()); try testing.expect(!t.cursorIsAtPrompt());
t.markSemanticPrompt(.prompt); t.markSemanticPrompt(.prompt);
try testing.expect(!t.cursorIsAtPrompt()); try testing.expect(!t.cursorIsAtPrompt());

137
src/terminal/apc.zig Normal file
View 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);
}

View File

@ -1,154 +1,8 @@
//! Types and functions related to Kitty protocols. //! 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 test {
/// of the CSI > u and CSI < u sequences. We implement the stack as @import("std").testing.refAllDecls(@This());
/// 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(),
);
} }

View 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");

View 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());
}

View 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",
}
}

View 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, .{});
}

View 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
View 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(),
);
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 86 B

View File

@ -0,0 +1 @@
DRoeCxgcCxcjEh4qDBgkCxcjChYiCxcjCRclBRMhBxIXHysvTVNRbHJwcXB2Li0zCBYXEyEiCxkaDBobChcbCBUZDxsnBBAcEBwoChYiCxcjDBgkDhwqBxUjDBccm6aqy9HP1NrYzs3UsK+2IjAxCBYXCBYXBxUWFBoaDxUVICYqIyktERcZDxUXDxUVEhgYDhUTCxIQGh8XusC4zM7FvL61q6elmZWTTVtcDBobDRscCxkaKS8vaW9vxMnOur/EiY+RaW5wICYmW2FhfYOBQEZEnqSc4ebeqauilZaOsa2rm5eVcH5/GigpChgZCBYX0NHP3d7c3tzbx8XExsTEvry8wL241dLN0tDF0tDF29nM4d/StbKpzMrAUk5DZmJXeYSGKTU3ER0fDRkb1tfVysvJ0tDPsa+tr6ytop+gmZaRqaahuritw8G2urirqKaZiYZ9paKZZmJXamZbOkZIDhocBxMVBBASxMDBtrKzqqanoZ2ejYeLeHF2eXFvhn58npePta6ml5CKgXp0W1hPaWZdZWdSYmRPFiYADR0AFCQAEyMAt7O0lJCRf3t8eHR1Zl9kY1xhYVpYbGRieXJqeHFpdW1oc2tmcG1kX1xTbW9ajY96jp55kaF8kKB7kaF8sK6rcnFtX11cXFpZW1pWWFdTXVpTXltUaGJgY11bY11da2Vla25dam1ccHtTnqmBorVtp7pypLdvobRsh4aCaGdjWFZVXFpZYWBcZ2ZiaGVeZGFaY11bYlxaV1FRZ2FhdHdmbG9egItjo66GpLdvq752rL93rsF5kpKIZ2ddWFxTW19WbnZdipJ6cnhaaW9RaGhgV1ZPY2Jga2poanFQd35dk6Vpn7B0oLFvorNxm6xqmKlnv760enpwVlpRW19Wc3til5+Hl55/k5p7iIiAcnJqd3Z0bm1rcHdWh45tipxgladrkaJglKVjkaJgkqNh09DJiYZ/YmZdY2deeYZYjJlrj51ijpxhztHClJaIdHNvdHNvanNHi5RpmaxnjKBbmqhrmadqkJ5hi5lcxsO8jImCaGtiYmZdg5Bikp9xjJpfjpxh1djJqq2eamllZ2Zid4BVmKF2kqZhh5tWlaNmlaNmjpxfjJpdw729rqiodnZ0cHBuiplij55nj6FVjJ5SzdC9t7qncW1sXlpZh45iqbCEmKllmapmmqlqnq1unaxtoK9w

File diff suppressed because one or more lines are too long

View File

@ -5,6 +5,7 @@ const stream = @import("stream.zig");
const ansi = @import("ansi.zig"); const ansi = @import("ansi.zig");
const csi = @import("csi.zig"); const csi = @import("csi.zig");
const sgr = @import("sgr.zig"); const sgr = @import("sgr.zig");
pub const apc = @import("apc.zig");
pub const point = @import("point.zig"); pub const point = @import("point.zig");
pub const color = @import("color.zig"); pub const color = @import("color.zig");
pub const kitty = @import("kitty.zig"); pub const kitty = @import("kitty.zig");

View File

@ -125,10 +125,10 @@ fn genTable() Table {
const source = State.sos_pm_apc_string; const source = State.sos_pm_apc_string;
// events // events
single(&result, 0x19, source, source, .ignore); single(&result, 0x19, source, source, .apc_put);
range(&result, 0, 0x17, source, source, .ignore); range(&result, 0, 0x17, source, source, .apc_put);
range(&result, 0x1C, 0x1F, source, source, .ignore); range(&result, 0x1C, 0x1F, source, source, .apc_put);
range(&result, 0x20, 0x7F, source, source, .ignore); range(&result, 0x20, 0x7F, source, source, .apc_put);
} }
// escape // escape

View File

@ -64,8 +64,17 @@ pub fn Stream(comptime Handler: type) type {
.esc_dispatch => |esc| try self.escDispatch(esc), .esc_dispatch => |esc| try self.escDispatch(esc),
.osc_dispatch => |cmd| try self.oscDispatch(cmd), .osc_dispatch => |cmd| try self.oscDispatch(cmd),
.dcs_hook => |dcs| log.warn("unhandled DCS hook: {}", .{dcs}), .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", .{}), .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", .{}),
} }
} }
} }

View File

@ -47,10 +47,6 @@ subprocess: Subprocess,
/// just stores internal state about a grid. /// just stores internal state about a grid.
terminal: terminal.Terminal, 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 /// The shared render state
renderer_state: *renderer.State, renderer_state: *renderer.State,
@ -75,6 +71,7 @@ data: ?*EventData,
/// pass around Config pointers which makes memory management a pain. /// pass around Config pointers which makes memory management a pain.
pub const DerivedConfig = struct { pub const DerivedConfig = struct {
palette: terminal.color.Palette, palette: terminal.color.Palette,
image_storage_limit: usize,
pub fn init( pub fn init(
alloc_gpa: Allocator, alloc_gpa: Allocator,
@ -84,6 +81,7 @@ pub const DerivedConfig = struct {
return .{ return .{
.palette = config.palette.value, .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); errdefer term.deinit(alloc);
term.color_palette = opts.config.palette; 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); var subprocess = try Subprocess.init(alloc, opts);
errdefer subprocess.deinit(); 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{ return Exec{
.alloc = alloc, .alloc = alloc,
.terminal = term, .terminal = term,
.terminal_stream = undefined,
.subprocess = subprocess, .subprocess = subprocess,
.renderer_state = opts.renderer_state, .renderer_state = opts.renderer_state,
.renderer_wakeup = opts.renderer_wakeup, .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 // Update the palette. Note this will only apply to new colors drawn
// since we decode all palette colors to RGB on usage. // since we decode all palette colors to RGB on usage.
self.terminal.color_palette = config.palette; 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. /// Resize the terminal.
@ -256,7 +271,7 @@ pub fn resize(
screen_size: renderer.ScreenSize, screen_size: renderer.ScreenSize,
padding: renderer.Padding, padding: renderer.Padding,
) !void { ) !void {
// Update the size of our pty // Update the size of our pty.
const padded_size = screen_size.subPadding(padding); const padded_size = screen_size.subPadding(padding);
try self.subprocess.resize(grid_size, padded_size); try self.subprocess.resize(grid_size, padded_size);
@ -269,7 +284,15 @@ pub fn resize(
defer self.renderer_state.mutex.unlock(); defer self.renderer_state.mutex.unlock();
// Update the size of our terminal state // 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 // Stop our process watcher
self.process.deinit(); self.process.deinit();
// Clear any StreamHandler state
self.terminal_stream.handler.deinit();
} }
/// This queues a render operation with the renderer thread. The render /// 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", .{}); 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 .{ return .{
.arena = arena, .arena = arena,
.env = env, .env = env,
@ -665,7 +694,7 @@ const Subprocess = struct {
.path = final_path, .path = final_path,
.args = args, .args = args,
.grid_size = opts.grid_size, .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, grid_size: *renderer.GridSize,
terminal: *terminal.Terminal, 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 /// 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 /// mailbox. This can be used by callers to determine if they need
/// to wake up the writer. /// to wake up the writer.
writer_messaged: bool = false, writer_messaged: bool = false,
pub fn deinit(self: *StreamHandler) void {
self.apc.deinit();
}
inline fn queueRender(self: *StreamHandler) !void { inline fn queueRender(self: *StreamHandler) !void {
try self.ev.queueRender(); try self.ev.queueRender();
} }
@ -1060,6 +1099,35 @@ const StreamHandler = struct {
self.writer_messaged = true; 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 { pub fn print(self: *StreamHandler, ch: u21) !void {
try self.terminal.print(ch); try self.terminal.print(ch);
} }
@ -1144,7 +1212,7 @@ const StreamHandler = struct {
try self.queueRender(); try self.queueRender();
} }
self.terminal.eraseDisplay(mode); self.terminal.eraseDisplay(self.alloc, mode);
} }
pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine) !void { pub fn eraseLine(self: *StreamHandler, mode: terminal.EraseLine) !void {
@ -1239,9 +1307,9 @@ const StreamHandler = struct {
}; };
if (enabled) if (enabled)
self.terminal.alternateScreen(opts) self.terminal.alternateScreen(self.alloc, opts)
else else
self.terminal.primaryScreen(opts); self.terminal.primaryScreen(self.alloc, opts);
// Schedule a render since we changed screens // Schedule a render since we changed screens
try self.queueRender(); try self.queueRender();
@ -1409,7 +1477,7 @@ const StreamHandler = struct {
pub fn fullReset( pub fn fullReset(
self: *StreamHandler, self: *StreamHandler,
) !void { ) !void {
self.terminal.fullReset(); self.terminal.fullReset(self.alloc);
} }
pub fn queryKittyKeyboard(self: *StreamHandler) !void { pub fn queryKittyKeyboard(self: *StreamHandler) !void {

View File

@ -12,6 +12,9 @@ grid_size: renderer.GridSize,
/// The size of the viewport in pixels. /// The size of the viewport in pixels.
screen_size: renderer.ScreenSize, screen_size: renderer.ScreenSize,
/// The padding of the viewport.
padding: renderer.Padding,
/// The full app configuration. This is only available during initialization. /// 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 /// The memory it points to is NOT stable after the init call so any values
/// in here must be copied. /// in here must be copied.