diff --git a/README.md b/README.md index 007ba52b..f1f49404 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,29 @@ Unix-likes. - Images ([kitty graphics protocol](https://sw.kovidgoyal.net/kitty/graphics-protocol/)) - [Explicit Width](https://github.com/kovidgoyal/kitty/blob/master/docs/text-sizing-protocol.rst) (width modifiers only) +### tmux + kitty graphics + +When running inside `tmux`, kitty graphics needs passthrough enabled: + +```tmux +set -g allow-passthrough on +``` + +`libvaxis` can auto-detect `tmux` and wrap kitty graphics commands in tmux +passthrough mode. It also uses kitty Unicode placeholders for image rendering +in tmux. + +The behavior is configured with `Vaxis.Options.kitty_graphics_tmux_mode`: + +- `.auto` (default): enable tmux wrapping when `$TMUX` is present +- `.off`: disable tmux wrapping +- `.on`: force tmux wrapping + +In placeholder mode, `pixel_offset` placement is not supported and falls back +to direct placement. +If placeholder row/column indexes exceed the combining-mark table, `libvaxis` +uses a deterministic fallback combining mark. + ## Usage [Documentation](https://rockorager.github.io/libvaxis/#vaxis.Vaxis) @@ -295,7 +318,9 @@ pub fn main() !void { defer tty.deinit(); // Initialize Vaxis - var vx = try vaxis.init(alloc, .{}); + var vx = try vaxis.init(alloc, .{ + .kitty_graphics_tmux_mode = .auto, + }); // deinit takes an optional allocator. If your program is exiting, you can // choose to pass a null allocator to save some exit time. defer vx.deinit(alloc, tty.writer()); diff --git a/USAGE.md b/USAGE.md index 221bd202..f3403088 100644 --- a/USAGE.md +++ b/USAGE.md @@ -16,6 +16,29 @@ lets Vaxis know what features it can use. For example, the Kitty Keyboard protocol, in-band-resize reports, and Unicode width measurements are just a few examples. +## tmux + kitty graphics + +If your application uses kitty graphics inside `tmux`, enable passthrough in +tmux configuration: + +```tmux +set -g allow-passthrough on +``` + +`Vaxis.Options` includes `kitty_graphics_tmux_mode`: + +- `.auto` (default): detect tmux via `$TMUX` +- `.off`: disable tmux wrapping +- `.on`: force tmux wrapping + +Example: + +```zig +var vx = try vaxis.init(alloc, .{ + .kitty_graphics_tmux_mode = .auto, +}); +``` + ### `libxev` Below is an example [`libxev`](https://github.com/mitchellh/libxev) event loop. diff --git a/src/Image.zig b/src/Image.zig index 233f32c2..323af7ff 100644 --- a/src/Image.zig +++ b/src/Image.zig @@ -30,6 +30,8 @@ pub const TransmitMedium = enum { pub const Placement = struct { img_id: u32, options: Image.DrawOptions, + src_width_px: u16 = 0, + src_height_px: u16 = 0, }; pub const CellSize = struct { @@ -164,6 +166,8 @@ pub fn draw(self: Image, win: Window, opts: DrawOptions) !void { const p = Placement{ .img_id = self.id, .options = p_opts, + .src_width_px = self.width, + .src_height_px = self.height, }; win.writeCell(0, 0, .{ .image = p }); } diff --git a/src/Parser.zig b/src/Parser.zig index 6358269a..92331379 100644 --- a/src/Parser.zig +++ b/src/Parser.zig @@ -57,7 +57,7 @@ pub fn parse(self: *Parser, input: []const u8, paste_allocator: ?std.mem.Allocat if (input[0] == 0x1b and input.len > 1) { switch (input[1]) { 0x4F => return parseSs3(input), - 0x50 => return skipUntilST(input), // DCS + 0x50 => return parseDcs(input), // DCS 0x58 => return skipUntilST(input), // SOS 0x5B => return parseCsi(input, &self.buf), // CSI 0x5D => return parseOsc(input, paste_allocator), @@ -188,11 +188,9 @@ inline fn parseApc(input: []const u8) Result { .n = 0, }; } - const end = std.mem.indexOfScalarPos(u8, input, 2, 0x1b) orelse return .{ - .event = null, - .n = 0, - }; - const sequence = input[0 .. end + 1 + 1]; + const st = skipUntilST(input); + if (st.n == 0) return st; + const sequence = input[0..st.n]; switch (input[2]) { 'G' => return .{ @@ -206,6 +204,31 @@ inline fn parseApc(input: []const u8) Result { } } +inline fn parseDcs(input: []const u8) Result { + const st = skipUntilST(input); + if (st.n == 0) return st; + + const sequence = input[0..st.n]; + // tmux passthrough wraps escaped sequences as: + // ESC Ptmux; ESC ESC ESC ESC \ ESC \ + if (sequence.len >= 11 and std.mem.startsWith(u8, sequence, "\x1bPtmux;")) { + // We only need to detect wrapped kitty graphics capability replies here. + // Inner APC can appear as ESC ESC _ G (escaped) or ESC _ G. + if (std.mem.indexOf(u8, sequence, "\x1b\x1b_G") != null or + std.mem.indexOf(u8, sequence, "\x1b_G") != null) + { + return .{ + .event = .cap_kitty_graphics, + .n = sequence.len, + }; + } + } + return .{ + .event = null, + .n = sequence.len, + }; +} + /// Skips sequences until we see an ST (String Terminator, ESC \) inline fn skipUntilST(input: []const u8) Result { if (input.len < 3) { @@ -214,20 +237,24 @@ inline fn skipUntilST(input: []const u8) Result { .n = 0, }; } - const end = std.mem.indexOfScalarPos(u8, input, 2, 0x1b) orelse return .{ - .event = null, - .n = 0, - }; - if (input.len < end + 1 + 1) { - return .{ - .event = null, - .n = 0, - }; + var i: usize = 2; + while (i + 1 < input.len) : (i += 1) { + if (input[i] != 0x1b) continue; + // tmux passthrough escapes embedded ESC bytes by doubling them. + if (input[i + 1] == 0x1b) { + i += 1; + continue; + } + if (input[i + 1] == '\\') { + return .{ + .event = null, + .n = i + 2, + }; + } } - const sequence = input[0 .. end + 1 + 1]; return .{ .event = null, - .n = sequence.len, + .n = 0, }; } @@ -906,6 +933,26 @@ test "parse: osc52 paste" { } } +test "parse: dcs with escaped esc bytes" { + const alloc = testing.allocator_instance.allocator(); + const input = "\x1bPabc\x1b\x1bdef\x1b\\x"; + var parser: Parser = .{}; + const result = try parser.parse(input, alloc); + + try testing.expectEqual(input.len - 1, result.n); + try testing.expectEqual(@as(?Event, null), result.event); +} + +test "parse: tmux wrapped kitty graphics response" { + const alloc = testing.allocator_instance.allocator(); + const input = "\x1bPtmux;\x1b\x1b_Gi=1;OK\x1b\x1b\\\x1b\\"; + var parser: Parser = .{}; + const result = try parser.parse(input, alloc); + + try testing.expectEqual(input.len, result.n); + try testing.expectEqual(@as(?Event, .cap_kitty_graphics), result.event); +} + test "parse: focus_in" { const alloc = testing.allocator_instance.allocator(); const input = "\x1b[I"; diff --git a/src/Vaxis.zig b/src/Vaxis.zig index 956d6d56..7d4ea3f7 100644 --- a/src/Vaxis.zig +++ b/src/Vaxis.zig @@ -13,6 +13,7 @@ const Mouse = @import("Mouse.zig"); const Screen = @import("Screen.zig"); const unicode = @import("unicode.zig"); const Window = @import("Window.zig"); +const kitty_placeholders = @import("kitty_placeholders.zig"); const Hyperlink = Cell.Hyperlink; const KittyFlags = Key.KittyFlags; @@ -41,8 +42,16 @@ pub const Capabilities = struct { multi_cursor: bool = false, }; +pub const KittyGraphicsTmuxMode = enum { + auto, + off, + on, +}; + pub const Options = struct { kitty_keyboard_flags: KittyFlags = .{}, + /// Configure tmux wrapping for kitty graphics commands. + kitty_graphics_tmux_mode: KittyGraphicsTmuxMode = .auto, /// When supplied, this allocator will be used for system clipboard /// requests. If not supplied, it won't be possible to request the system /// clipboard @@ -73,6 +82,7 @@ queries_done: atomic.Value(bool) = atomic.Value(bool).init(true), // images next_img_id: u32 = 1, +kitty_graphics_tmux_active: bool = false, sgr: enum { standard, @@ -241,6 +251,223 @@ pub fn exitAltScreen(self: *Vaxis, tty: *IoWriter) !void { self.state.alt_screen = false; } +fn kgpCommandBegin(self: *const Vaxis, tty: *IoWriter) !void { + if (self.kitty_graphics_tmux_active) { + try tty.writeAll("\x1bPtmux;\x1b\x1b_G"); + return; + } + try tty.writeAll("\x1b_G"); +} + +fn resolveKittyGraphicsTmuxActive(self: *const Vaxis) bool { + if (builtin.os.tag == .windows) return false; + return switch (self.opts.kitty_graphics_tmux_mode) { + .off => false, + .on => true, + .auto => std.posix.getenv("TMUX") != null, + }; +} + +fn kittyGraphicsCommandPathEnabled(self: *const Vaxis) bool { + return self.caps.kitty_graphics or self.kitty_graphics_tmux_active; +} + +fn kgpWriteEscapedPayload(self: *const Vaxis, tty: *IoWriter, payload: []const u8) !void { + if (!self.kitty_graphics_tmux_active) { + try tty.writeAll(payload); + return; + } + + for (payload) |byte| { + if (byte == 0x1B) + try tty.writeAll("\x1b\x1b") + else + try tty.writeByte(byte); + } +} + +fn kgpCommandEnd(self: *const Vaxis, tty: *IoWriter) !void { + if (self.kitty_graphics_tmux_active) { + // End wrapped command (escaped ST), then end the tmux passthrough DCS. + try tty.writeAll("\x1b\x1b\\\x1b\\"); + return; + } + try tty.writeAll("\x1b\\"); +} + +fn kgpWriteCommand(self: *const Vaxis, tty: *IoWriter, payload: []const u8) !void { + try self.kgpCommandBegin(tty); + try self.kgpWriteEscapedPayload(tty, payload); + try self.kgpCommandEnd(tty); +} + +const PlaceholderCellArea = struct { + rows: u16, + cols: u16, +}; + +fn placeholderCellGeometry(self: *const Vaxis) ?struct { w: u32, h: u32 } { + if (self.screen.width == 0 or self.screen.height == 0) return null; + if (self.screen.width_pix == 0 or self.screen.height_pix == 0) return null; + + const cell_w = std.math.divCeil(u32, self.screen.width_pix, self.screen.width) catch return null; + const cell_h = std.math.divCeil(u32, self.screen.height_pix, self.screen.height) catch return null; + if (cell_w == 0 or cell_h == 0) return null; + return .{ .w = cell_w, .h = cell_h }; +} + +fn inferColsFromRows( + self: *const Vaxis, + rows: u16, + src_width_px: u16, + src_height_px: u16, +) ?u16 { + const geom = self.placeholderCellGeometry() orelse return null; + if (src_width_px == 0 or src_height_px == 0) return null; + + const target_height_px = @as(u64, @intCast(@max(1, rows))) * geom.h; + const scaled_width_px = std.math.divCeil( + u64, + target_height_px * src_width_px, + src_height_px, + ) catch return null; + const cols = std.math.divCeil(u64, scaled_width_px, geom.w) catch return null; + return @intCast(@min(cols, std.math.maxInt(u16))); +} + +fn inferRowsFromCols( + self: *const Vaxis, + cols: u16, + src_width_px: u16, + src_height_px: u16, +) ?u16 { + const geom = self.placeholderCellGeometry() orelse return null; + if (src_width_px == 0 or src_height_px == 0) return null; + + const target_width_px = @as(u64, @intCast(@max(1, cols))) * geom.w; + const scaled_height_px = std.math.divCeil( + u64, + target_width_px * src_height_px, + src_width_px, + ) catch return null; + const rows = std.math.divCeil(u64, scaled_height_px, geom.h) catch return null; + return @intCast(@min(rows, std.math.maxInt(u16))); +} + +fn naturalImageCellArea(self: *const Vaxis, img: Image.Placement) ?PlaceholderCellArea { + const geom = self.placeholderCellGeometry() orelse return null; + if (img.src_width_px == 0 or img.src_height_px == 0) return null; + + const cols = std.math.divCeil(u32, img.src_width_px, geom.w) catch return null; + const rows = std.math.divCeil(u32, img.src_height_px, geom.h) catch return null; + return .{ + .rows = @max(1, @as(u16, @intCast(@min(rows, std.math.maxInt(u16))))), + .cols = @max(1, @as(u16, @intCast(@min(cols, std.math.maxInt(u16))))), + }; +} + +fn resolvePlaceholderCellArea(self: *const Vaxis, img: Image.Placement) ?PlaceholderCellArea { + if (img.options.size) |size| { + if (size.rows) |rows| { + if (size.cols) |cols| { + return .{ + .rows = @max(1, rows), + .cols = @max(1, cols), + }; + } + const inferred_cols = inferColsFromRows(self, rows, img.src_width_px, img.src_height_px) orelse return null; + return .{ + .rows = @max(1, rows), + .cols = @max(1, inferred_cols), + }; + } + if (size.cols) |cols| { + const inferred_rows = inferRowsFromCols(self, cols, img.src_width_px, img.src_height_px) orelse return null; + return .{ + .rows = @max(1, inferred_rows), + .cols = @max(1, cols), + }; + } + } + return self.naturalImageCellArea(img); +} + +fn canUsePlaceholderModeForImage(self: *const Vaxis, img: Image.Placement) bool { + if (!self.kitty_graphics_tmux_active or !self.caps.kitty_graphics) return false; + if (img.options.pixel_offset != null) return false; + return self.resolvePlaceholderCellArea(img) != null; +} + +fn writeKittyPlacement( + self: *const Vaxis, + tty: *IoWriter, + img: Image.Placement, + placeholder_mode: bool, +) !void { + try self.kgpCommandBegin(tty); + try tty.print("a=p,i={d}", .{img.img_id}); + + if (placeholder_mode) { + try tty.writeAll(",U=1"); + } else { + if (img.options.pixel_offset) |offset| { + try tty.print(",X={d},Y={d}", .{ offset.x, offset.y }); + } + if (img.options.size) |size| { + if (size.rows) |rows| + try tty.print(",r={d}", .{rows}); + if (size.cols) |cols| + try tty.print(",c={d}", .{cols}); + } + } + + if (img.options.clip_region) |clip| { + if (clip.x) |x| + try tty.print(",x={d}", .{x}); + if (clip.y) |y| + try tty.print(",y={d}", .{y}); + if (clip.width) |width| + try tty.print(",w={d}", .{width}); + if (clip.height) |height| + try tty.print(",h={d}", .{height}); + } + if (img.options.z_index) |z| { + try tty.print(",z={d}", .{z}); + } + try tty.writeAll(",C=1"); + try self.kgpCommandEnd(tty); +} + +fn renderTmuxPlaceholders(self: *const Vaxis, tty: *IoWriter) !void { + if (!self.kitty_graphics_tmux_active or !self.caps.kitty_graphics) return; + + var row: u16 = 0; + while (row < self.screen.height) : (row += 1) { + var col: u16 = 0; + while (col < self.screen.width) : (col += 1) { + const i = (@as(usize, @intCast(row)) * self.screen.width) + col; + const cell = self.screen.buf[i]; + const img = cell.image orelse continue; + if (!self.canUsePlaceholderModeForImage(img)) continue; + + const area = self.resolvePlaceholderCellArea(img) orelse continue; + const r = (img.img_id >> 16) & 0xFF; + const g = (img.img_id >> 8) & 0xFF; + const b = img.img_id & 0xFF; + try tty.print(ctlseqs.fg_rgb_legacy, .{ r, g, b }); + + var dy: u16 = 0; + while (dy < area.rows and row + dy < self.screen.height) : (dy += 1) { + try tty.print(ctlseqs.cup, .{ row + dy + 1, col + 1 }); + var dx: u16 = 0; + while (dx < area.cols and col + dx < self.screen.width) : (dx += 1) { + try kitty_placeholders.writeGrapheme(tty, dy, dx); + } + } + } + } +} + /// write queries to the terminal to determine capabilities. Individual /// capabilities will be delivered to the client and possibly intercepted by /// Vaxis to enable features. @@ -260,6 +487,7 @@ pub fn queryTerminal(self: *Vaxis, tty: *IoWriter, timeout_ns: u64) !void { /// you are using Loop.run() pub fn queryTerminalSend(vx: *Vaxis, tty: *IoWriter) !void { vx.queries_done.store(false, .unordered); + vx.kitty_graphics_tmux_active = vx.resolveKittyGraphicsTmuxActive(); // TODO: re-enable this // const colorterm = std.posix.getenv("COLORTERM") orelse ""; @@ -299,9 +527,9 @@ pub fn queryTerminalSend(vx: *Vaxis, tty: *IoWriter) !void { ctlseqs.multi_cursor_query ++ ctlseqs.cursor_position_request ++ ctlseqs.xtversion ++ - ctlseqs.csi_u_query ++ - ctlseqs.kitty_graphics_query ++ - ctlseqs.primary_device_attrs); + ctlseqs.csi_u_query); + try vx.kgpWriteCommand(tty, "i=1,a=q"); + try tty.writeAll(ctlseqs.primary_device_attrs); try tty.flush(); } @@ -314,6 +542,7 @@ pub fn enableDetectedFeatures(self: *Vaxis, tty: *IoWriter) !void { .windows => { // No feature detection on windows. We just hard enable some knowns for ConPTY self.sgr = .legacy; + self.kitty_graphics_tmux_active = false; }, else => { // Apply any environment variables @@ -334,6 +563,7 @@ pub fn enableDetectedFeatures(self: *Vaxis, tty: *IoWriter) !void { self.caps.unicode = .wcwidth; if (std.posix.getenv("VAXIS_FORCE_UNICODE")) |_| self.caps.unicode = .unicode; + self.kitty_graphics_tmux_active = self.resolveKittyGraphicsTmuxActive(); // enable detected features if (self.caps.kitty_keyboard) { @@ -413,7 +643,7 @@ pub fn render(self: *Vaxis, tty: *IoWriter) !void { reposition_ptr.* = true; // Clear all images if (vx.caps.kitty_graphics) - try io.writeAll(ctlseqs.kitty_graphics_clear); + try vx.kgpWriteCommand(io, "a=d"); } }; @@ -524,36 +754,8 @@ pub fn render(self: *Vaxis, tty: *IoWriter) !void { } if (cell.image) |img| { - try tty.print( - ctlseqs.kitty_graphics_preamble, - .{img.img_id}, - ); - if (img.options.pixel_offset) |offset| { - try tty.print( - ",X={d},Y={d}", - .{ offset.x, offset.y }, - ); - } - if (img.options.clip_region) |clip| { - if (clip.x) |x| - try tty.print(",x={d}", .{x}); - if (clip.y) |y| - try tty.print(",y={d}", .{y}); - if (clip.width) |width| - try tty.print(",w={d}", .{width}); - if (clip.height) |height| - try tty.print(",h={d}", .{height}); - } - if (img.options.size) |size| { - if (size.rows) |rows| - try tty.print(",r={d}", .{rows}); - if (size.cols) |cols| - try tty.print(",c={d}", .{cols}); - } - if (img.options.z_index) |z| { - try tty.print(",z={d}", .{z}); - } - try tty.writeAll(ctlseqs.kitty_graphics_closing); + const placeholder_mode = self.canUsePlaceholderModeForImage(img); + try self.writeKittyPlacement(tty, img, placeholder_mode); } // something is different, so let's loop through everything and @@ -757,6 +959,16 @@ pub fn render(self: *Vaxis, tty: *IoWriter) !void { cursor_pos.col = col + w; cursor_pos.row = row; } + if (started and self.kitty_graphics_tmux_active and self.caps.kitty_graphics) { + try self.renderTmuxPlaceholders(tty); + try tty.print( + ctlseqs.cup, + .{ + cursor_pos.row + 1, + cursor_pos.col + 1, + }, + ); + } if (!started) return; if (self.screen.cursor_vis) { if (self.state.alt_screen) { @@ -902,7 +1114,7 @@ pub fn transmitLocalImagePath( medium: Image.TransmitMedium, format: Image.TransmitFormat, ) !Image { - if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; + if (!self.kittyGraphicsCommandPathEnabled()) return error.NoGraphicsCapability; defer self.next_img_id += 1; @@ -923,22 +1135,22 @@ pub fn transmitLocalImagePath( switch (format) { .rgb => { - try tty.print( - "\x1b_Gf=24,s={d},v={d},i={d},t={c};{s}\x1b\\", - .{ width, height, id, medium_char, encoded }, - ); + try self.kgpCommandBegin(tty); + try tty.print("f=24,s={d},v={d},i={d},t={c};", .{ width, height, id, medium_char }); + try tty.writeAll(encoded); + try self.kgpCommandEnd(tty); }, .rgba => { - try tty.print( - "\x1b_Gf=32,s={d},v={d},i={d},t={c};{s}\x1b\\", - .{ width, height, id, medium_char, encoded }, - ); + try self.kgpCommandBegin(tty); + try tty.print("f=32,s={d},v={d},i={d},t={c};", .{ width, height, id, medium_char }); + try tty.writeAll(encoded); + try self.kgpCommandEnd(tty); }, .png => { - try tty.print( - "\x1b_Gf=100,i={d},t={c};{s}\x1b\\", - .{ id, medium_char, encoded }, - ); + try self.kgpCommandBegin(tty); + try tty.print("f=100,i={d},t={c};", .{ id, medium_char }); + try tty.writeAll(encoded); + try self.kgpCommandEnd(tty); }, } @@ -959,7 +1171,7 @@ pub fn transmitPreEncodedImage( height: u16, format: Image.TransmitFormat, ) !Image { - if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; + if (!self.kittyGraphicsCommandPathEnabled()) return error.NoGraphicsCapability; defer self.next_img_id += 1; const id = self.next_img_id; @@ -971,33 +1183,30 @@ pub fn transmitPreEncodedImage( }; if (bytes.len < 4096) { + try self.kgpCommandBegin(tty); try tty.print( - "\x1b_Gf={d},s={d},v={d},i={d};{s}\x1b\\", - .{ - fmt, - width, - height, - id, - bytes, - }, + "f={d},s={d},v={d},i={d};", + .{ fmt, width, height, id }, ); + try tty.writeAll(bytes); + try self.kgpCommandEnd(tty); } else { var n: usize = 4096; + try self.kgpCommandBegin(tty); try tty.print( - "\x1b_Gf={d},s={d},v={d},i={d},m=1;{s}\x1b\\", - .{ fmt, width, height, id, bytes[0..n] }, + "f={d},s={d},v={d},i={d},m=1;", + .{ fmt, width, height, id }, ); + try tty.writeAll(bytes[0..n]); + try self.kgpCommandEnd(tty); while (n < bytes.len) : (n += 4096) { const end: usize = @min(n + 4096, bytes.len); const m: u2 = if (end == bytes.len) 0 else 1; - try tty.print( - "\x1b_Gm={d};{s}\x1b\\", - .{ - m, - bytes[n..end], - }, - ); + try self.kgpCommandBegin(tty); + try tty.print("m={d};", .{m}); + try tty.writeAll(bytes[n..end]); + try self.kgpCommandEnd(tty); } } @@ -1016,7 +1225,7 @@ pub fn transmitImage( img: *const zigimg.Image, format: Image.TransmitFormat, ) !Image { - if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; + if (!self.kittyGraphicsCommandPathEnabled()) return error.NoGraphicsCapability; var arena = std.heap.ArenaAllocator.init(alloc); defer arena.deinit(); @@ -1051,7 +1260,7 @@ pub fn loadImage( tty: *IoWriter, src: Image.Source, ) !Image { - if (!self.caps.kitty_graphics) return error.NoGraphicsCapability; + if (!self.kittyGraphicsCommandPathEnabled()) return error.NoGraphicsCapability; var read_buffer: [1024 * 1024]u8 = undefined; // 1MB buffer var img = switch (src) { @@ -1063,11 +1272,19 @@ pub fn loadImage( } /// deletes an image from the terminal's memory -pub fn freeImage(_: Vaxis, tty: *IoWriter, id: u32) void { - tty.print("\x1b_Ga=d,d=I,i={d};\x1b\\", .{id}) catch |err| { +pub fn freeImage(self: *const Vaxis, tty: *IoWriter, id: u32) void { + self.kgpCommandBegin(tty) catch |err| { + log.err("couldn't start delete image {d} command: {}", .{ id, err }); + return; + }; + tty.print("a=d,d=I,i={d};", .{id}) catch |err| { log.err("couldn't delete image {d}: {}", .{ id, err }); return; }; + self.kgpCommandEnd(tty) catch |err| { + log.err("couldn't end delete image {d} command: {}", .{ id, err }); + return; + }; tty.flush() catch {}; } @@ -1225,36 +1442,7 @@ pub fn prettyPrint(self: *Vaxis, tty: *IoWriter) !void { } if (cell.image) |img| { - try tty.print( - ctlseqs.kitty_graphics_preamble, - .{img.img_id}, - ); - if (img.options.pixel_offset) |offset| { - try tty.print( - ",X={d},Y={d}", - .{ offset.x, offset.y }, - ); - } - if (img.options.clip_region) |clip| { - if (clip.x) |x| - try tty.print(",x={d}", .{x}); - if (clip.y) |y| - try tty.print(",y={d}", .{y}); - if (clip.width) |width| - try tty.print(",w={d}", .{width}); - if (clip.height) |height| - try tty.print(",h={d}", .{height}); - } - if (img.options.size) |size| { - if (size.rows) |rows| - try tty.print(",r={d}", .{rows}); - if (size.cols) |cols| - try tty.print(",c={d}", .{cols}); - } - if (img.options.z_index) |z| { - try tty.print(",z={d}", .{z}); - } - try tty.writeAll(ctlseqs.kitty_graphics_closing); + try self.writeKittyPlacement(tty, img, false); } // something is different, so let's loop through everything and @@ -1454,3 +1642,138 @@ test "render: no output when no changes" { defer std.testing.allocator.free(output); try std.testing.expectEqual(@as(usize, 0), output.len); } + +test "kgp wrapper: tmux mode wraps and escapes" { + var vx = try Vaxis.init(std.testing.allocator, .{}); + var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer deinit_writer.deinit(); + defer vx.deinit(std.testing.allocator, &deinit_writer.writer); + + vx.kitty_graphics_tmux_active = true; + + var writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer writer.deinit(); + try vx.kgpWriteCommand(&writer.writer, "a=p,i=1,C=1"); + const output = try writer.toOwnedSlice(); + defer std.testing.allocator.free(output); + + const expected = "\x1bPtmux;\x1b\x1b_Ga=p,i=1,C=1\x1b\x1b\\\x1b\\"; + try std.testing.expectEqualStrings(expected, output); +} + +test "kgp wrapper: non tmux mode is raw kgp" { + var vx = try Vaxis.init(std.testing.allocator, .{}); + var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer deinit_writer.deinit(); + defer vx.deinit(std.testing.allocator, &deinit_writer.writer); + + vx.kitty_graphics_tmux_active = false; + + var writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer writer.deinit(); + try vx.kgpWriteCommand(&writer.writer, "a=p,i=1,C=1"); + const output = try writer.toOwnedSlice(); + defer std.testing.allocator.free(output); + + const expected = "\x1b_Ga=p,i=1,C=1\x1b\\"; + try std.testing.expectEqualStrings(expected, output); +} + +test "resolve placeholder area" { + var vx = try Vaxis.init(std.testing.allocator, .{}); + var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer deinit_writer.deinit(); + defer vx.deinit(std.testing.allocator, &deinit_writer.writer); + + vx.screen.width = 80; + vx.screen.height = 24; + vx.screen.width_pix = 800; + vx.screen.height_pix = 480; + + const with_both: Image.Placement = .{ + .img_id = 1, + .options = .{ .size = .{ .rows = 3, .cols = 4 } }, + .src_width_px = 400, + .src_height_px = 200, + }; + const both = vx.resolvePlaceholderCellArea(with_both) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u16, 3), both.rows); + try std.testing.expectEqual(@as(u16, 4), both.cols); + + const with_rows_only: Image.Placement = .{ + .img_id = 1, + .options = .{ .size = .{ .rows = 5 } }, + .src_width_px = 400, + .src_height_px = 200, + }; + const rows_only = vx.resolvePlaceholderCellArea(with_rows_only) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u16, 5), rows_only.rows); + try std.testing.expectEqual(@as(u16, 20), rows_only.cols); + + const with_cols_only: Image.Placement = .{ + .img_id = 1, + .options = .{ .size = .{ .cols = 10 } }, + .src_width_px = 400, + .src_height_px = 200, + }; + const cols_only = vx.resolvePlaceholderCellArea(with_cols_only) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u16, 3), cols_only.rows); + try std.testing.expectEqual(@as(u16, 10), cols_only.cols); + + const natural: Image.Placement = .{ + .img_id = 1, + .options = .{}, + .src_width_px = 400, + .src_height_px = 200, + }; + const none = vx.resolvePlaceholderCellArea(natural) orelse return error.TestUnexpectedResult; + try std.testing.expectEqual(@as(u16, 10), none.rows); + try std.testing.expectEqual(@as(u16, 40), none.cols); +} + +test "render: tmux placeholder path emits wrapped placement and placeholder text" { + var vx = try Vaxis.init(std.testing.allocator, .{ .kitty_graphics_tmux_mode = .on }); + var deinit_writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer deinit_writer.deinit(); + defer vx.deinit(std.testing.allocator, &deinit_writer.writer); + + vx.caps.kitty_graphics = true; + vx.kitty_graphics_tmux_active = true; + + var resize_writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer resize_writer.deinit(); + try vx.resize( + std.testing.allocator, + &resize_writer.writer, + .{ + .rows = 2, + .cols = 2, + .x_pixel = 20, + .y_pixel = 20, + }, + ); + + const win = vx.window(); + win.writeCell(0, 0, .{ + .image = .{ + .img_id = 0x010203, + .options = .{ .size = .{ .rows = 1, .cols = 1 } }, + .src_width_px = 10, + .src_height_px = 10, + }, + }); + + var render_writer = std.io.Writer.Allocating.init(std.testing.allocator); + defer render_writer.deinit(); + try vx.render(&render_writer.writer); + const output = try render_writer.toOwnedSlice(); + defer std.testing.allocator.free(output); + + const placement = "\x1bPtmux;\x1b\x1b_Ga=p,i=66051,U=1,C=1\x1b\x1b\\\x1b\\"; + try std.testing.expect(std.mem.indexOf(u8, output, placement) != null); + try std.testing.expect(std.mem.indexOf(u8, output, "\x1b[38;2;1;2;3m") != null); + + var placeholder_buf: [16]u8 = undefined; + const placeholder = try kitty_placeholders.encodeGrapheme(&placeholder_buf, 0, 0); + try std.testing.expect(std.mem.indexOf(u8, output, placeholder) != null); +} diff --git a/src/kitty_placeholders.zig b/src/kitty_placeholders.zig new file mode 100644 index 00000000..5e9a50b4 --- /dev/null +++ b/src/kitty_placeholders.zig @@ -0,0 +1,350 @@ +const std = @import("std"); + +pub const placeholder_codepoint: u21 = 0x10EEEE; + +pub const diacritics = [_]u21{ + 0x0305, + 0x030D, + 0x030E, + 0x0310, + 0x0312, + 0x033D, + 0x033E, + 0x033F, + 0x0346, + 0x034A, + 0x034B, + 0x034C, + 0x0350, + 0x0351, + 0x0352, + 0x0357, + 0x035B, + 0x0363, + 0x0364, + 0x0365, + 0x0366, + 0x0367, + 0x0368, + 0x0369, + 0x036A, + 0x036B, + 0x036C, + 0x036D, + 0x036E, + 0x036F, + 0x0483, + 0x0484, + 0x0485, + 0x0486, + 0x0487, + 0x0592, + 0x0593, + 0x0594, + 0x0595, + 0x0597, + 0x0598, + 0x0599, + 0x059C, + 0x059D, + 0x059E, + 0x059F, + 0x05A0, + 0x05A1, + 0x05A8, + 0x05A9, + 0x05AB, + 0x05AC, + 0x05AF, + 0x05C4, + 0x0610, + 0x0611, + 0x0612, + 0x0613, + 0x0614, + 0x0615, + 0x0616, + 0x0617, + 0x0657, + 0x0658, + 0x0659, + 0x065A, + 0x065B, + 0x065D, + 0x065E, + 0x06D6, + 0x06D7, + 0x06D8, + 0x06D9, + 0x06DA, + 0x06DB, + 0x06DC, + 0x06DF, + 0x06E0, + 0x06E1, + 0x06E2, + 0x06E4, + 0x06E7, + 0x06E8, + 0x06EB, + 0x06EC, + 0x0730, + 0x0732, + 0x0733, + 0x0735, + 0x0736, + 0x073A, + 0x073D, + 0x073F, + 0x0740, + 0x0741, + 0x0743, + 0x0745, + 0x0747, + 0x0749, + 0x074A, + 0x07EB, + 0x07EC, + 0x07ED, + 0x07EE, + 0x07EF, + 0x07F0, + 0x07F1, + 0x07F3, + 0x0816, + 0x0817, + 0x0818, + 0x0819, + 0x081B, + 0x081C, + 0x081D, + 0x081E, + 0x081F, + 0x0820, + 0x0821, + 0x0822, + 0x0823, + 0x0825, + 0x0826, + 0x0827, + 0x0829, + 0x082A, + 0x082B, + 0x082C, + 0x082D, + 0x0951, + 0x0953, + 0x0954, + 0x0F82, + 0x0F83, + 0x0F86, + 0x0F87, + 0x135D, + 0x135E, + 0x135F, + 0x17DD, + 0x193A, + 0x1A17, + 0x1A75, + 0x1A76, + 0x1A77, + 0x1A78, + 0x1A79, + 0x1A7A, + 0x1A7B, + 0x1A7C, + 0x1B6B, + 0x1B6D, + 0x1B6E, + 0x1B6F, + 0x1B70, + 0x1B71, + 0x1B72, + 0x1B73, + 0x1CD0, + 0x1CD1, + 0x1CD2, + 0x1CDA, + 0x1CDB, + 0x1CE0, + 0x1DC0, + 0x1DC1, + 0x1DC3, + 0x1DC4, + 0x1DC5, + 0x1DC6, + 0x1DC7, + 0x1DC8, + 0x1DC9, + 0x1DCB, + 0x1DCC, + 0x1DD1, + 0x1DD2, + 0x1DD3, + 0x1DD4, + 0x1DD5, + 0x1DD6, + 0x1DD7, + 0x1DD8, + 0x1DD9, + 0x1DDA, + 0x1DDB, + 0x1DDC, + 0x1DDD, + 0x1DDE, + 0x1DDF, + 0x1DE0, + 0x1DE1, + 0x1DE2, + 0x1DE3, + 0x1DE4, + 0x1DE5, + 0x1DE6, + 0x1DFE, + 0x20D0, + 0x20D1, + 0x20D4, + 0x20D5, + 0x20D6, + 0x20D7, + 0x20DB, + 0x20DC, + 0x20E1, + 0x20E7, + 0x20E9, + 0x20F0, + 0x2CEF, + 0x2CF0, + 0x2CF1, + 0x2DE0, + 0x2DE1, + 0x2DE2, + 0x2DE3, + 0x2DE4, + 0x2DE5, + 0x2DE6, + 0x2DE7, + 0x2DE8, + 0x2DE9, + 0x2DEA, + 0x2DEB, + 0x2DEC, + 0x2DED, + 0x2DEE, + 0x2DEF, + 0x2DF0, + 0x2DF1, + 0x2DF2, + 0x2DF3, + 0x2DF4, + 0x2DF5, + 0x2DF6, + 0x2DF7, + 0x2DF8, + 0x2DF9, + 0x2DFA, + 0x2DFB, + 0x2DFC, + 0x2DFD, + 0x2DFE, + 0x2DFF, + 0xA66F, + 0xA67C, + 0xA67D, + 0xA6F0, + 0xA6F1, + 0xA8E0, + 0xA8E1, + 0xA8E2, + 0xA8E3, + 0xA8E4, + 0xA8E5, + 0xA8E6, + 0xA8E7, + 0xA8E8, + 0xA8E9, + 0xA8EA, + 0xA8EB, + 0xA8EC, + 0xA8ED, + 0xA8EE, + 0xA8EF, + 0xA8F0, + 0xA8F1, + 0xAAB0, + 0xAAB2, + 0xAAB3, + 0xAAB7, + 0xAAB8, + 0xAABE, + 0xAABF, + 0xAAC1, + 0xFE20, + 0xFE21, + 0xFE22, + 0xFE23, + 0xFE24, + 0xFE25, + 0xFE26, + 0x10A0F, + 0x10A38, + 0x1D185, + 0x1D186, + 0x1D187, + 0x1D188, + 0x1D189, + 0x1D1AA, + 0x1D1AB, + 0x1D1AC, + 0x1D1AD, + 0x1D242, + 0x1D243, + 0x1D244, +}; + +pub fn diacriticForIndex(index: usize) u21 { + return if (index < diacritics.len) diacritics[index] else diacritics[0]; +} + +pub fn encodeGrapheme(buf: *[16]u8, row: usize, col: usize) ![]const u8 { + var total: usize = 0; + total += try std.unicode.utf8Encode(placeholder_codepoint, buf[total..]); + total += try std.unicode.utf8Encode(diacriticForIndex(row), buf[total..]); + total += try std.unicode.utf8Encode(diacriticForIndex(col), buf[total..]); + return buf[0..total]; +} + +pub fn writeGrapheme(writer: anytype, row: usize, col: usize) !void { + var buf: [16]u8 = undefined; + const grapheme = try encodeGrapheme(&buf, row, col); + try writer.writeAll(grapheme); +} + +test "placeholder grapheme includes base and two marks" { + var bytes: [16]u8 = undefined; + const grapheme = try encodeGrapheme(&bytes, 2, 3); + + var iter = std.unicode.Utf8Iterator{ .bytes = grapheme, .i = 0 }; + const first = iter.nextCodepoint() orelse return error.TestUnexpectedResult; + const second = iter.nextCodepoint() orelse return error.TestUnexpectedResult; + const third = iter.nextCodepoint() orelse return error.TestUnexpectedResult; + const fourth = iter.nextCodepoint(); + + try std.testing.expectEqual(placeholder_codepoint, first); + try std.testing.expectEqual(diacritics[2], second); + try std.testing.expectEqual(diacritics[3], third); + try std.testing.expect(fourth == null); +} + +test "placeholder grapheme out of range falls back to first diacritic" { + var bytes: [16]u8 = undefined; + const grapheme = try encodeGrapheme(&bytes, diacritics.len + 10, diacritics.len + 20); + + var iter = std.unicode.Utf8Iterator{ .bytes = grapheme, .i = 0 }; + _ = iter.nextCodepoint() orelse return error.TestUnexpectedResult; + const second = iter.nextCodepoint() orelse return error.TestUnexpectedResult; + const third = iter.nextCodepoint() orelse return error.TestUnexpectedResult; + + try std.testing.expectEqual(diacritics[0], second); + try std.testing.expectEqual(diacritics[0], third); +}