mirror of
https://github.com/ghostty-org/ghostty.git
synced 2025-07-16 00:36:07 +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
|
/// Returns the viewport for the given pin, prefering to pin to
|
||||||
/// "active" if the pin is within the active area.
|
/// "active" if the pin is within the active area.
|
||||||
fn pinIsActive(self: *const PageList, p: Pin) bool {
|
fn pinIsActive(self: *const PageList, p: Pin) bool {
|
||||||
|
@ -6,4 +6,7 @@ pub usingnamespace @import("../terminal/kitty/key.zig");
|
|||||||
|
|
||||||
test {
|
test {
|
||||||
@import("std").testing.refAllDecls(@This());
|
@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");
|
const builtin = @import("builtin");
|
||||||
|
|
||||||
|
pub const kitty = @import("kitty.zig");
|
||||||
pub const page = @import("page.zig");
|
pub const page = @import("page.zig");
|
||||||
pub const point = @import("point.zig");
|
pub const point = @import("point.zig");
|
||||||
pub const PageList = @import("PageList.zig");
|
pub const PageList = @import("PageList.zig");
|
||||||
|
Reference in New Issue
Block a user