From 0bb65902ebec104dac954b59f28500812419e1af Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Thu, 1 Oct 2026 00:45:14 -0300 Subject: A terminal's kitty graphics draw as glyph art where pixels cannot, and by choice: Petscii on the pane, TermImages for new ones Under an outer terminal without kitty graphics a tty pardes cannot pass a terminal pane's images on, so it draws them itself as an image pane's Petscii does: each row of an image is matched to C64-palette glyph art over the cells it covers (GlyphArt.renderRect, the exact-grid half of GlyphArt.render), cached while it is on screen. yazi is still told the kitty protocol works, so it keeps sending its previews. The same art is a choice anywhere: the Petscii builtin now takes a terminal pane too (PaneKind.picture), flipping its images between pixels and glyphs, and a terminal holding images, or drawing them as glyphs by choice, says petscii:on|off in its tag as an image pane does. TermImages real|petscii (default real) is what a new terminal starts with; it is in DumpConfig, /ctl and the reference. Co-Authored-By: Claude Opus 5.5 --- src/TerminalImages.zig | 100 +++++++++++++++++++++++++++++++++++++------------ 1 file changed, 76 insertions(+), 24 deletions(-) (limited to 'src/TerminalImages.zig') diff --git a/src/TerminalImages.zig b/src/TerminalImages.zig index 773297b2..bc1b2ea8 100644 --- a/src/TerminalImages.zig +++ b/src/TerminalImages.zig @@ -2,8 +2,8 @@ //! 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. +//! hands them on: as pixels (`ImagePlace`, exact geometry) to a shell that +//! draws them, else as glyph art in the cells, as an image pane's Petscii. //! 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. @@ -14,17 +14,25 @@ const image = @import("image.zig"); const TerminalImages = @This(); const gfx = ghostty_vt.kitty.graphics; +const GlyphArt = image.GlyphArt; 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; +/// This pane draws its images as glyph art even where pixels could be +/// drawn (the Petscii builtin; TermImages sets it for a new pane). +petscii: bool = false, /// 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, +/// Glyph art for the fragments drawn as cells, kept while on screen. +glyphs: std.AutoHashMapUnmanaged(GlyphKey, Glyphs) = .empty, const Converted = struct { generation: u64, rgba: []u8, used: bool = true }; +const Glyphs = struct { cells: []GlyphArt.Cell, used: bool = true }; +const GlyphKey = struct { id: u32, generation: u64, src: PixelRect, cols: u16 }; /// One image's rectangle in the pane, in pixels from the viewport's top /// left corner (above or left of it is negative). @@ -34,6 +42,9 @@ 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); + var gl = ti.glyphs.valueIterator(); + while (gl.next()) |g| gpa.free(g.cells); + ti.glyphs.deinit(gpa); } /// Accept PNG (`f=100`) as well as raw pixels: ghostty-vt built as a @@ -64,6 +75,8 @@ pub fn draw( ) void { var conv = ti.converted.valueIterator(); while (conv.next()) |c| c.used = false; + var gl = ti.glyphs.valueIterator(); + while (gl.next()) |g| g.used = false; defer ti.prune(p.gpa); const screen = vt.screens.active; @@ -73,7 +86,8 @@ pub fn draw( 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 }; + const pixels = p.native_images and !ti.petscii; + const ctx: Ctx = .{ .ti = ti, .p = p, .s = s, .pane = pane_id, .serial = serial, .tx = tx, .body_y = body_y, .cols = cols, .rows_of = rows_of, .cw = cw, .ch = ch, .pixels = pixels }; // Virtual placements: shown through the placeholder cells, one run of // them on a row at a time. The placeholders themselves are not text. @@ -130,6 +144,7 @@ pub fn draw( const Ctx = struct { ti: *TerminalImages, p: *pardes.Pardes, + s: *pardes.Surface, pane: u8, serial: u32, tx: u16, @@ -138,6 +153,7 @@ const Ctx = struct { rows_of: []const ?u16, cw: u32, ch: u32, + pixels: bool, /// 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. @@ -165,27 +181,37 @@ const Ctx = struct { }; 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, + if (ctx.pixels) { + _ = 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, + }); + continue; + } + // Glyph art fills whole cells: a sliver of a row is left out. + if ((y1 - y0) * 2 < ch) continue; + const cells = ctx.ti.glyphsOf(ctx.p.gpa, img, rgba, piece, c1 - c0) orelse continue; + for (cells, c0..) |*cell, x| ctx.s.set(ctx.tx + @as(u16, @intCast(x)), ctx.body_y + row, cell.glyph[0..cell.glyph_len], .{ + .fg = .{ .rgb = GlyphArt.commodore[cell.fg] }, + .bg = .{ .rgb = GlyphArt.commodore[cell.bg] }, }); } } @@ -234,6 +260,24 @@ fn convert(gpa: std.mem.Allocator, img: gfx.Image, n: usize) ?[]u8 { return out; } +fn glyphsOf(ti: *TerminalImages, gpa: std.mem.Allocator, img: gfx.Image, rgba: []const u8, piece: PixelRect, cols: u16) ?[]const GlyphArt.Cell { + const key: GlyphKey = .{ .id = img.id, .generation = img.generation, .src = piece, .cols = cols }; + if (ti.glyphs.getPtr(key)) |hit| { + hit.used = true; + return hit.cells; + } + const grid = GlyphArt.renderRect(gpa, rgba, img.width, img.height, piece, cols, 1, GlyphArt.commodore, true) catch return null; + if (grid.cells.len != cols) { + gpa.free(grid.cells); + return null; + } + ti.glyphs.put(gpa, key, .{ .cells = grid.cells }) catch { + gpa.free(grid.cells); + return null; + }; + return grid.cells; +} + /// 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) { @@ -244,4 +288,12 @@ fn prune(ti: *TerminalImages, gpa: std.mem.Allocator) void { gpa.free(ti.converted.get(stale).?.rgba); _ = ti.converted.remove(stale); } + while (true) { + var gl = ti.glyphs.iterator(); + const stale = while (gl.next()) |entry| { + if (!entry.value_ptr.used) break entry.key_ptr.*; + } else break; + gpa.free(ti.glyphs.get(stale).?.cells); + _ = ti.glyphs.remove(stale); + } } -- cgit v1.3