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