summaryrefslogtreecommitdiff
path: root/src/terminal.zig
diff options
context:
space:
mode:
Diffstat (limited to 'src/terminal.zig')
-rw-r--r--src/terminal.zig177
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));
+}