diff options
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)); } } |
