diff options
| author | Gabriel Schneider <[email protected]> | 2026-10-01 00:35:37 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-10-01 00:53:17 -0300 |
| commit | 28c74918cde4092d964daa847c026ed3de282683 (patch) | |
| tree | 29245d17029b95f4676a821fa0e3ce1eaf95ec3e /src/terminal.zig | |
| parent | 3a4f79f40038b880e4d7c88f5651f410cf016191 (diff) | |
| download | pardes-28c74918cde4092d964daa847c026ed3de282683.tar.gz pardes-28c74918cde4092d964daa847c026ed3de282683.zip | |
A terminal pane draws the kitty graphics its program places, so yazi's image and PDF previews show
yazi asks the pane XTVERSION, and ghostty-vt answers "libghostty": yazi
takes it for Ghostty and sends its previews by the kitty graphics protocol
with Unicode placeholders (a=T,U=1, then U+10EEEE cells). ghostty-vt stored
the images, but nothing drew them: the placeholders showed as tofu in the
GUI and as raw codepoints in the tty, in every build since terminal panes
answered queries (vkznsvzs, 2026-07-01). Before that no query was answered
and yazi fell back to chafa's text art.
Each frame, TerminalImages.zig reads the active screen's images and
placements (virtual ones through their placeholder runs, and ones placed on
a cell), cuts each into the cell rows it covers, maps those rows through
the body's own row walk, clips them to the pane, and hands them on as
ImagePlaces with exact geometry (NativePlacement.exact). The GUI draws
them as textures; the tty passes them to an outer terminal with kitty
graphics as one placement per row, its source cropped to whole cells.
Placeholder cells draw no text. Scrolling, a delete, a resize, the
alternate screen and a closed pane all fall out of reading the emulator's
state every frame.
A pane now knows its size in pixels: CSI 14/16/18 t are answered, the
emulator's width_px/height_px follow the shell's cells, and the pty's
winsize carries pixels (host_io.ptyWinsize), so yazi and a nested pardes
size their images right. ghostty-vt's image store is 64 MiB per screen
(its library default, 10 MB, refused a large preview), PNG transmissions
are decoded with stb_image, and RGB images are converted only while shown.
test/yazi_preview.py runs yazi in a hidden window on a PNG and a PDF and
checks the captures, also through a tty pardes nested in the pane.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'src/terminal.zig')
| -rw-r--r-- | src/terminal.zig | 177 |
1 files changed, 177 insertions, 0 deletions
diff --git a/src/terminal.zig b/src/terminal.zig index c0341d00..e6304c0a 100644 --- a/src/terminal.zig +++ b/src/terminal.zig @@ -20,6 +20,10 @@ const ghostty_vt = if (enabled) @import("ghostty-vt") else struct {}; pub const history_max = if (enabled) 64 else 8; pub const VtSlot = if (enabled) ghostty_vt.Terminal else void; +/// Kitty graphics are drawn where pixel geometry is (pdf_enabled, as for +/// image panes' native placements). +pub const images_enabled = enabled and pardes.pdf_enabled; +pub const TerminalImages = if (images_enabled) @import("TerminalImages.zig") else void; pub const StreamSlot = if (enabled) ghostty_vt.Stream(MarkedHandler) else void; /// The `aid` pardes's injected bash and fish marks carry (host_io.zig). @@ -127,6 +131,8 @@ pub const State = struct { /// The grid cell the program last heard the mouse at, so motion is /// reported once per cell. mouse_cell: if (enabled) ?ghostty_vt.Coordinate else void = if (enabled) null else {}, + /// The kitty graphics its program placed, as drawn (TerminalImages.zig). + images: TerminalImages = if (images_enabled) .{} else {}, }; const GColor = ghostty_vt.color; @@ -560,6 +566,7 @@ pub fn screenTextLen(pane: *Pane) usize { pub fn deinitEmulator(pane: *Pane, gpa: std.mem.Allocator) void { if (comptime !enabled) return; const state = pane.terminal orelse return; + if (comptime images_enabled) state.images.deinit(gpa); state.stream.deinit(); state.vt.deinit(gpa); gpa.destroy(state); @@ -578,13 +585,18 @@ pub fn create(gpa: std.mem.Allocator, cols: u16, rows: u16) !*Pane { .cols = cols, .rows = rows, .max_scrollback = 16 * 1024 * 1024, + .kitty_image_storage_limit = if (images_enabled) TerminalImages.storage_limit else 0, }); state.stream = .initAlloc(gpa, .{ .inner = state.vt.vtHandler() }); state.replay.head = 0; state.replay.len = 0; state.reply_len = 0; + state.mouse_cell = null; + state.images = if (images_enabled) .{} else {}; + if (comptime images_enabled) TerminalImages.installPngDecoder(); state.stream.handler.inner.effects.write_pty = ptyReport; state.stream.handler.inner.effects.device_attributes = ptyDeviceAttrs; + state.stream.handler.inner.effects.size = ptySize; pane.terminal = state; pane.tty_filter = true; return pane; @@ -874,6 +886,7 @@ pub fn feedOutput(p: *Pardes, pane: *Pane, bytes: []const u8) void { // viewport down only when the viewport was already on the last row, so // a reader scrolled back into the scrollback stays where they scrolled. const at_bottom = atBottom(pane); + syncPixels(p, pane); if (has_positions) ingestWithPositions(pane, bytes) else ingest(pane, bytes); p.shell_rows.markStale(pane); if (pane.body.mode == .tty and at_bottom) followOutput(pane); @@ -2766,6 +2779,59 @@ pub fn ptyDeviceAttrs(_: *ghostty_vt.TerminalStream.Handler) DeviceAttrs { return .{}; } +/// CSI 14/16/18 t: the pane's size, and its cells' in pixels, which a +/// program sizes its kitty graphics by (yazi asks 16 t). Unknown, not asked. +fn ptySize(handler: *ghostty_vt.TerminalStream.Handler) ?ghostty_vt.size_report.Size { + const t = handler.terminal; + if (t.width_px == 0 or t.height_px == 0 or t.cols == 0 or t.rows == 0) return null; + return .{ .rows = t.rows, .columns = t.cols, .cell_width = t.width_px / t.cols, .cell_height = t.height_px / t.rows }; +} + +/// The emulator's size in pixels follows the shell's cells: kitty graphics +/// placements are sized by it, and CSI 14/16 t report it. +fn syncPixels(p: *const Pardes, pane: *Pane) void { + if (comptime !images_enabled) return; + const state = pane.terminal orelse return; + const vt = &state.vt; + vt.width_px = @as(u32, vt.cols) * p.cell_pixels.w; + vt.height_px = @as(u32, vt.rows) * p.cell_pixels.h; +} + +/// Draw the pane's kitty graphics images over its body rows (after the +/// rows and their colours: placeholder cells are blanked here). +pub fn drawImages(p: *Pardes, s: *Surface, pane: *Pane, id: usize, tx: u16, tw: u16, body_y: u16, body_h: u16) void { + if (comptime !images_enabled) return; + const state = pane.terminal orelse return; + const screen = state.vt.screens.active; + syncPixels(p, pane); + // A body taller than this draws its images on the rows that fit. + var rows_of: [1024]?u16 = undefined; + const vp_rows: usize = @min(scrollbar(pane).len, rows_of.len); + @memset(rows_of[0..vp_rows], null); + var any_placeholder = false; + var walk: BodyWalk = .init(pane); + var vr: u16 = 0; + while (walk.next()) |row| : (vr += 1) { + if (vr >= body_h) break; + const vi = switch (row) { + .grid => |v| v, + .edit => continue, + }; + if (vi < 0 or vi >= vp_rows) continue; + const row_pin = screen.pages.pin(.{ .viewport = .{ .y = @intCast(vi) } }) orelse continue; + const flags = row_pin.rowAndCell().row; + // A prompt row outside tty mode is cut or hidden: its columns are + // not the grid's. + if (pane.body.mode != .tty and flags.semantic_prompt != .none) continue; + any_placeholder = any_placeholder or flags.kitty_virtual_placeholder; + rows_of[@intCast(vi)] = vr; + } + // Nothing to draw, no placeholder to blank and nothing cached to let + // go: the common case costs the rows' flags. + if (!any_placeholder and screen.kitty_images.placements.count() == 0 and state.images.converted.count() == 0) return; + state.images.draw(p, s, &state.vt, @intCast(id), pane.serial, tx, body_y, @min(tw, pane.cols), rows_of[0..vp_rows]); +} + /// Every byte the core wrote to each pane's pty since the last call, `\n` /// between panes' shares: "0:<bytes>\n1:<bytes>\n" for panes that got any. fn ptyWritesByPane(p: *Pardes, out: *std.ArrayList(u8)) ![]const u8 { @@ -2817,3 +2883,114 @@ test "a program that asked for focus reports hears CSI I and CSI O as its pane t p.update(.tick); try std.testing.expectEqualStrings("1:\x1b[O\n", try ptyWritesByPane(p, &out)); } + +/// `a=T` of a 4x2 RGB image, `i=7`, red on the left half, blue on the +/// right, with the rest of the control data `extra` (`c=2,r=1` places it +/// over two cells). +inline fn kittyTransmit(comptime extra: []const u8) []const u8 { + return comptime blk: { + const pixels = [_]u8{ 255, 0, 0, 255, 0, 0, 0, 0, 255, 0, 0, 255 } ** 2; + var buf: [std.base64.standard.Encoder.calcSize(pixels.len)]u8 = undefined; + const b64 = std.base64.standard.Encoder.encode(&buf, &pixels)[0..buf.len].*; + break :blk "\x1b_Ga=T,f=24,s=4,v=2,i=7," ++ extra ++ ";" ++ b64 ++ "\x1b\\"; + }; +} + +fn imagePlaces(s: *const Surface) []const ?pardes.ImagePlace { + return s.images[0..s.nimages]; +} + +test "a terminal's kitty graphics image is drawn where its program placed it, and a delete takes it away" { + if (comptime !images_enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .shells = 1 }); + defer p.deinit(); + p.native_images = true; + p.active = 0; + p.update(.{ .resize = .{ .cols = 80, .rows = 24, .cell_pixels = .{ .w = 10, .h = 20 } } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const pane = p.panes[0].?; + pane.body.mode = .tty; + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[3;5H" ++ kittyTransmit("c=2,r=1") } }); + var s = try p.render(frame.allocator()); + const places = imagePlaces(s); + try std.testing.expectEqual(@as(usize, 1), places.len); + const place = places[0].?; + const r = p.rects[0]; + try std.testing.expect(place.native.exact); + try std.testing.expectEqual(@as(u32, 7), place.native.page); + try std.testing.expectEqual(pane.serial, place.serial); + try std.testing.expectEqual(r.x + config.GUTTER + 4, place.x); + try std.testing.expectEqual(p.bodyTop(pane, r) + 2, place.y); + try std.testing.expectEqual(@as(u16, 2), place.w); + try std.testing.expectEqual(@as(u16, 1), place.h); + try std.testing.expectEqual(@as(usize, 4), place.iw); + try std.testing.expectEqual(@as(usize, 2), place.ih); + try std.testing.expectEqualSlices(u8, &.{ 255, 0, 0, 255 }, place.rgba[0..4]); + const g = place.native.geometry.?; + try std.testing.expectEqual(pardes.image.PixelRect{ .x = 0, .y = 0, .w = 4, .h = 2 }, g.src); + try std.testing.expectEqual(pardes.image.PixelRect{ .x = 0, .y = 0, .w = 20, .h = 20 }, g.dst); + + // Scrolled up by the shell's output, it goes up with the text. + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[24;1H\n\n" } }); + _ = frame.reset(.retain_capacity); + s = try p.render(frame.allocator()); + try std.testing.expectEqual(p.bodyTop(pane, r), imagePlaces(s)[0].?.y); + p.update(.{ .output = .{ .pane = 0, .bytes = "\n" } }); + _ = frame.reset(.retain_capacity); + s = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), imagePlaces(s).len); + + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[1;1H" ++ kittyTransmit("c=2,r=1") } }); + _ = frame.reset(.retain_capacity); + s = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 1), imagePlaces(s).len); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b_Ga=d,d=A\x1b\\" } }); + _ = frame.reset(.retain_capacity); + s = try p.render(frame.allocator()); + try std.testing.expectEqual(@as(usize, 0), imagePlaces(s).len); +} + +test "a virtual kitty placement shows through its placeholder cells, which draw no text" { + if (comptime !images_enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .shells = 1 }); + defer p.deinit(); + p.native_images = true; + p.update(.{ .resize = .{ .cols = 80, .rows = 24, .cell_pixels = .{ .w = 10, .h = 20 } } }); + var frame = std.heap.ArenaAllocator.init(gpa); + defer frame.deinit(); + const pane = p.panes[0].?; + pane.body.mode = .tty; + // yazi's way: a virtual placement two cells by one, then the two + // placeholders, the image's id in their colour, row and column in + // their diacritics. + p.update(.{ .output = .{ .pane = 0, .bytes = kittyTransmit("U=1,c=2,r=1,q=2") ++ + "\x1b[2;3H\x1b[38;2;0;0;7m\u{10EEEE}\u{0305}\u{0305}\u{10EEEE}\u{0305}\u{030D}\x1b[39mX" } }); + const s = try p.render(frame.allocator()); + const places = imagePlaces(s); + try std.testing.expectEqual(@as(usize, 1), places.len); + const r = p.rects[0]; + const tx = r.x + config.GUTTER; + const y = p.bodyTop(pane, r) + 1; + try std.testing.expectEqual(tx + 2, places[0].?.x); + try std.testing.expectEqual(y, places[0].?.y); + try std.testing.expectEqual(@as(u16, 2), places[0].?.w); + try std.testing.expectEqualStrings(" ", s.at(tx + 2, y).grapheme()); + try std.testing.expectEqualStrings(" ", s.at(tx + 3, y).grapheme()); + try std.testing.expectEqualStrings("X", s.at(tx + 4, y).grapheme()); +} + +test "a program asking the cell size in pixels (CSI 16 t) is told the shell's" { + if (comptime !images_enabled) return error.SkipZigTest; + const gpa = std.testing.allocator; + const p = try Pardes.init(gpa, .{ .cols = 80, .rows = 24, .shells = 1 }); + defer p.deinit(); + p.update(.{ .resize = .{ .cols = 80, .rows = 24, .cell_pixels = .{ .w = 10, .h = 20 } } }); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(gpa); + _ = try ptyWritesByPane(p, &out); + p.update(.{ .output = .{ .pane = 0, .bytes = "\x1b[16t" } }); + try std.testing.expectEqualStrings("0:\x1b[6;20;10t\n", try ptyWritesByPane(p, &out)); +} |
