diff --git a/src/terminal/c/formatter.zig b/src/terminal/c/formatter.zig index 5b4504c8c..e64da555d 100644 --- a/src/terminal/c/formatter.zig +++ b/src/terminal/c/formatter.zig @@ -37,6 +37,7 @@ pub const ScreenOptions = extern struct { hyperlink: bool, protection: bool, kitty_keyboard: bool, + kitty_graphics: bool, charsets: bool, comptime { @@ -53,6 +54,7 @@ pub const ScreenOptions = extern struct { .hyperlink = self.hyperlink, .protection = self.protection, .kitty_keyboard = self.kitty_keyboard, + .kitty_graphics = self.kitty_graphics, .charsets = self.charsets, }; } @@ -232,7 +234,7 @@ test "terminal_new/free" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); try testing.expect(f != null); free(f); @@ -244,7 +246,7 @@ test "terminal_new invalid_value on null terminal" { &lib.alloc.test_allocator, &f, null, - .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); try testing.expect(f == null); } @@ -269,7 +271,7 @@ test "format plain" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); @@ -295,7 +297,7 @@ test "format reflects terminal changes" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); @@ -327,7 +329,7 @@ test "format null returns required size" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); @@ -359,7 +361,7 @@ test "format buffer too small" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); @@ -392,7 +394,7 @@ test "format vt" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .vt, .unwrap = false, .trim = true, .extra = .{ .palette = true, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = true, .hyperlink = true, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .vt, .unwrap = false, .trim = true, .extra = .{ .palette = true, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = true, .hyperlink = true, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); @@ -437,7 +439,7 @@ test "format plain with selection" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .plain, .unwrap = false, .trim = true, .selection = &sel, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .plain, .unwrap = false, .trim = true, .selection = &sel, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); @@ -463,7 +465,7 @@ test "format html" { &lib.alloc.test_allocator, &f, t, - .{ .emit = .html, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + .{ .emit = .html, .unwrap = false, .trim = true, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .kitty_graphics = false, .charsets = false } } }, )); defer free(f); diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index a375b4dd7..4c1aef066 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -18,6 +18,116 @@ const Pin = PageList.Pin; const Row = @import("page.zig").Row; const Selection = @import("Selection.zig"); const Style = @import("style.zig").Style; +const build_options = @import("terminal_options"); +const encoder = std.base64.standard.Encoder; +const ImageStorage = if (build_options.kitty_graphics) kitty.graphics.ImageStorage else void; + +const KittyGfxState = if (build_options.kitty_graphics) struct { + alloc: Allocator, + terminal: *const Terminal, + images: *const ImageStorage, + // Track which images have already been loaded + seen_images: std.AutoArrayHashMapUnmanaged(u32, void), + // Track how many rows we should skip since we already created + // newlines for them + skip_rows: u32 = 0, + + fn init(alloc: Allocator, terminal: *const Terminal, images: *const ImageStorage) KittyGfxState { + return .{ + .images = images, + .seen_images = .{}, + .alloc = alloc, + .terminal = terminal, + }; + } + + fn deinit(self: *KittyGfxState) void { + self.seen_images.deinit(self.alloc); + } + + fn format(self: *KittyGfxState, writer: *std.Io.Writer, page: *const Page, y: size.CellCountInt, blank_rows: *usize) std.Io.Writer.Error!void { + var p_iter = self.images.placements.iterator(); + while (p_iter.next()) |p_entry| { + const key = p_entry.key_ptr.*; + const placement = p_entry.value_ptr.*; + + // Only handle pin-based placements on this specific node+row. + const pin = switch (placement.location) { + .pin => |p| p, + .virtual => continue, + }; + if (&pin.node.data != page or pin.y != y) continue; + + // Transmit image data if not already sent. + if (!self.seen_images.contains(key.image_id)) { + if (self.images.imageById(key.image_id)) |img| { + const encoded_size = encoder.calcSize(img.data.len); + const encoded_buf = self.alloc.alloc(u8, encoded_size) catch { + return error.WriteFailed; + }; + defer self.alloc.free(encoded_buf); + + _ = encoder.encode(encoded_buf, img.data); + + const chunk_size = 4096; + var start: usize = 0; + while (start < encoded_buf.len) { + const end = @min(start + chunk_size, encoded_buf.len); + const is_last = end == encoded_buf.len; + const more_flag: u32 = if (is_last) 0 else 1; + + const seq = std.fmt.allocPrint( + self.alloc, + "\x1b_Ga=t,q=1,f={d},s={d},v={d},i={d},m={d};{s}\x1b\\", + .{ + kitty.graphics.Transmission.protocolValue(img.format), + img.width, + img.height, + img.id, + more_flag, + encoded_buf[start..end], + }, + ) catch return error.WriteFailed; + defer self.alloc.free(seq); + try writer.writeAll(seq); + start = end; + } + } + self.seen_images.put(self.alloc, key.image_id, {}) catch { + return error.WriteFailed; + }; + } + + // Flush any accumulated blank rows before emitting image + // placements so that newlines from previous content rows + // appear before the image data in the output stream. + if (blank_rows.* > 0) { + for (0..blank_rows.*) |_| try writer.writeAll("\r\n"); + blank_rows.* = 0; + } + + // Move cursor to the placement's column, emit placement, + // then return cursor to column 1 so subsequent cell content + // aligns correctly. + try writer.print("\x1b[{d}G", .{pin.x + 1}); + const p_seq = std.fmt.allocPrint( + self.alloc, + "\x1b_Ga=p,q=1,i={d},c={d},r={d},X={d},Y={d}\x1b\\", + .{ key.image_id, placement.columns, placement.rows, placement.x_offset, placement.y_offset }, + ) catch return error.WriteFailed; + defer self.alloc.free(p_seq); + try writer.writeAll(p_seq); + try writer.print("\x1b[1G", .{}); + + if (self.images.imageById(key.image_id)) |image| { + const grid = placement.gridSize(image, self.terminal); + // Skip grid.rows - 1 because the placement row itself is + // already being consumed by the current loop iteration. + self.skip_rows += grid.rows -| 1; + } + } + } +} else void; /// Formats available. pub const Format = lib.Enum(lib.target, &.{ @@ -335,6 +445,7 @@ pub const TerminalFormatter = struct { } var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); + screen_formatter.terminal = self.terminal; screen_formatter.content = self.content; screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; @@ -425,6 +536,9 @@ pub const ScreenFormatter = struct { /// The screen to format. screen: *const Screen, + /// The terminal, needed for kitty graphics grid size calculations. + terminal: ?*const Terminal = null, + /// The common options opts: Options, @@ -476,6 +590,9 @@ pub const ScreenFormatter = struct { /// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. kitty_keyboard: bool, + /// Emit Kitty graphics protocol state + kitty_graphics: bool, + /// Emit character set designations and invocations. /// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations. charsets: bool, @@ -486,6 +603,7 @@ pub const ScreenFormatter = struct { .style = false, .hyperlink = false, .protection = false, + .kitty_graphics = false, .kitty_keyboard = false, .charsets = false, }; @@ -496,6 +614,7 @@ pub const ScreenFormatter = struct { .style = true, .hyperlink = true, .protection = false, + .kitty_graphics = false, .kitty_keyboard = false, .charsets = false, }; @@ -507,6 +626,7 @@ pub const ScreenFormatter = struct { .style = true, .hyperlink = true, .protection = true, + .kitty_graphics = true, .kitty_keyboard = true, .charsets = true, }; @@ -535,6 +655,20 @@ pub const ScreenFormatter = struct { self: ScreenFormatter, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { + // Setup kitty graphics state if needed. This is passed down to + // the page formatters so placements are emitted inline during + // content row iteration. + // Currently we only support emitting kitty graphics for the vt + // format. + const need_kitty_gfx = self.opts.emit == .vt and + self.extra.kitty_graphics and + comptime build_options.kitty_graphics; + var kitty_gfx_state: ?KittyGfxState = if (need_kitty_gfx) + KittyGfxState.init(self.screen.alloc, self.terminal.?, &self.screen.kitty_images) + else + null; + defer if (kitty_gfx_state) |*s| s.deinit(); + switch (self.content) { .none => {}, @@ -542,6 +676,7 @@ pub const ScreenFormatter = struct { // Emit our pagelist contents according to our selection. var list_formatter: PageListFormatter = .init(&self.screen.pages, self.opts); list_formatter.pin_map = self.pin_map; + list_formatter.kitty_gfx_state = if (kitty_gfx_state) |*s| s else null; if (selection_) |sel| { list_formatter.top_left = sel.topLeft(self.screen); list_formatter.bottom_right = sel.bottomRight(self.screen); @@ -716,6 +851,8 @@ pub const PageListFormatter = struct { /// Warning: there is a significant performance hit to track this pin_map: ?PinMap, + kitty_gfx_state: ?*KittyGfxState, + pub fn init( list: *const PageList, opts: Options, @@ -727,6 +864,7 @@ pub const PageListFormatter = struct { .bottom_right = null, .rectangle = false, .pin_map = null, + .kitty_gfx_state = null, }; } @@ -752,6 +890,7 @@ pub const PageListFormatter = struct { formatter.end_y = chunk.end - 1; formatter.trailing_state = page_state; formatter.rectangle = self.rectangle; + formatter.kitty_gfx_state = self.kitty_gfx_state; // For rectangle selection, apply start_x and end_x to all chunks if (self.rectangle) { @@ -841,6 +980,8 @@ pub const PageFormatter = struct { /// accounting works properly. trailing_state: ?TrailingState, + kitty_gfx_state: ?*KittyGfxState, + /// Trailing state. This is used to ensure that rows wrapped across /// multiple pages are unwrapped properly, as well as other accounting /// we may do in the future. @@ -864,6 +1005,7 @@ pub const PageFormatter = struct { .rectangle = false, .point_map = null, .trailing_state = null, + .kitty_gfx_state = null, }; } @@ -1040,10 +1182,23 @@ pub const PageFormatter = struct { break :cells_subset .{ subset, row_start_x }; }; + // Emit kitty images if they are present on this page+row. + if (self.kitty_gfx_state) |kitty_gfx| { + try kitty_gfx.format(writer, self.page, y, &blank_rows); + } + // If this row is blank, accumulate to avoid a bunch of extra // work later. If it isn't blank, make sure we dump all our - // blanks. + // blanks. If kitty graphics just emitted a placement, skip + // the blank rows that the image occupies since the receiving + // terminal will advance the cursor for the image. if (!Cell.hasTextAny(cells_subset)) { + if (self.kitty_gfx_state) |kitty_gfx| { + if (kitty_gfx.skip_rows > 0) { + kitty_gfx.skip_rows -= 1; + continue; + } + } blank_rows += 1; continue; } @@ -4873,6 +5028,159 @@ test "Screen vt with kitty keyboard" { } } +test "Screen vt with kitty graphics" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + s.nextSlice("\x1b[H"); + // 2x2 red block + s.nextSlice("\x1b_Ga=T,f=24,s=2,v=2;/wAA/wAA/wAA/wAA\x1b\\"); + std.debug.print("Images after nextSlice: {}\n", .{t.screens.active.kitty_images.images.count()}); + + var pin_map: std.ArrayList(Pin) = .empty; + defer pin_map.deinit(alloc); + + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.terminal = &t; + formatter.extra.kitty_graphics = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + s2.nextSlice(output); + + try testing.expectEqual(t.screens.active.kitty_images.images.count(), 1); + try testing.expectEqual(t2.screens.active.kitty_images.images.count(), 1); + var iter = t.screens.active.kitty_images.images.iterator(); + const orig_img = iter.next().?.value_ptr.*; + var iter2 = t2.screens.active.kitty_images.images.iterator(); + const rest_img = iter2.next().?.value_ptr.*; + + try testing.expectEqual(orig_img.width, rest_img.width); + try testing.expectEqual(orig_img.height, rest_img.height); + try testing.expectEqual(orig_img.format, rest_img.format); + try testing.expectEqualSlices(u8, orig_img.data, rest_img.data); +} + +test "Screen vt with kitty graphics spacing" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + // Set pixel dimensions so gridSize can calculate rows when c/r aren't set. + t.width_px = 80 * 10; + t.height_px = 24 * 20; + + var s = t.vtStream(); + defer s.deinit(); + + // Move to top, transmit+display first image (2x2 red block, 3 rows tall) + s.nextSlice("\x1b[H"); + s.nextSlice("\x1b_Ga=T,f=24,s=2,v=2,c=10,r=3,i=1;/wAA/wAA/wAA/wAA\x1b\\"); + + // Write text between images. + s.nextSlice("hello between images\r\n"); + + // Transmit+display second image (2x2 blue block, 2 rows tall) + s.nextSlice("\x1b_Ga=T,f=24,s=2,v=2,c=10,r=2,i=2;AAD/AAD/AAD/AAD/\x1b\\"); + + // Verify source terminal state. + try testing.expectEqual(@as(u32, 2), t.screens.active.kitty_images.images.count()); + try testing.expectEqual(@as(u32, 2), t.screens.active.kitty_images.placements.count()); + + // Verify the text landed at the expected position in the source terminal. + // Image 1 is 3 rows (r=3) at row 0, cursor advances to row 2 col 10 + // (setCursorPos is 1-indexed: row=3, col=11 → 0-indexed: row=2, col=10). + { + const c = t.screens.active.pages.getCell(.{ .active = .{ .x = 10, .y = 2 } }).?; + try testing.expectEqual(@as(u21, 'h'), c.cell.codepoint()); + } + + // Format and replay into a second terminal. + var formatter: ScreenFormatter = .init(t.screens.active, .vt); + formatter.terminal = &t; + formatter.extra.kitty_graphics = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + t2.width_px = 80 * 10; + t2.height_px = 24 * 20; + + var s2 = t2.vtStream(); + defer s2.deinit(); + + s2.nextSlice(output); + + // Both images should be restored. + try testing.expectEqual(@as(u32, 2), t2.screens.active.kitty_images.images.count()); + try testing.expectEqual(@as(u32, 2), t2.screens.active.kitty_images.placements.count()); + + // With C=0 (default cursor movement), the terminal advances the + // cursor automatically after image placement, matching the source + // terminal's behavior. Text lands at row 2, same as the source. + { + const c = t2.screens.active.pages.getCell(.{ .active = .{ .x = 10, .y = 2 } }).?; + try testing.expectEqual(@as(u21, 'h'), c.cell.codepoint()); + } + + // Verify the second image placement exists and is below the text. + // Text is on row 2 with \r\n after it, so image 2 should be on row 3. + var found_img2 = false; + var p_iter = t2.screens.active.kitty_images.placements.iterator(); + while (p_iter.next()) |entry| { + if (entry.key_ptr.image_id == 2) { + const placement = entry.value_ptr.*; + const pin = switch (placement.location) { + .pin => |p| p, + .virtual => continue, + }; + try testing.expectEqual(@as(usize, 3), pin.y); + found_img2 = true; + } + } + try testing.expect(found_img2); +} + test "Screen vt with charsets" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/kitty/graphics.zig b/src/terminal/kitty/graphics.zig index 6659cd310..6832a9d3e 100644 --- a/src/terminal/kitty/graphics.zig +++ b/src/terminal/kitty/graphics.zig @@ -23,6 +23,7 @@ const image = @import("graphics_image.zig"); const storage = @import("graphics_storage.zig"); pub const unicode = @import("graphics_unicode.zig"); pub const Command = command.Command; +pub const Transmission = command.Transmission; pub const CommandParser = command.Parser; pub const Image = image.Image; pub const LoadingImage = image.LoadingImage; diff --git a/src/terminal/kitty/graphics_command.zig b/src/terminal/kitty/graphics_command.zig index aa35fbeb9..179810448 100644 --- a/src/terminal/kitty/graphics_command.zig +++ b/src/terminal/kitty/graphics_command.zig @@ -436,6 +436,17 @@ pub const Transmission = struct { }; } + /// Returns the protocol value for this format + pub fn protocolValue(self: Format) u8 { + return switch (self) { + .rgb => 24, + .rgba => 32, + .png => 100, + .gray_alpha => 16, + .gray => 8, + }; + } + fn parse(kv: KV) !Transmission { var result: Transmission = .{}; if (kv.get('f')) |v| {