From f901dde2a0878b9797f67ef6a7548a1b7e1d16d2 Mon Sep 17 00:00:00 2001 From: Tim Culverhouse Date: Tue, 30 Jan 2024 16:26:35 -0600 Subject: [PATCH] images: kitty image protocol works Signed-off-by: Tim Culverhouse --- examples/image.zig | 10 ++-- src/Image.zig | 61 ++++++++++++++++++++++++ src/InternalScreen.zig | 6 --- src/Screen.zig | 47 ++++--------------- src/Window.zig | 18 ------- src/cell.zig | 3 ++ src/ctlseqs.zig | 5 ++ src/image/Kitty.zig | 75 ----------------------------- src/image/image.zig | 101 --------------------------------------- src/main.zig | 2 +- src/vaxis.zig | 104 +++++++++++++++++++++++++++++++---------- 11 files changed, 167 insertions(+), 265 deletions(-) create mode 100644 src/Image.zig delete mode 100644 src/image/Kitty.zig delete mode 100644 src/image/image.zig diff --git a/examples/image.zig b/examples/image.zig index 501e02d..0d32e8e 100644 --- a/examples/image.zig +++ b/examples/image.zig @@ -43,8 +43,9 @@ pub fn main() !void { // _always_ be called, but is left to the application to decide when try vx.queryTerminal(); - var img = try vaxis.Image.init(alloc, .{ .path = "vaxis.png" }, 1, .kitty); - defer img.deinit(); + const img = try vx.loadImage(alloc, .{ .path = "vaxis.png" }); + + var n: usize = 0; // The main event loop. Vaxis provides a thread safe, blocking, buffered // 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") switch (event) { .key_press => |key| { + n += 1; if (key.matches('c', .{ .ctrl = true })) { break :outer; } else if (key.matches('l', .{ .ctrl = true })) { @@ -79,7 +81,9 @@ pub fn main() !void { // the old and only updated cells will be drawn win.clear(); - try img.draw(win); + const child = win.initChild(n, n, .expand, .expand); + + img.draw(child, false, 0); // Render the screen try vx.render(); diff --git a/src/Image.zig b/src/Image.zig new file mode 100644 index 0000000..04d6204 --- /dev/null +++ b/src/Image.zig @@ -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, + }; +} diff --git a/src/InternalScreen.zig b/src/InternalScreen.zig index 6566f56..b5948ee 100644 --- a/src/InternalScreen.zig +++ b/src/InternalScreen.zig @@ -3,8 +3,6 @@ const assert = std.debug.assert; const Style = @import("cell.zig").Style; const Cell = @import("cell.zig").Cell; 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); @@ -37,13 +35,10 @@ cursor_vis: bool = false, mouse_shape: Shape = .default, -images: std.ArrayList(Placement) = undefined, - /// sets each cell to the default cell pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !InternalScreen { var screen = InternalScreen{ .buf = try alloc.alloc(InternalCell, w * h), - .images = std.ArrayList(Placement).init(alloc), }; for (screen.buf, 0..) |_, 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 { - self.images.deinit(); for (self.buf, 0..) |_, i| { self.buf[i].char.deinit(); self.buf[i].uri.deinit(); diff --git a/src/Screen.zig b/src/Screen.zig index abb7c62..1a10eeb 100644 --- a/src/Screen.zig +++ b/src/Screen.zig @@ -3,30 +3,19 @@ const assert = std.debug.assert; const Cell = @import("cell.zig").Cell; 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 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, height: usize = 0, +width_pix: usize = 0, +height_pix: usize = 0, + buf: []Cell = undefined, cursor_row: usize = 0, @@ -37,14 +26,15 @@ unicode: bool = false, mouse_shape: Shape = .default, -images: std.ArrayList(Placement) = undefined, - -pub fn init(alloc: std.mem.Allocator, w: usize, h: usize) !Screen { +pub fn init(alloc: std.mem.Allocator, winsize: Winsize) !Screen { + const w = winsize.cols; + const h = winsize.rows; var self = Screen{ .buf = try alloc.alloc(Cell, w * h), .width = w, .height = h, - .images = std.ArrayList(Placement).init(alloc), + .width_pix = winsize.x_pixel, + .height_pix = winsize.y_pixel, }; for (self.buf, 0..) |_, 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 { alloc.free(self.buf); - self.images.deinit(); } /// 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); 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); -} diff --git a/src/Window.zig b/src/Window.zig index e15de67..e64950b 100644 --- a/src/Window.zig +++ b/src/Window.zig @@ -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); } -/// 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 pub fn clear(self: Window) void { 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 diff --git a/src/cell.zig b/src/cell.zig index 561e0cf..dd662b6 100644 --- a/src/cell.zig +++ b/src/cell.zig @@ -1,7 +1,10 @@ +const Image = @import("Image.zig"); + pub const Cell = struct { char: Character = .{}, style: Style = .{}, link: Hyperlink = .{}, + image: ?Image.Placement = null, }; pub const Character = struct { diff --git a/src/ctlseqs.zig b/src/ctlseqs.zig index 59a2c9c..02d2949 100644 --- a/src/ctlseqs.zig +++ b/src/ctlseqs.zig @@ -89,3 +89,8 @@ pub const osc8_clear = "\x1b]8;;\x1b\\"; pub const osc9_notify = "\x1b]9;{s}\x1b\\"; pub const osc777_notify = "\x1b]777;notify;{s};{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\\"; diff --git a/src/image/Kitty.zig b/src/image/Kitty.zig deleted file mode 100644 index cce4827..0000000 --- a/src/image/Kitty.zig +++ /dev/null @@ -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], - }, - ); - } - } -} diff --git a/src/image/image.zig b/src/image/image.zig deleted file mode 100644 index c686ad1..0000000 --- a/src/image/image.zig +++ /dev/null @@ -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, - }; - } -}; diff --git a/src/main.zig b/src/main.zig index a173708..e0c30df 100644 --- a/src/main.zig +++ b/src/main.zig @@ -10,7 +10,7 @@ pub const Winsize = @import("Tty.zig").Winsize; 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. pub fn init(comptime EventType: type, opts: Options) !Vaxis(EventType) { diff --git a/src/vaxis.zig b/src/vaxis.zig index 1af2aeb..39f12d2 100644 --- a/src/vaxis.zig +++ b/src/vaxis.zig @@ -1,5 +1,6 @@ const std = @import("std"); const atomic = std.atomic; +const base64 = std.base64.standard.Encoder; const Queue = @import("queue.zig").Queue; const ctlseqs = @import("ctlseqs.zig"); @@ -14,7 +15,8 @@ const Style = @import("cell.zig").Style; const Hyperlink = @import("cell.zig").Hyperlink; const gwidth = @import("gwidth.zig"); 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 /// 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, /// if we have entered kitty keyboard kitty_keyboard: bool = false, + // TODO: should be false but we aren't querying yet + kitty_graphics: bool = true, bracketed_paste: bool = false, mouse: bool = false, } = .{}, @@ -71,6 +75,9 @@ pub fn Vaxis(comptime T: type) type { /// futex times out query_futex: atomic.Value(u32) = atomic.Value(u32).init(0), + // images + next_img_id: u32 = 1, + // statistics renders: usize = 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 { log.debug("resizing screen: width={d} height={d}", .{ winsize.cols, winsize.rows }); 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; // try self.screen.int(alloc, winsize.cols, winsize.rows); // 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 pub fn queueRefresh(self: *Self) void { self.refresh = true; - self.screen_last.images.clearRetainingCapacity(); } /// draws the screen to the terminal @@ -280,26 +286,8 @@ pub fn Vaxis(comptime T: type) type { var cursor: Style = .{}; var link: Hyperlink = .{}; - // remove images from the screen by looping through the last state - // and comparing to the next state - 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()); - } + // Clear all images + _ = try tty.write(ctlseqs.kitty_graphics_clear); var i: usize = 0; 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 // anything 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; // Close any osc8 sequence we might be in before // 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 }); } + 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 // find out what @@ -581,6 +573,70 @@ pub fn Vaxis(comptime T: type) type { 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, + }; + } }; }