mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-15 16:26:08 +03:00
terminal2: starting to port kitty graphics
This commit is contained in:
@ -1405,6 +1405,10 @@ pub fn untrackPin(self: *PageList, p: *Pin) void {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn countTrackedPins(self: *const PageList) usize {
|
||||
return self.tracked_pins.count();
|
||||
}
|
||||
|
||||
/// Returns the viewport for the given pin, prefering to pin to
|
||||
/// "active" if the pin is within the active area.
|
||||
fn pinIsActive(self: *const PageList, p: Pin) bool {
|
||||
|
@ -6,4 +6,7 @@ pub usingnamespace @import("../terminal/kitty/key.zig");
|
||||
|
||||
test {
|
||||
@import("std").testing.refAllDecls(@This());
|
||||
|
||||
_ = @import("kitty/graphics.zig");
|
||||
_ = @import("kitty/key.zig");
|
||||
}
|
||||
|
22
src/terminal2/kitty/graphics.zig
Normal file
22
src/terminal2/kitty/graphics.zig
Normal file
@ -0,0 +1,22 @@
|
||||
//! Kitty graphics protocol support.
|
||||
//!
|
||||
//! Documentation:
|
||||
//! https://sw.kovidgoyal.net/kitty/graphics-protocol
|
||||
//!
|
||||
//! Unimplemented features that are still todo:
|
||||
//! - shared memory transmit
|
||||
//! - virtual placement w/ unicode
|
||||
//! - animation
|
||||
//!
|
||||
//! Performance:
|
||||
//! The performance of this particular subsystem of Ghostty is not great.
|
||||
//! We can avoid a lot more allocations, we can replace some C code (which
|
||||
//! implicitly allocates) with native Zig, we can improve the data structures
|
||||
//! to avoid repeated lookups, etc. I tried to avoid pessimization but my
|
||||
//! aim to ship a v1 of this implementation came at some cost. I learned a lot
|
||||
//! though and I think we can go back through and fix this up.
|
||||
|
||||
pub usingnamespace @import("graphics_command.zig");
|
||||
pub usingnamespace @import("graphics_exec.zig");
|
||||
pub usingnamespace @import("graphics_image.zig");
|
||||
pub usingnamespace @import("graphics_storage.zig");
|
984
src/terminal2/kitty/graphics_command.zig
Normal file
984
src/terminal2/kitty/graphics_command.zig
Normal file
@ -0,0 +1,984 @@
|
||||
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
|
||||
|
||||
// The following are not supported directly via the protocol
|
||||
// but they are formats that a png may decode to that we
|
||||
// support.
|
||||
grey_alpha,
|
||||
};
|
||||
|
||||
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());
|
||||
}
|
344
src/terminal2/kitty/graphics_exec.zig
Normal file
344
src/terminal2/kitty/graphics_exec.zig
Normal file
@ -0,0 +1,344 @@
|
||||
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;
|
||||
}
|
||||
|
||||
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,
|
||||
result.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_image_id;
|
||||
storage.next_image_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",
|
||||
}
|
||||
}
|
776
src/terminal2/kitty/graphics_image.zig
Normal file
776
src/terminal2/kitty/graphics_image.zig
Normal file
@ -0,0 +1,776 @@
|
||||
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;
|
||||
};
|
||||
|
||||
if (comptime builtin.os.tag != .windows) {
|
||||
if (std.mem.indexOfScalar(u8, buf[0..size], 0) != null) {
|
||||
// std.os.realpath *asserts* that the path does not have
|
||||
// internal nulls instead of erroring.
|
||||
log.warn("failed to get absolute path: BadPathName", .{});
|
||||
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.allocTmpDir(std.heap.page_allocator)) |dir| {
|
||||
defer internal_os.freeTmpDir(std.heap.page_allocator, 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) {
|
||||
.grey_alpha => 2,
|
||||
.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.decompressor(fbs.reader());
|
||||
|
||||
// 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 < 2 or bpp > 4) {
|
||||
log.warn("png with unsupported bpp={}", .{bpp});
|
||||
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) {
|
||||
2 => .grey_alpha,
|
||||
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;
|
||||
const 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;
|
||||
const 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, .{});
|
||||
}
|
919
src/terminal2/kitty/graphics_storage.zig
Normal file
919
src/terminal2/kitty/graphics_storage.zig
Normal file
@ -0,0 +1,919 @@
|
||||
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 PageList = @import("../PageList.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 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 image ID. We start mid-way
|
||||
/// through the u32 range to avoid collisions with buggy programs.
|
||||
next_image_id: u32 = 2147483647,
|
||||
|
||||
/// This is the next automatically assigned placement ID. This is never
|
||||
/// user-facing so we can start at 0. This is 32-bits because we use
|
||||
/// the same space for external placement IDs. We can start at zero
|
||||
/// because any number is valid.
|
||||
next_internal_placement_id: u32 = 0,
|
||||
|
||||
/// 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,
|
||||
t: *terminal.Terminal,
|
||||
) 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.clearPlacements(t);
|
||||
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,
|
||||
});
|
||||
|
||||
// The important piece here is that the placement ID needs to
|
||||
// be marked internal if it is zero. This allows multiple placements
|
||||
// to be added for the same image. If it is non-zero, then it is
|
||||
// an external placement ID and we can only have one placement
|
||||
// per (image id, placement id) pair.
|
||||
const key: PlacementKey = .{
|
||||
.image_id = image_id,
|
||||
.placement_id = if (placement_id == 0) .{
|
||||
.tag = .internal,
|
||||
.id = id: {
|
||||
defer self.next_internal_placement_id +%= 1;
|
||||
break :id self.next_internal_placement_id;
|
||||
},
|
||||
} else .{
|
||||
.tag = .external,
|
||||
.id = placement_id,
|
||||
},
|
||||
};
|
||||
|
||||
const gop = try self.placements.getOrPut(alloc, key);
|
||||
gop.value_ptr.* = p;
|
||||
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
fn clearPlacements(self: *ImageStorage, t: *terminal.Terminal) void {
|
||||
var it = self.placements.iterator();
|
||||
while (it.next()) |entry| entry.value_ptr.deinit(t);
|
||||
self.placements.clearRetainingCapacity();
|
||||
}
|
||||
|
||||
/// 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: *terminal.Terminal,
|
||||
cmd: command.Delete,
|
||||
) void {
|
||||
switch (cmd) {
|
||||
.all => |delete_images| if (delete_images) {
|
||||
// We just reset our entire state.
|
||||
self.deinit(alloc, t);
|
||||
self.* = .{
|
||||
.dirty = true,
|
||||
.total_limit = self.total_limit,
|
||||
};
|
||||
} else {
|
||||
// Delete all our placements
|
||||
self.clearPlacements(t);
|
||||
self.placements.deinit(alloc);
|
||||
self.placements = .{};
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.id => |v| self.deleteById(
|
||||
alloc,
|
||||
t,
|
||||
v.image_id,
|
||||
v.placement_id,
|
||||
v.delete,
|
||||
),
|
||||
|
||||
.newest => |v| newest: {
|
||||
if (true) @panic("TODO");
|
||||
const img = self.imageByNumber(v.image_number) orelse break :newest;
|
||||
self.deleteById(alloc, img.id, v.placement_id, v.delete);
|
||||
},
|
||||
|
||||
.intersect_cursor => |delete_images| {
|
||||
if (true) @panic("TODO");
|
||||
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| {
|
||||
if (true) @panic("TODO");
|
||||
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| {
|
||||
if (true) @panic("TODO");
|
||||
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| {
|
||||
if (true) @panic("TODO");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.row => |v| {
|
||||
if (true) @panic("TODO");
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
.z => |v| {
|
||||
if (true) @panic("TODO");
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
},
|
||||
|
||||
// We don't support animation frames yet so they are successfully
|
||||
// deleted!
|
||||
.animation_frames => {},
|
||||
}
|
||||
}
|
||||
|
||||
fn deleteById(
|
||||
self: *ImageStorage,
|
||||
alloc: Allocator,
|
||||
t: *terminal.Terminal,
|
||||
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) {
|
||||
entry.value_ptr.deinit(t);
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (self.placements.getEntry(.{
|
||||
.image_id = image_id,
|
||||
.placement_id = .{ .tag = .external, .id = placement_id },
|
||||
})) |entry| {
|
||||
entry.value_ptr.deinit(t);
|
||||
self.placements.removeByPtr(entry.key_ptr);
|
||||
}
|
||||
}
|
||||
|
||||
// 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);
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// Mark dirty to force redraw
|
||||
self.dirty = true;
|
||||
}
|
||||
|
||||
/// 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: packed struct {
|
||||
tag: enum(u1) { internal, external },
|
||||
id: u32,
|
||||
},
|
||||
};
|
||||
|
||||
pub const Placement = struct {
|
||||
/// The tracked pin for this placement.
|
||||
pin: *PageList.Pin,
|
||||
|
||||
/// 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,
|
||||
|
||||
pub fn deinit(
|
||||
self: *const Placement,
|
||||
t: *terminal.Terminal,
|
||||
) void {
|
||||
t.screen.pages.untrackPin(self.pin);
|
||||
}
|
||||
|
||||
/// 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,
|
||||
},
|
||||
};
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
// Our pin for the placement
|
||||
fn trackPin(
|
||||
t: *terminal.Terminal,
|
||||
pt: point.Point.Coordinate,
|
||||
) !*PageList.Pin {
|
||||
return try t.screen.pages.trackPin(t.screen.pages.pin(.{
|
||||
.active = pt,
|
||||
}).?);
|
||||
}
|
||||
|
||||
test "storage: add placement with zero placement id" {
|
||||
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, &t);
|
||||
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, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) });
|
||||
try s.addPlacement(alloc, 1, 0, .{ .pin = try trackPin(&t, .{ .x = 25, .y = 25 }) });
|
||||
|
||||
try testing.expectEqual(@as(usize, 2), 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 = .{ .tag = .internal, .id = 0 },
|
||||
}) != null);
|
||||
try testing.expect(s.placements.get(.{
|
||||
.image_id = 1,
|
||||
.placement_id = .{ .tag = .internal, .id = 1 },
|
||||
}) != null);
|
||||
}
|
||||
|
||||
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);
|
||||
const tracked = t.screen.pages.countTrackedPins();
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, &t);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
|
||||
s.dirty = false;
|
||||
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());
|
||||
try testing.expectEqual(tracked, t.screen.pages.countTrackedPins());
|
||||
}
|
||||
|
||||
test "storage: delete all placements and images preserves limit" {
|
||||
const testing = std.testing;
|
||||
const alloc = testing.allocator;
|
||||
var t = try terminal.Terminal.init(alloc, 3, 3);
|
||||
defer t.deinit(alloc);
|
||||
const tracked = t.screen.pages.countTrackedPins();
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, &t);
|
||||
s.total_limit = 5000;
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
|
||||
s.dirty = false;
|
||||
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());
|
||||
try testing.expectEqual(@as(usize, 5000), s.total_limit);
|
||||
try testing.expectEqual(tracked, t.screen.pages.countTrackedPins());
|
||||
}
|
||||
|
||||
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);
|
||||
const tracked = t.screen.pages.countTrackedPins();
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, &t);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
|
||||
s.dirty = false;
|
||||
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());
|
||||
try testing.expectEqual(tracked, t.screen.pages.countTrackedPins());
|
||||
}
|
||||
|
||||
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);
|
||||
const tracked = t.screen.pages.countTrackedPins();
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, &t);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
|
||||
s.dirty = false;
|
||||
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());
|
||||
try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins());
|
||||
}
|
||||
|
||||
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);
|
||||
const tracked = t.screen.pages.countTrackedPins();
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, &t);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
|
||||
s.dirty = false;
|
||||
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());
|
||||
try testing.expectEqual(tracked + 1, t.screen.pages.countTrackedPins());
|
||||
}
|
||||
|
||||
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);
|
||||
const tracked = t.screen.pages.countTrackedPins();
|
||||
|
||||
var s: ImageStorage = .{};
|
||||
defer s.deinit(alloc, &t);
|
||||
try s.addImage(alloc, .{ .id = 1 });
|
||||
try s.addImage(alloc, .{ .id = 2 });
|
||||
try s.addImage(alloc, .{ .id = 3 });
|
||||
try s.addPlacement(alloc, 1, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 1, 2, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
try s.addPlacement(alloc, 2, 1, .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) });
|
||||
|
||||
s.dirty = false;
|
||||
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());
|
||||
try testing.expectEqual(tracked + 2, t.screen.pages.countTrackedPins());
|
||||
}
|
||||
|
||||
// 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.dirty = false;
|
||||
// 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 = .{ .tag = .external, .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.dirty = false;
|
||||
// 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 = .{ .tag = .external, .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.dirty = false;
|
||||
// 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.dirty = false;
|
||||
// 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 = .{ .tag = .external, .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.dirty = false;
|
||||
// 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 = .{ .tag = .external, .id = 1 },
|
||||
// }) != null);
|
||||
// }
|
151
src/terminal2/kitty/key.zig
Normal file
151
src/terminal2/kitty/key.zig
Normal file
@ -0,0 +1,151 @@
|
||||
//! Kitty keyboard protocol support.
|
||||
|
||||
const std = @import("std");
|
||||
|
||||
/// Stack for the key flags. This implements the push/pop behavior
|
||||
/// of the CSI > u and CSI < u sequences. We implement the stack as
|
||||
/// fixed size to avoid heap allocation.
|
||||
pub const KeyFlagStack = struct {
|
||||
const len = 8;
|
||||
|
||||
flags: [len]KeyFlags = .{.{}} ** len,
|
||||
idx: u3 = 0,
|
||||
|
||||
/// Return the current stack value
|
||||
pub fn current(self: KeyFlagStack) KeyFlags {
|
||||
return self.flags[self.idx];
|
||||
}
|
||||
|
||||
/// Perform the "set" operation as described in the spec for
|
||||
/// the CSI = u sequence.
|
||||
pub fn set(
|
||||
self: *KeyFlagStack,
|
||||
mode: KeySetMode,
|
||||
v: KeyFlags,
|
||||
) void {
|
||||
switch (mode) {
|
||||
.set => self.flags[self.idx] = v,
|
||||
.@"or" => self.flags[self.idx] = @bitCast(
|
||||
self.flags[self.idx].int() | v.int(),
|
||||
),
|
||||
.not => self.flags[self.idx] = @bitCast(
|
||||
self.flags[self.idx].int() & ~v.int(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/// Push a new set of flags onto the stack. If the stack is full
|
||||
/// then the oldest entry is evicted.
|
||||
pub fn push(self: *KeyFlagStack, flags: KeyFlags) void {
|
||||
// Overflow and wrap around if we're full, which evicts
|
||||
// the oldest entry.
|
||||
self.idx +%= 1;
|
||||
self.flags[self.idx] = flags;
|
||||
}
|
||||
|
||||
/// Pop `n` entries from the stack. This will just wrap around
|
||||
/// if `n` is greater than the amount in the stack.
|
||||
pub fn pop(self: *KeyFlagStack, n: usize) void {
|
||||
// If n is more than our length then we just reset the stack.
|
||||
// This also avoids a DoS vector where a malicious client
|
||||
// could send a huge number of pop commands to waste cpu.
|
||||
if (n >= self.flags.len) {
|
||||
self.idx = 0;
|
||||
self.flags = .{.{}} ** len;
|
||||
return;
|
||||
}
|
||||
|
||||
for (0..n) |_| {
|
||||
self.flags[self.idx] = .{};
|
||||
self.idx -%= 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we the overflow works as expected
|
||||
test {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.idx = stack.flags.len - 1;
|
||||
stack.idx +%= 1;
|
||||
try testing.expect(stack.idx == 0);
|
||||
|
||||
stack.idx = 0;
|
||||
stack.idx -%= 1;
|
||||
try testing.expect(stack.idx == stack.flags.len - 1);
|
||||
}
|
||||
};
|
||||
|
||||
/// The possible flags for the Kitty keyboard protocol.
|
||||
pub const KeyFlags = packed struct(u5) {
|
||||
disambiguate: bool = false,
|
||||
report_events: bool = false,
|
||||
report_alternates: bool = false,
|
||||
report_all: bool = false,
|
||||
report_associated: bool = false,
|
||||
|
||||
pub fn int(self: KeyFlags) u5 {
|
||||
return @bitCast(self);
|
||||
}
|
||||
|
||||
// Its easy to get packed struct ordering wrong so this test checks.
|
||||
test {
|
||||
const testing = std.testing;
|
||||
|
||||
try testing.expectEqual(
|
||||
@as(u5, 0b1),
|
||||
(KeyFlags{ .disambiguate = true }).int(),
|
||||
);
|
||||
try testing.expectEqual(
|
||||
@as(u5, 0b10),
|
||||
(KeyFlags{ .report_events = true }).int(),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
/// The possible modes for setting the key flags.
|
||||
pub const KeySetMode = enum { set, @"or", not };
|
||||
|
||||
test "KeyFlagStack: push pop" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.push(.{ .disambiguate = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.pop(1);
|
||||
try testing.expectEqual(KeyFlags{}, stack.current());
|
||||
}
|
||||
|
||||
test "KeyFlagStack: pop big number" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.pop(100);
|
||||
try testing.expectEqual(KeyFlags{}, stack.current());
|
||||
}
|
||||
|
||||
test "KeyFlagStack: set" {
|
||||
const testing = std.testing;
|
||||
var stack: KeyFlagStack = .{};
|
||||
stack.set(.set, .{ .disambiguate = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.set(.@"or", .{ .report_events = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{
|
||||
.disambiguate = true,
|
||||
.report_events = true,
|
||||
},
|
||||
stack.current(),
|
||||
);
|
||||
|
||||
stack.set(.not, .{ .report_events = true });
|
||||
try testing.expectEqual(
|
||||
KeyFlags{ .disambiguate = true },
|
||||
stack.current(),
|
||||
);
|
||||
}
|
BIN
src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data
vendored
Normal file
BIN
src/terminal2/kitty/testdata/image-png-none-50x76-2147483647-raw.data
vendored
Normal file
Binary file not shown.
After Width: | Height: | Size: 86 B |
1
src/terminal2/kitty/testdata/image-rgb-none-20x15-2147483647.data
vendored
Normal file
1
src/terminal2/kitty/testdata/image-rgb-none-20x15-2147483647.data
vendored
Normal file
@ -0,0 +1 @@
|
||||
DRoeCxgcCxcjEh4qDBgkCxcjChYiCxcjCRclBRMhBxIXHysvTVNRbHJwcXB2Li0zCBYXEyEiCxkaDBobChcbCBUZDxsnBBAcEBwoChYiCxcjDBgkDhwqBxUjDBccm6aqy9HP1NrYzs3UsK+2IjAxCBYXCBYXBxUWFBoaDxUVICYqIyktERcZDxUXDxUVEhgYDhUTCxIQGh8XusC4zM7FvL61q6elmZWTTVtcDBobDRscCxkaKS8vaW9vxMnOur/EiY+RaW5wICYmW2FhfYOBQEZEnqSc4ebeqauilZaOsa2rm5eVcH5/GigpChgZCBYX0NHP3d7c3tzbx8XExsTEvry8wL241dLN0tDF0tDF29nM4d/StbKpzMrAUk5DZmJXeYSGKTU3ER0fDRkb1tfVysvJ0tDPsa+tr6ytop+gmZaRqaahuritw8G2urirqKaZiYZ9paKZZmJXamZbOkZIDhocBxMVBBASxMDBtrKzqqanoZ2ejYeLeHF2eXFvhn58npePta6ml5CKgXp0W1hPaWZdZWdSYmRPFiYADR0AFCQAEyMAt7O0lJCRf3t8eHR1Zl9kY1xhYVpYbGRieXJqeHFpdW1oc2tmcG1kX1xTbW9ajY96jp55kaF8kKB7kaF8sK6rcnFtX11cXFpZW1pWWFdTXVpTXltUaGJgY11bY11da2Vla25dam1ccHtTnqmBorVtp7pypLdvobRsh4aCaGdjWFZVXFpZYWBcZ2ZiaGVeZGFaY11bYlxaV1FRZ2FhdHdmbG9egItjo66GpLdvq752rL93rsF5kpKIZ2ddWFxTW19WbnZdipJ6cnhaaW9RaGhgV1ZPY2Jga2poanFQd35dk6Vpn7B0oLFvorNxm6xqmKlnv760enpwVlpRW19Wc3til5+Hl55/k5p7iIiAcnJqd3Z0bm1rcHdWh45tipxgladrkaJglKVjkaJgkqNh09DJiYZ/YmZdY2deeYZYjJlrj51ijpxhztHClJaIdHNvdHNvanNHi5RpmaxnjKBbmqhrmadqkJ5hi5lcxsO8jImCaGtiYmZdg5Bikp9xjJpfjpxh1djJqq2eamllZ2Zid4BVmKF2kqZhh5tWlaNmlaNmjpxfjJpdw729rqiodnZ0cHBuiplij55nj6FVjJ5SzdC9t7qncW1sXlpZh45iqbCEmKllmapmmqlqnq1unaxtoK9w
|
1
src/terminal2/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data
vendored
Normal file
1
src/terminal2/kitty/testdata/image-rgb-zlib_deflate-128x96-2147483647.data
vendored
Normal file
File diff suppressed because one or more lines are too long
@ -1,5 +1,6 @@
|
||||
const builtin = @import("builtin");
|
||||
|
||||
pub const kitty = @import("kitty.zig");
|
||||
pub const page = @import("page.zig");
|
||||
pub const point = @import("point.zig");
|
||||
pub const PageList = @import("PageList.zig");
|
||||
|
Reference in New Issue
Block a user