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 | |
| 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')
| -rw-r--r-- | src/TerminalImages.zig | 247 | ||||
| -rw-r--r-- | src/body_layer.zig | 1 | ||||
| -rw-r--r-- | src/gui/gui.zig | 2 | ||||
| -rw-r--r-- | src/host_io.zig | 13 | ||||
| -rw-r--r-- | src/surface.zig | 4 | ||||
| -rw-r--r-- | src/terminal.zig | 177 | ||||
| -rw-r--r-- | src/tty/tty.zig | 128 |
7 files changed, 568 insertions, 4 deletions
diff --git a/src/TerminalImages.zig b/src/TerminalImages.zig new file mode 100644 index 00000000..773297b2 --- /dev/null +++ b/src/TerminalImages.zig @@ -0,0 +1,247 @@ +//! A terminal pane's kitty graphics: the images its program sent (yazi's +//! previews, `kitten icat`) drawn where it placed them. ghostty-vt keeps +//! the images and placements; this reads them each frame, cuts each one +//! into the cell rows it covers, clipped to the rows the pane shows, and +//! hands them on as pixels (`ImagePlace`, exact geometry) to a shell that +//! draws them. +//! Both kinds of placement are drawn: one on a cell (`a=T`/`a=p`), which +//! moves with the text it was put on, and a virtual one (`U=1`) shown +//! through the U+10EEEE placeholder cells the program prints. +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); +const pardes = @import("pardes.zig"); +const image = @import("image.zig"); + +const TerminalImages = @This(); +const gfx = ghostty_vt.kitty.graphics; +const PixelRect = image.PixelRect; + +/// The bytes ghostty-vt may hold for one screen's images (it evicts the +/// oldest past this): a 4K picture in RGBA, with room for a second. +pub const storage_limit = 64 * 1024 * 1024; + +/// Pixels in RGBA for images ghostty-vt holds in another format (RGB, +/// grey): only for images on screen, dropped the frame they are not. +converted: std.AutoHashMapUnmanaged(u32, Converted) = .empty, + +const Converted = struct { generation: u64, rgba: []u8, used: bool = true }; + +/// One image's rectangle in the pane, in pixels from the viewport's top +/// left corner (above or left of it is negative). +const Rect = struct { x: i64, y: i64, w: i64, h: i64 }; + +pub fn deinit(ti: *TerminalImages, gpa: std.mem.Allocator) void { + var conv = ti.converted.valueIterator(); + while (conv.next()) |c| gpa.free(c.rgba); + ti.converted.deinit(gpa); +} + +/// Accept PNG (`f=100`) as well as raw pixels: ghostty-vt built as a +/// library decodes none itself. stb_image does it, as for image panes. +pub fn installPngDecoder() void { + ghostty_vt.sys.decode_png = &decodePng; +} + +fn decodePng(gpa: std.mem.Allocator, bytes: []const u8) ghostty_vt.sys.DecodeError!ghostty_vt.sys.Image { + const decoded = image.decode(gpa, bytes) orelse return error.InvalidData; + return .{ .width = @intCast(decoded.w), .height = @intCast(decoded.h), .data = decoded.rgba }; +} + +/// Draw the active screen's images. `rows_of[v]` is the body row the +/// viewport's row `v` is shown on, if it is shown; `cols` how many of its +/// columns the body shows, from `tx`. +pub fn draw( + ti: *TerminalImages, + p: *pardes.Pardes, + s: *pardes.Surface, + vt: *ghostty_vt.Terminal, + pane_id: u8, + serial: u32, + tx: u16, + body_y: u16, + cols: u16, + rows_of: []const ?u16, +) void { + var conv = ti.converted.valueIterator(); + while (conv.next()) |c| c.used = false; + defer ti.prune(p.gpa); + + const screen = vt.screens.active; + const storage = &screen.kitty_images; + const pages = &screen.pages; + const top = pages.getTopLeft(.viewport); + const bottom = pages.getBottomRight(.viewport) orelse return; + const cw: u32 = @max(1, p.cell_pixels.w); + const ch: u32 = @max(1, p.cell_pixels.h); + const ctx: Ctx = .{ .ti = ti, .p = p, .pane = pane_id, .serial = serial, .tx = tx, .body_y = body_y, .cols = cols, .rows_of = rows_of, .cw = cw, .ch = ch }; + + // Virtual placements: shown through the placeholder cells, one run of + // them on a row at a time. The placeholders themselves are not text. + var runs = gfx.unicode.placementIterator(top, bottom); + while (runs.next()) |run| { + const at = pages.pointFromPin(.viewport, run.pin) orelse continue; + const v = at.viewport.y; + if (v >= rows_of.len) continue; + const row = rows_of[v] orelse continue; + for (@as(usize, at.viewport.x)..@as(usize, at.viewport.x) + run.width) |x| { + if (x >= cols) break; + const cell = s.at(tx + @as(u16, @intCast(x)), body_y + row); + cell.text[0] = ' '; + cell.len = 1; + } + const img = storage.imageById(run.image_id) orelse continue; + const rp = run.renderPlacement(storage, &img, cw, ch) catch continue; + if (rp.dest_width == 0 or rp.dest_height == 0 or rp.source_width == 0 or rp.source_height == 0) continue; + ctx.place(img, .{ .x = rp.source_x, .y = rp.source_y, .w = rp.source_width, .h = rp.source_height }, .{ + .x = @as(i64, at.viewport.x) * cw + rp.offset_x, + .y = @as(i64, v) * ch + rp.offset_y, + .w = rp.dest_width, + .h = rp.dest_height, + }); + } + + // Placements on a cell: they move with the text they were put on, and + // go where it goes, above the viewport or off the screen. + const origin = pages.pointFromPin(.screen, top) orelse return; + var it = storage.placements.iterator(); + while (it.next()) |entry| { + const placed = entry.value_ptr.*; + const pin = switch (placed.location) { + .pin => |pin| pin, + .virtual => continue, + }; + const img = storage.images.get(entry.key_ptr.image_id) orelse continue; + const at = pages.pointFromPin(.screen, pin.*) orelse continue; + const size = placed.pixelSize(img, vt); + const sx = @min(placed.source_x, img.width); + const sy = @min(placed.source_y, img.height); + const sw = if (placed.source_width > 0) @min(placed.source_width, img.width - sx) else img.width - sx; + const sh = if (placed.source_height > 0) @min(placed.source_height, img.height - sy) else img.height - sy; + if (size.width == 0 or size.height == 0 or sw == 0 or sh == 0) continue; + ctx.place(img, .{ .x = sx, .y = sy, .w = sw, .h = sh }, .{ + .x = @as(i64, at.screen.x) * cw + placed.x_offset, + .y = (@as(i64, at.screen.y) - @as(i64, origin.screen.y)) * ch + placed.y_offset, + .w = size.width, + .h = size.height, + }); + } +} + +const Ctx = struct { + ti: *TerminalImages, + p: *pardes.Pardes, + pane: u8, + serial: u32, + tx: u16, + body_y: u16, + cols: u16, + rows_of: []const ?u16, + cw: u32, + ch: u32, + + /// Draw `src` of `img` over `dst`, one cell row of it at a time: a row + /// is shown, or not, or shown elsewhere, as the body lays it out. + fn place(ctx: Ctx, img: gfx.Image, src: PixelRect, dst: Rect) void { + const cw: i64 = ctx.cw; + const ch: i64 = ctx.ch; + const x0 = @max(dst.x, 0); + const x1 = @min(dst.x + dst.w, @as(i64, ctx.cols) * cw); + if (x1 <= x0) return; + const rgba = ctx.ti.pixelsOf(ctx.p.gpa, img) orelse return; + const first_row = @divFloor(@max(dst.y, 0), ch); + const last_row = @min(@divFloor(dst.y + dst.h - 1, ch), @as(i64, @intCast(ctx.rows_of.len)) - 1); + var v = first_row; + while (v <= last_row) : (v += 1) { + const row = ctx.rows_of[@intCast(v)] orelse continue; + const y0 = @max(dst.y, v * ch); + const y1 = @min(dst.y + dst.h, (v + 1) * ch); + if (y1 <= y0) continue; + // The source pixels under this piece, by the same scale. + const piece: PixelRect = .{ + .x = src.x + @as(u32, @intCast(@divFloor((x0 - dst.x) * src.w, dst.w))), + .y = src.y + @as(u32, @intCast(@divFloor((y0 - dst.y) * src.h, dst.h))), + .w = @max(1, @as(u32, @intCast(@divFloor((x1 - x0) * src.w, dst.w)))), + .h = @max(1, @as(u32, @intCast(@divFloor((y1 - y0) * src.h, dst.h)))), + }; + const c0: u16 = @intCast(@divFloor(x0, cw)); + const c1: u16 = @intCast(@divFloor(x1 + cw - 1, cw)); + _ = ctx.p.appendImagePlace(.{ + .pane = ctx.pane, + .serial = ctx.serial, + .native = .{ + .page = img.id, + .revision = @truncate(img.generation), + .exact = true, + .geometry = .{ .src = piece, .dst = .{ + .x = @intCast(x0 - @as(i64, c0) * cw), + .y = @intCast(y0 - v * ch), + .w = @intCast(x1 - x0), + .h = @intCast(y1 - y0), + } }, + }, + .x = ctx.tx + c0, + .y = ctx.body_y + row, + .w = c1 - c0, + .h = 1, + .rgba = rgba, + .iw = img.width, + .ih = img.height, + }); + } + } +}; + +/// The image's pixels as RGBA: its own bytes when it is held so. +fn pixelsOf(ti: *TerminalImages, gpa: std.mem.Allocator, img: gfx.Image) ?[]const u8 { + const n = @as(usize, img.width) * img.height; + if (n == 0) return null; + if (img.format == .rgba) return if (img.data.len == n * 4) img.data else null; + const gop = ti.converted.getOrPut(gpa, img.id) catch return null; + if (gop.found_existing) { + if (gop.value_ptr.generation == img.generation) { + gop.value_ptr.used = true; + return gop.value_ptr.rgba; + } + gpa.free(gop.value_ptr.rgba); + _ = ti.converted.remove(img.id); + return ti.pixelsOf(gpa, img); + } + const out = convert(gpa, img, n) orelse { + _ = ti.converted.remove(img.id); + return null; + }; + gop.value_ptr.* = .{ .generation = img.generation, .rgba = out }; + return out; +} + +fn convert(gpa: std.mem.Allocator, img: gfx.Image, n: usize) ?[]u8 { + const depth: usize = switch (img.format) { + .rgb => 3, + .gray_alpha => 2, + .gray => 1, + else => return null, + }; + if (img.data.len != n * depth) return null; + const out = gpa.alloc(u8, n * 4) catch return null; + for (0..n) |i| { + const px = img.data[i * depth ..][0..depth]; + out[i * 4 ..][0..4].* = switch (depth) { + 3 => .{ px[0], px[1], px[2], 255 }, + 2 => .{ px[0], px[0], px[0], px[1] }, + else => .{ px[0], px[0], px[0], 255 }, + }; + } + return out; +} + +/// Drop what this frame did not draw: the caches hold only what is shown. +fn prune(ti: *TerminalImages, gpa: std.mem.Allocator) void { + while (true) { + var conv = ti.converted.iterator(); + const stale = while (conv.next()) |entry| { + if (!entry.value_ptr.used) break entry.key_ptr.*; + } else break; + gpa.free(ti.converted.get(stale).?.rgba); + _ = ti.converted.remove(stale); + } +} diff --git a/src/body_layer.zig b/src/body_layer.zig index 914694e7..b24bd6ed 100644 --- a/src/body_layer.zig +++ b/src/body_layer.zig @@ -306,6 +306,7 @@ pub fn renderBody(p: *Pardes, s: *Surface, arena: std.mem.Allocator, pane: *Pane panes.File.recolorDiffOutput(p, s, pane, r, tx, tw, body_h, body); } tz_color.end(); + if (pane.isTerminal()) panes.terminal.drawImages(p, s, pane, id, tx, tw, body_y, body_h); const sel_btn = buttonTints(th); if (p.look_hover_preview) |preview| { diff --git a/src/gui/gui.zig b/src/gui/gui.zig index ea0e2862..739e5a69 100644 --- a/src/gui/gui.zig +++ b/src/gui/gui.zig @@ -4741,7 +4741,7 @@ fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = shellOf(ctx); if (s.ptys[pane]) |pt| { if (pt.fd < 0) return; - const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const ws = host_io.ptyWinsize(s.core, cols, rows); _ = posix.system.ioctl(pt.fd, TIOCSWINSZ, @intFromPtr(&ws)); } } diff --git a/src/host_io.zig b/src/host_io.zig index 4ebac9ee..b138a840 100644 --- a/src/host_io.zig +++ b/src/host_io.zig @@ -1409,6 +1409,17 @@ fn exportWinid(serial: u32) void { /// The longest line a command pane runs (exec.zig refuses a longer one). pub const exec_command_max = pardes.exec.command_max; +/// A pane's pty size, in cells and in pixels: a program sizes its kitty +/// graphics by the pixels (yazi's previews; pardes nested in a pane). +pub fn ptyWinsize(core: ?*const pardes.Pardes, cols: u16, rows: u16) posix.winsize { + var ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + if (comptime pardes.pdf_enabled) if (core) |c| { + ws.xpixel = std.math.cast(u16, @as(u32, cols) * c.cell_pixels.w) orelse 0; + ws.ypixel = std.math.cast(u16, @as(u32, rows) * c.cell_pixels.h) orelse 0; + }; + return ws; +} + pub fn forkShell( core: ?*pardes.Pardes, pane: usize, @@ -1493,7 +1504,7 @@ pub fn forkShell( .command else if (core) |c| (if (c.settings.pager == .off) .terminal_off else .terminal) else .terminal; const envp = ChildEnv.build(&env_slots, paging); - const ws = posix.winsize{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const ws = ptyWinsize(core, cols, rows); // The child says here why it could not become the shell (its chdir or // its exec failed, as for a script whose interpreter is not there): the // pipe closes on exec, so the parent reads nothing when the shell runs, diff --git a/src/surface.zig b/src/surface.zig index d1ea78b1..a08d6831 100644 --- a/src/surface.zig +++ b/src/surface.zig @@ -240,6 +240,10 @@ pub const NativePlacement = if (pdf_enabled) struct { pixel_offset_y: f32 = 0, /// Separate paper behind straight-alpha PDF content; null for other images. paper_bg: ?[3]u8 = null, + /// `geometry` is the whole of it: `src` drawn into `dst`, scaled as + /// it says. A terminal pane's kitty graphics come this way, one cell + /// row at a time, already clipped (TerminalImages.zig). + exact: bool = false, } else struct {}; pub const ImagePatch = struct { from: u32, y: u32, h: u32 }; 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)); +} diff --git a/src/tty/tty.zig b/src/tty/tty.zig index a01df94b..a067ffce 100644 --- a/src/tty/tty.zig +++ b/src/tty/tty.zig @@ -795,6 +795,63 @@ fn updateCoreTerminalSize(core: *pardes.Pardes, cols: u16, rows: u16, pixel_w: u } }); } +/// A terminal pane's image fragment (`native.exact`): its `src` into its +/// `dst`, inside one cell row. Kitty scales a source rectangle to whole +/// cells, so the source is trimmed to the cells' scale rather than the +/// picture stretched: a fragment loses under a cell at its right edge. +/// A full row is declared one row high; a part of one (an image's top or +/// bottom edge) only its columns, its height then following the source. +fn exactKittyPlacement( + place: pardes.ImagePlace, + screen_cols: u16, + screen_rows: u16, + screen_pixel_w: u16, + screen_pixel_h: u16, +) ?KittyPlacement { + if (comptime !pardes.pdf_enabled) return null; + const g = place.native.geometry orelse return null; + const cell_w = terminalCellPixels(screen_pixel_w, screen_cols, 8); + const cell_h = terminalCellPixels(screen_pixel_h, screen_rows, 16); + if (g.dst.w == 0 or g.dst.h == 0 or g.src.w == 0 or g.src.h == 0) return null; + const cell_x = g.dst.x / cell_w; + const pixel_x = g.dst.x % cell_w; + const pixel_y = @min(g.dst.y, cell_h - 1); + const cols: u32 = @max(1, (g.dst.w + cell_w / 2) / cell_w); + if (cell_x >= place.w) return null; + const declared_w = cols * cell_w; + // At the cells' scale, the source that fills `cols` cells. + var src_w = g.src.w; + if (declared_w < g.dst.w) src_w = @max(1, @as(u32, @intCast(@as(u64, g.src.w) * declared_w / g.dst.w))); + var src_h = g.src.h; + const full = g.dst.y == 0 and g.dst.h >= cell_h; + if (!full) { + // As tall as it is at that scale, and no taller than the row. + const room = cell_h - pixel_y; + const fits: u32 = @intCast(@as(u64, room) * src_w / declared_w); + if (fits == 0) return null; + src_h = @min(src_h, fits); + } + return .{ + .cell_x = std.math.cast(u16, cell_x) orelse return null, + .cell_y = 0, + .cell_cols = std.math.cast(u16, @min(@as(u32, place.w) - cell_x, (pixel_x + declared_w + cell_w - 1) / cell_w)) orelse return null, + .cell_rows = 1, + .options = .{ + .clip_region = .{ + .x = std.math.cast(u16, g.src.x) orelse return null, + .y = std.math.cast(u16, g.src.y) orelse return null, + .width = std.math.cast(u16, src_w) orelse return null, + .height = std.math.cast(u16, src_h) orelse return null, + }, + .pixel_offset = if (pixel_x != 0 or pixel_y != 0) .{ .x = @intCast(pixel_x), .y = @intCast(pixel_y) } else null, + .size = if (full) + .{ .cols = std.math.cast(u16, cols) orelse return null, .rows = 1 } + else + .{ .cols = std.math.cast(u16, cols) orelse return null }, + }, + }; +} + fn kittyPlacement( place: pardes.ImagePlace, screen_cols: u16, @@ -901,6 +958,61 @@ fn kittyPlacement( } } +test "a terminal's image fragment goes to kitty cropped to its cells' scale, a full row one row high" { + if (comptime !pardes.pdf_enabled) return; + var place = pardes.ImagePlace{ + .pane = 0, + .serial = 3, + .native = .{ .page = 7, .exact = true, .geometry = .{ + .src = .{ .x = 0, .y = 40, .w = 240, .h = 20 }, + .dst = .{ .x = 0, .y = 0, .w = 240, .h = 20 }, + } }, + .x = 99, + .y = 4, + .w = 24, + .h = 1, + .rgba = &.{}, + .iw = 240, + .ih = 160, + }; + // 10x20 pixel cells: a whole row, one to one. + const full = exactKittyPlacement(place, 100, 30, 1000, 600).?; + try std.testing.expectEqual(@as(u16, 0), full.cell_x); + try std.testing.expectEqual(@as(?u16, 24), full.options.size.?.cols); + try std.testing.expectEqual(@as(?u16, 1), full.options.size.?.rows); + try std.testing.expectEqual(@as(?u16, 40), full.options.clip_region.?.y); + try std.testing.expectEqual(@as(?u16, 240), full.options.clip_region.?.width); + try std.testing.expect(full.options.pixel_offset == null); + + // The top of a letterboxed picture: 7 pixels down into the row, 13 + // tall, 35 pixels in, 205 wide, which rounds to 21 cells. Its columns + // only, its source no taller than the 13 pixels left in the row at + // that scale. + place.native.geometry = .{ + .src = .{ .x = 0, .y = 0, .w = 205, .h = 13 }, + .dst = .{ .x = 35, .y = 7, .w = 205, .h = 13 }, + }; + const top = exactKittyPlacement(place, 100, 30, 1000, 600).?; + try std.testing.expectEqual(@as(u16, 3), top.cell_x); + try std.testing.expectEqual(@as(u16, 5), top.options.pixel_offset.?.x); + try std.testing.expectEqual(@as(u16, 7), top.options.pixel_offset.?.y); + try std.testing.expectEqual(@as(?u16, 21), top.options.size.?.cols); + try std.testing.expectEqual(@as(?u16, null), top.options.size.?.rows); + const w = top.options.clip_region.?.width.?; + const h = top.options.clip_region.?.height.?; + try std.testing.expectEqual(@as(u16, 205), w); + try std.testing.expect(h > 0 and @as(u32, h) * 210 / w <= 13); + + // Narrower than its cells round to: the source is cut to their scale. + place.native.geometry = .{ + .src = .{ .x = 0, .y = 0, .w = 400, .h = 40 }, + .dst = .{ .x = 0, .y = 0, .w = 204, .h = 20 }, + }; + const cut = exactKittyPlacement(place, 100, 30, 1000, 600).?; + try std.testing.expectEqual(@as(?u16, 20), cut.options.size.?.cols); + try std.testing.expectEqual(@as(?u16, 392), cut.options.clip_region.?.width); +} + test "Kitty PDF fragments never declare pixels beyond their clipped bottom" { if (comptime !pardes.pdf_enabled) return; @@ -1696,7 +1808,19 @@ const Shell = struct { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else { - if (place.native.fit == .contain) { + if (place.native.exact) { + if (exactKittyPlacement(place, vx.screen.width, vx.screen.height, vx.screen.width_pix, vx.screen.height_pix)) |placement| { + // The cell keeps its own glyph and colours + // (Image.draw would reset them): a fragment + // short of the cell shows the pane under it. + const x = place.x + placement.cell_x; + if (win.readCell(x, place.y)) |under| { + var cell = under; + cell.image = .{ .img_id = cached.id, .options = placement.options }; + win.writeCell(x, place.y, cell); + } + } + } else if (place.native.fit == .contain) { const child = win.child(.{ .x_off = place.x, .y_off = place.y, .width = place.w, .height = place.h }); cached.draw(child, .{ .scale = .contain }) catch {}; } else if (kittyPlacement( @@ -1778,7 +1902,7 @@ const Shell = struct { fn ptyResize(ctx: ?*anyopaque, pane: u8, cols: u16, rows: u16) void { const s = of(ctx); if (s.ptys[pane]) |pt| { - const ws: posix.winsize = .{ .row = rows, .col = cols, .xpixel = 0, .ypixel = 0 }; + const ws = host_io.ptyWinsize(s.core, cols, rows); _ = posix.system.ioctl(pt.file.handle, TIOCSWINSZ, @intFromPtr(&ws)); } } |
