pull/12182/merge
Eric Bower 2026-06-02 10:21:05 -05:00 committed by GitHub
commit d1f2e284b8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 332 additions and 10 deletions

View File

@ -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);

View File

@ -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;

View File

@ -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;

View File

@ -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| {