images: kitty image protocol works

Signed-off-by: Tim Culverhouse <tim@timculverhouse.com>
This commit is contained in:
Tim Culverhouse 2024-01-30 16:26:35 -06:00
parent cc75fe6272
commit f901dde2a0
11 changed files with 167 additions and 265 deletions

View file

@ -43,8 +43,9 @@ pub fn main() !void {
// _always_ be called, but is left to the application to decide when // _always_ be called, but is left to the application to decide when
try vx.queryTerminal(); try vx.queryTerminal();
var img = try vaxis.Image.init(alloc, .{ .path = "vaxis.png" }, 1, .kitty); const img = try vx.loadImage(alloc, .{ .path = "vaxis.png" });
defer img.deinit();
var n: usize = 0;
// The main event loop. Vaxis provides a thread safe, blocking, buffered // The main event loop. Vaxis provides a thread safe, blocking, buffered
// queue which can serve as the primary event queue for an application // queue which can serve as the primary event queue for an application
@ -56,6 +57,7 @@ pub fn main() !void {
// enum has the fields for those events (ie "key_press", "winsize") // enum has the fields for those events (ie "key_press", "winsize")
switch (event) { switch (event) {
.key_press => |key| { .key_press => |key| {
n += 1;
if (key.matches('c', .{ .ctrl = true })) { if (key.matches('c', .{ .ctrl = true })) {
break :outer; break :outer;
} else if (key.matches('l', .{ .ctrl = true })) { } else if (key.matches('l', .{ .ctrl = true })) {
@ -79,7 +81,9 @@ pub fn main() !void {
// the old and only updated cells will be drawn // the old and only updated cells will be drawn
win.clear(); win.clear();
try img.draw(win); const child = win.initChild(n, n, .expand, .expand);
img.draw(child, false, 0);
// Render the screen // Render the screen
try vx.render(); try vx.render();

61
src/Image.zig Normal file
View file

@ -0,0 +1,61 @@
const std = @import("std");
const fmt = std.fmt;
const math = std.math;
const testing = std.testing;
const base64 = std.base64.standard.Encoder;
const zigimg = @import("zigimg");
const Window = @import("Window.zig");
const Winsize = @import("Tty.zig").Winsize;
const log = std.log.scoped(.image);
const Image = @This();
const transmit_opener = "\x1b_Gf=32,i={d},s={d},v={d},m={d};";
pub const Source = union(enum) {
path: []const u8,
mem: []const u8,
};
pub const Placement = struct {
img_id: u32,
z_index: i32,
scale: bool,
};
pub const CellSize = struct {
rows: usize,
cols: usize,
};
/// unique identifier for this image. This will be managed by the screen.
id: u32,
// width in pixels
width: usize,
// height in pixels
height: usize,
pub fn draw(self: Image, win: Window, scale: bool, z_index: i32) void {
const p = Placement{
.img_id = self.id,
.z_index = z_index,
.scale = scale,
};
win.writeCell(0, 0, .{ .image = p });
}
pub fn cellSize(self: Image, winsize: Winsize) !CellSize {
// cell geometry
const pix_per_col = try std.math.divCeil(usize, winsize.x_pixel, winsize.cols);
const pix_per_row = try std.math.divCeil(usize, winsize.y_pixel, winsize.rows);
const cell_width = std.math.divCeil(usize, self.width, pix_per_col) catch 0;
const cell_height = std.math.divCeil(usize, self.height, pix_per_row) catch 0;
return .{
.rows = cell_height,
.cols = cell_width,
};
}

View file

@ -3,8 +3,6 @@ const assert = std.debug.assert;
const Style = @import("cell.zig").Style; const Style = @import("cell.zig").Style;
const Cell = @import("cell.zig").Cell; const Cell = @import("cell.zig").Cell;
const Shape = @import("Mouse.zig").Shape; const Shape = @import("Mouse.zig").Shape;
const Image = @import("image/image.zig").Image;
const Placement = @import("Screen.zig").Placement;
const log = std.log.scoped(.internal_screen); const log = std.log.scoped(.internal_screen);
@ -37,13 +35,10 @@ cursor_vis: bool = false,
mouse_shape: Shape = .default, mouse_shape: Shape = .default,
images: std.ArrayList(Placement) = undefined,
/// sets each cell to the default cell /// sets each cell to the default cell
pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !InternalScreen { pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !InternalScreen {
var screen = InternalScreen{ var screen = InternalScreen{
.buf = try alloc.alloc(InternalCell, w * h), .buf = try alloc.alloc(InternalCell, w * h),
.images = std.ArrayList(Placement).init(alloc),
}; };
for (screen.buf, 0..) |_, i| { for (screen.buf, 0..) |_, i| {
screen.buf[i] = .{ screen.buf[i] = .{
@ -58,7 +53,6 @@ pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !InternalScreen {
} }
pub fn deinit(self: *InternalScreen, alloc: std.mem.Allocator) void { pub fn deinit(self: *InternalScreen, alloc: std.mem.Allocator) void {
self.images.deinit();
for (self.buf, 0..) |_, i| { for (self.buf, 0..) |_, i| {
self.buf[i].char.deinit(); self.buf[i].char.deinit();
self.buf[i].uri.deinit(); self.buf[i].uri.deinit();

View file

@ -3,30 +3,19 @@ const assert = std.debug.assert;
const Cell = @import("cell.zig").Cell; const Cell = @import("cell.zig").Cell;
const Shape = @import("Mouse.zig").Shape; const Shape = @import("Mouse.zig").Shape;
const Image = @import("image/image.zig").Image; const Image = @import("Image.zig");
const Winsize = @import("Tty.zig").Winsize;
const log = std.log.scoped(.screen); const log = std.log.scoped(.screen);
const Screen = @This(); const Screen = @This();
pub const Placement = struct {
img: Image,
placement_id: u32,
col: usize,
row: usize,
/// two placements are considered equal if their image id and their
/// placement id are equal
pub fn eql(self: Placement, tgt: Placement) bool {
if (self.img.getId() != tgt.img.getId()) return false;
if (self.placement_id != tgt.placement_id) return false;
return true;
}
};
width: usize = 0, width: usize = 0,
height: usize = 0, height: usize = 0,
width_pix: usize = 0,
height_pix: usize = 0,
buf: []Cell = undefined, buf: []Cell = undefined,
cursor_row: usize = 0, cursor_row: usize = 0,
@ -37,14 +26,15 @@ unicode: bool = false,
mouse_shape: Shape = .default, mouse_shape: Shape = .default,
images: std.ArrayList(Placement) = undefined, pub fn init(alloc: std.mem.Allocator, winsize: Winsize) !Screen {
const w = winsize.cols;
pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !Screen { const h = winsize.rows;
var self = Screen{ var self = Screen{
.buf = try alloc.alloc(Cell, w * h), .buf = try alloc.alloc(Cell, w * h),
.width = w, .width = w,
.height = h, .height = h,
.images = std.ArrayList(Placement).init(alloc), .width_pix = winsize.x_pixel,
.height_pix = winsize.y_pixel,
}; };
for (self.buf, 0..) |_, i| { for (self.buf, 0..) |_, i| {
self.buf[i] = .{}; self.buf[i] = .{};
@ -53,7 +43,6 @@ pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !Screen {
} }
pub fn deinit(self: *Screen, alloc: std.mem.Allocator) void { pub fn deinit(self: *Screen, alloc: std.mem.Allocator) void {
alloc.free(self.buf); alloc.free(self.buf);
self.images.deinit();
} }
/// writes a cell to a location. 0 indexed /// writes a cell to a location. 0 indexed
@ -70,19 +59,3 @@ pub fn writeCell(self: *Screen, col: usize, row: usize, cell: Cell) void {
assert(i < self.buf.len); assert(i < self.buf.len);
self.buf[i] = cell; self.buf[i] = cell;
} }
pub fn writeImage(
self: *Screen,
col: usize,
row: usize,
img: Image,
placement_id: u32,
) !void {
const p = Placement{
.img = img,
.placement_id = placement_id,
.col = col,
.row = row,
};
try self.images.append(p);
}

View file

@ -69,27 +69,9 @@ pub fn writeCell(self: Window, col: usize, row: usize, cell: Cell) void {
self.screen.writeCell(col + self.x_off, row + self.y_off, cell); self.screen.writeCell(col + self.x_off, row + self.y_off, cell);
} }
/// writes an image to the location in the window
pub fn writeImage(
self: Window,
img: Image,
placement_id: u32,
) !void {
if (self.height == 0 or self.width == 0) return;
try self.screen.writeImage(self.x_off, self.y_off, img, placement_id);
}
/// fills the window with the default cell /// fills the window with the default cell
pub fn clear(self: Window) void { pub fn clear(self: Window) void {
self.fill(.{}); self.fill(.{});
// we clear any image with it's first cell within this window
for (self.screen.images.items, 0..) |p, i| {
if (p.col >= self.x_off and p.col < self.width and
p.row >= self.y_off and p.row < self.height)
{
_ = self.screen.images.swapRemove(i);
}
}
} }
/// returns the width of the grapheme. This depends on the terminal capabilities /// returns the width of the grapheme. This depends on the terminal capabilities

View file

@ -1,7 +1,10 @@
const Image = @import("Image.zig");
pub const Cell = struct { pub const Cell = struct {
char: Character = .{}, char: Character = .{},
style: Style = .{}, style: Style = .{},
link: Hyperlink = .{}, link: Hyperlink = .{},
image: ?Image.Placement = null,
}; };
pub const Character = struct { pub const Character = struct {

View file

@ -89,3 +89,8 @@ pub const osc8_clear = "\x1b]8;;\x1b\\";
pub const osc9_notify = "\x1b]9;{s}\x1b\\"; pub const osc9_notify = "\x1b]9;{s}\x1b\\";
pub const osc777_notify = "\x1b]777;notify;{s};{s}\x1b\\"; pub const osc777_notify = "\x1b]777;notify;{s};{s}\x1b\\";
pub const osc22_mouse_shape = "\x1b]22;{s}\x1b\\"; pub const osc22_mouse_shape = "\x1b]22;{s}\x1b\\";
// Kitty graphics
pub const kitty_graphics_clear = "\x1b_Ga=d\x1b\\";
pub const kitty_graphics_place = "\x1b_Ga=p,i={d},z={d},C=1\x1b\\";
pub const kitty_graphics_scale = "\x1b_Ga=p,i={d},z={d},c={d},r={d},C=1\x1b\\";

View file

@ -1,75 +0,0 @@
const std = @import("std");
const fmt = std.fmt;
const math = std.math;
const testing = std.testing;
const base64 = std.base64.standard.Encoder;
const zigimg = @import("zigimg");
const Window = @import("../Window.zig");
const Winsize = @import("../Tty.zig").Winsize;
const log = std.log.scoped(.kitty);
const Kitty = @This();
const max_chunk: usize = 4096;
const transmit_opener = "\x1b_Gf=32,i={d},s={d},v={d},m={d};";
alloc: std.mem.Allocator,
/// the decoded image
img: zigimg.Image,
/// unique identifier for this image. This will be managed by the screen. The ID
/// is only null for images which have not been transmitted to the screen
id: u32,
pub fn deinit(self: *const Kitty) void {
var img = self.img;
img.deinit();
}
/// transmit encodes and transmits the image to the terminal
pub fn transmit(self: Kitty, writer: anytype) !void {
var alloc = self.alloc;
const png_buf = try alloc.alloc(u8, self.img.imageByteSize());
defer alloc.free(png_buf);
const png = try self.img.writeToMemory(png_buf, .{ .png = .{} });
const b64_buf = try alloc.alloc(u8, base64.calcSize(png.len));
const encoded = base64.encode(b64_buf, png);
defer alloc.free(b64_buf);
log.debug("transmitting kitty image: id={d}, len={d}", .{ self.id, encoded.len });
if (encoded.len < max_chunk) {
try fmt.format(
writer,
"\x1b_Gf=100,i={d};{s}\x1b\\",
.{
self.id,
encoded,
},
);
} else {
var n: usize = max_chunk;
try fmt.format(
writer,
"\x1b_Gf=100,i={d},m=1;{s}\x1b\\",
.{ self.id, encoded[0..n] },
);
while (n < encoded.len) : (n += max_chunk) {
const end: usize = @min(n + max_chunk, encoded.len);
const m: u2 = if (end == encoded.len) 0 else 1;
try fmt.format(
writer,
"\x1b_Gm={d};{s}\x1b\\",
.{
m,
encoded[n..end],
},
);
}
}
}

View file

@ -1,101 +0,0 @@
const std = @import("std");
const math = std.math;
const testing = std.testing;
const zigimg = @import("zigimg");
const Winsize = @import("../Tty.zig").Winsize;
const Window = @import("../Window.zig");
const Kitty = @import("Kitty.zig");
const log = std.log.scoped(.image);
pub const Image = union(enum) {
kitty: Kitty,
pub const Protocol = enum {
kitty,
// TODO: sixel, full block, half block, quad block
};
pub const CellSize = struct {
rows: usize,
cols: usize,
};
pub const Source = union(enum) {
path: []const u8,
mem: []const u8,
};
/// initialize a new image
pub fn init(
alloc: std.mem.Allocator,
src: Source,
id: u32,
protocol: Protocol,
) !Image {
const img = switch (src) {
.path => |path| try zigimg.Image.fromFilePath(alloc, path),
.mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes),
};
switch (protocol) {
.kitty => {
return .{
.kitty = Kitty{
.alloc = alloc,
.img = img,
.id = id,
},
};
},
}
}
pub fn deinit(self: Image) void {
switch (self) {
inline else => |*case| case.deinit(),
}
}
pub fn draw(self: Image, win: Window) !void {
switch (self) {
.kitty => {
const row: u16 = @truncate(win.y_off);
const col: u16 = @truncate(win.x_off);
// the placement id has the high 16 bits as the column and the low 16
// bits as the row. This means we can only place this image one time at
// the same location - which is completely sane
const pid: u32 = col << 15 | row;
try win.writeImage(self, pid);
},
}
}
pub fn transmit(self: Image, writer: anytype) !void {
switch (self) {
.kitty => |k| return k.transmit(writer),
}
}
pub fn getId(self: Image) ?u32 {
switch (self) {
.kitty => |k| return k.id,
}
}
pub fn cellSize(self: Image, winsize: Winsize) !CellSize {
// cell geometry
const pix_per_col = try math.divCeil(usize, winsize.x_pixel, winsize.cols);
const pix_per_row = try math.divCeil(usize, winsize.y_pixel, winsize.rows);
const cell_width = math.divCeil(usize, self.img.width, pix_per_col) catch 0;
const cell_height = math.divCeil(usize, self.img.height, pix_per_row) catch 0;
return CellSize{
.rows = cell_height,
.cols = cell_width,
};
}
};

View file

@ -10,7 +10,7 @@ pub const Winsize = @import("Tty.zig").Winsize;
pub const widgets = @import("widgets/main.zig"); pub const widgets = @import("widgets/main.zig");
pub const Image = @import("image/image.zig").Image; pub const Image = @import("Image.zig");
/// Initialize a Vaxis application. /// Initialize a Vaxis application.
pub fn init(comptime EventType: type, opts: Options) !Vaxis(EventType) { pub fn init(comptime EventType: type, opts: Options) !Vaxis(EventType) {

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const atomic = std.atomic; const atomic = std.atomic;
const base64 = std.base64.standard.Encoder;
const Queue = @import("queue.zig").Queue; const Queue = @import("queue.zig").Queue;
const ctlseqs = @import("ctlseqs.zig"); const ctlseqs = @import("ctlseqs.zig");
@ -14,7 +15,8 @@ const Style = @import("cell.zig").Style;
const Hyperlink = @import("cell.zig").Hyperlink; const Hyperlink = @import("cell.zig").Hyperlink;
const gwidth = @import("gwidth.zig"); const gwidth = @import("gwidth.zig");
const Shape = @import("Mouse.zig").Shape; const Shape = @import("Mouse.zig").Shape;
const Placement = Screen.Placement; const Image = @import("Image.zig");
const zigimg = @import("zigimg");
/// Vaxis is the entrypoint for a Vaxis application. The provided type T should /// Vaxis is the entrypoint for a Vaxis application. The provided type T should
/// be a tagged union which contains all of the events the application will /// be a tagged union which contains all of the events the application will
@ -58,6 +60,8 @@ pub fn Vaxis(comptime T: type) type {
alt_screen: bool = false, alt_screen: bool = false,
/// if we have entered kitty keyboard /// if we have entered kitty keyboard
kitty_keyboard: bool = false, kitty_keyboard: bool = false,
// TODO: should be false but we aren't querying yet
kitty_graphics: bool = true,
bracketed_paste: bool = false, bracketed_paste: bool = false,
mouse: bool = false, mouse: bool = false,
} = .{}, } = .{},
@ -71,6 +75,9 @@ pub fn Vaxis(comptime T: type) type {
/// futex times out /// futex times out
query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), query_futex: atomic.Value(u32) = atomic.Value(u32).init(0),
// images
next_img_id: u32 = 1,
// statistics // statistics
renders: usize = 0, renders: usize = 0,
render_dur: i128 = 0, render_dur: i128 = 0,
@ -151,7 +158,7 @@ pub fn Vaxis(comptime T: type) type {
pub fn resize(self: *Self, alloc: std.mem.Allocator, winsize: Winsize) !void { pub fn resize(self: *Self, alloc: std.mem.Allocator, winsize: Winsize) !void {
log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows });
self.screen.deinit(alloc); self.screen.deinit(alloc);
self.screen = try Screen.init(alloc, winsize.cols, winsize.rows); self.screen = try Screen.init(alloc, winsize);
self.screen.unicode = self.caps.unicode; self.screen.unicode = self.caps.unicode;
// try self.screen.int(alloc, winsize.cols, winsize.rows); // try self.screen.int(alloc, winsize.cols, winsize.rows);
// we only init our current screen. This has the effect of redrawing // we only init our current screen. This has the effect of redrawing
@ -243,7 +250,6 @@ pub fn Vaxis(comptime T: type) type {
// the next render call will refresh the entire screen // the next render call will refresh the entire screen
pub fn queueRefresh(self: *Self) void { pub fn queueRefresh(self: *Self) void {
self.refresh = true; self.refresh = true;
self.screen_last.images.clearRetainingCapacity();
} }
/// draws the screen to the terminal /// draws the screen to the terminal
@ -280,26 +286,8 @@ pub fn Vaxis(comptime T: type) type {
var cursor: Style = .{}; var cursor: Style = .{};
var link: Hyperlink = .{}; var link: Hyperlink = .{};
// remove images from the screen by looping through the last state // Clear all images
// and comparing to the next state _ = try tty.write(ctlseqs.kitty_graphics_clear);
for (self.screen_last.images.items) |last_img| {
const keep: bool = for (self.screen.images.items) |next_img| {
if (last_img.eql(next_img)) break true;
} else false;
if (keep) continue;
// TODO: remove image placements
}
// add new images. Could slightly optimize by knowing which images
// we need to keep from the remove loop
for (self.screen.images.items) |img| {
const transmit: bool = for (self.screen_last.images.items) |last_img| {
if (last_img.eql(img)) break false;
} else true;
if (!transmit) continue;
// TODO: transmit the new image to the screen
try img.img.transmit(tty.buffered_writer.writer());
}
var i: usize = 0; var i: usize = 0;
while (i < self.screen.buf.len) { while (i < self.screen.buf.len) {
@ -326,7 +314,7 @@ pub fn Vaxis(comptime T: type) type {
// If cell is the same as our last frame, we don't need to do // If cell is the same as our last frame, we don't need to do
// anything // anything
const last = self.screen_last.buf[i]; const last = self.screen_last.buf[i];
if (!self.refresh and last.eql(cell) and !last.skipped) { if (!self.refresh and last.eql(cell) and !last.skipped and cell.image == null) {
reposition = true; reposition = true;
// Close any osc8 sequence we might be in before // Close any osc8 sequence we might be in before
// repositioning // repositioning
@ -348,6 +336,10 @@ pub fn Vaxis(comptime T: type) type {
try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 }); try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.cup, .{ row + 1, col + 1 });
} }
if (cell.image) |img| {
try std.fmt.format(tty.buffered_writer.writer(), ctlseqs.kitty_graphics_place, .{ img.img_id, img.z_index });
}
// something is different, so let's loop throuugh everything and // something is different, so let's loop throuugh everything and
// find out what // find out what
@ -581,6 +573,70 @@ pub fn Vaxis(comptime T: type) type {
try tty.flush(); try tty.flush();
} }
} }
pub fn loadImage(
self: *Self,
alloc: std.mem.Allocator,
src: Image.Source,
) !Image {
var tty = self.tty orelse return error.NoTTY;
defer self.next_img_id += 1;
const writer = tty.buffered_writer.writer();
var img = switch (src) {
.path => |path| try zigimg.Image.fromFilePath(alloc, path),
.mem => |bytes| try zigimg.Image.fromMemory(alloc, bytes),
};
defer img.deinit();
const png_buf = try alloc.alloc(u8, img.imageByteSize());
defer alloc.free(png_buf);
const png = try img.writeToMemory(png_buf, .{ .png = .{} });
const b64_buf = try alloc.alloc(u8, base64.calcSize(png.len));
const encoded = base64.encode(b64_buf, png);
defer alloc.free(b64_buf);
const id = self.next_img_id;
log.debug("transmitting kitty image: id={d}, len={d}", .{ id, encoded.len });
if (encoded.len < 4096) {
try std.fmt.format(
writer,
"\x1b_Gf=100,i={d};{s}\x1b\\",
.{
id,
encoded,
},
);
} else {
var n: usize = 4096;
try std.fmt.format(
writer,
"\x1b_Gf=100,i={d},m=1;{s}\x1b\\",
.{ id, encoded[0..n] },
);
while (n < encoded.len) : (n += 4096) {
const end: usize = @min(n + 4096, encoded.len);
const m: u2 = if (end == encoded.len) 0 else 1;
try std.fmt.format(
writer,
"\x1b_Gm={d};{s}\x1b\\",
.{
m,
encoded[n..end],
},
);
}
}
try tty.buffered_writer.flush();
return Image{
.id = id,
.width = img.width,
.height = img.height,
};
}
}; };
} }