diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-07 18:29:21 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-05-07 18:29:21 +0300 |
| commit | dcfe11a7588fcd42471159453810abc9b7720a57 (patch) | |
| tree | c32a77266028e73ca2382b96c96f8438a5fcd621 /vendor/libghostty-vt/src/terminal | |
| parent | 2dfed860103c4e4505ac7f25def9fe37da1e1e6a (diff) | |
build: update libghostty-vt vendor
vendor ghostty 063ac3e and remove the musl libc++ link workaround.
expose the new selection formatter and hyperlink uri apis for follow-up rendering work.
add manual artifact validation for static linux builds.
Diffstat (limited to 'vendor/libghostty-vt/src/terminal')
28 files changed, 3735 insertions, 196 deletions
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig index 39873374..17eea73d 100644 --- a/vendor/libghostty-vt/src/terminal/PageList.zig +++ b/vendor/libghostty-vt/src/terminal/PageList.zig @@ -1137,6 +1137,17 @@ fn resizeCols( for (total..self.rows) |_| _ = try self.grow(); } + // Reflow can unwrap enough rows that a history viewport pin lands in the + // active area before we do any preserved-cursor growth below. Switch back + // to the active viewport now so intermediate grow() integrity checks stay + // valid. + switch (self.viewport) { + .active, .top => {}, + .pin => if (self.pinIsActive(self.viewport_pin.*)) { + self.viewport = .active; + }, + } + // See preserved_cursor setup for why. if (preserved_cursor) |c| cursor: { const active_pt = self.pointFromPin( diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index 77e05b09..39fdd610 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -255,7 +255,16 @@ pub const Options = struct { /// The total storage limit for Kitty images in bytes for this /// screen. Kitty image storage is per-screen. - kitty_image_storage_limit: usize = 320 * 1000 * 1000, // 320MB + kitty_image_storage_limit: usize = switch (build_options.artifact) { + .ghostty => 320 * 1000 * 1000, // 320MB + .lib => 10 * 1000 * 1000, // 10MB + }, + + /// The limits for what medium types are allowed for Kitty image loading. + kitty_image_loading_limits: if (build_options.kitty_graphics) + kitty.graphics.LoadingImage.Limits + else + void = if (build_options.kitty_graphics) .direct else {}, /// A simple, default terminal. If you rely on specific dimensions or /// scrollback (or lack of) then do not use this directly. This is just @@ -313,6 +322,7 @@ pub fn init( &result, opts.kitty_image_storage_limit, ) catch unreachable; + result.kitty_images.image_limits = opts.kitty_image_loading_limits; } return result; @@ -6487,6 +6497,86 @@ test "Screen: resize more cols with populated scrollback" { } } +test "Screen: resize more cols bounded scrollback keeps viewport valid" { + // Regression test for issue #12298. + // + // This needs to live at the Screen layer rather than PageList because the + // bad state only appears once Screen forwards the active cursor into the + // resize path. A direct PageList resize repro does not hit the same bug. + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, .{ + .cols = 2, + .rows = 10, + .max_scrollback = 10_000, + }); + defer s.deinit(); + + // Build 30 rows of scrollback on top of our 10-row viewport so we have a + // 40-row screen with history above the active area. + for (0..30) |_| _ = try s.pages.grow(); + s.cursorReload(); + try testing.expectEqual(@as(usize, 40), s.pages.scrollbar().total); + + // Fill the entire screen with two-row wrapped runs: + // - even rows mark the end of a wrapped line + // - odd rows mark the continuation + // + // With 2 columns, each logical line occupies two rows. When we grow to 4 + // columns with reflow enabled, those pairs unwrap back into single rows. + // That cuts the total row count down and is what stresses the viewport pin. + var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null); + while (it.next()) |chunk| { + const page = &chunk.node.data; + for (chunk.start..chunk.end) |y| { + const rac = page.getRowAndCell(0, y); + if (y % 2 == 0) { + rac.row.wrap = true; + } else { + rac.row.wrap_continuation = true; + } + + for (0..s.pages.cols) |x| { + page.getRowAndCell(x, y).cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'A' }, + }; + } + } + } + + // Pin the viewport to a history row just above the active area. + // + // Before resize: + // - total rows = 40 + // - active area starts at row 30 + // - viewport is pinned at row 28 + // + // After unwrap during resize: + // - total rows shrinks to 20 + // - the old row 28 remaps into what is now the active area + // + // The bug was that resize/grow would temporarily keep the viewport as a + // history pin even after reflow had moved it into the active area, leaving + // fewer than `rows` visible rows beneath the pin and tripping integrity + // checks. + s.pages.scroll(.{ .pin = s.pages.pin(.{ .screen = .{ .y = 28 } }).? }); + try testing.expect(s.pages.viewport == .pin); + try testing.expect(s.pages.getBottomRight(.viewport) != null); + + // Growing columns triggers reflow, which unwraps the synthetic wrapped + // rows above. This used to panic during the resize path. + try s.resize(.{ .cols = 4, .rows = s.pages.rows, .reflow = true }); + + // After the fix, the viewport is normalized back to the active area as + // soon as the pinned row lands there, so viewport queries remain valid. + try testing.expectEqual(@as(usize, 4), s.pages.cols); + try testing.expect(s.pages.scrollbar().total < 40); + try testing.expect(s.pages.viewport == .active); + try testing.expect(s.pages.getBottomRight(.viewport) != null); +} + test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig index 99536e7a..6740a93e 100644 --- a/vendor/libghostty-vt/src/terminal/Terminal.zig +++ b/vendor/libghostty-vt/src/terminal/Terminal.zig @@ -191,6 +191,26 @@ pub const Options = struct { /// The default mode state. When the terminal gets a reset, it /// will revert back to this state. default_modes: modespkg.ModePacked = .{}, + + /// The total storage limit for Kitty images in bytes. Has no effect + /// if kitty images are disabled at build-time. + kitty_image_storage_limit: usize = switch (build_options.artifact) { + .ghostty => 320 * 1000 * 1000, // 320MB + + // libghostty we start with a much lower limit since this is an + // embedded library and we want to be more conservative with memory + // usage by default. + .lib => 10 * 1000 * 1000, // 10MB + }, + + /// The limits for what medium types are allowed for Kitty image loading. + /// Has no effect if kitty images are disabled otherwise. For example, + // if no `sys.decode_png` hook is specified, png formats are disabled + // no matter what. + kitty_image_loading_limits: if (build_options.kitty_graphics) + kitty.graphics.LoadingImage.Limits + else + void = if (build_options.kitty_graphics) .direct else {}, }; /// Initialize a new terminal. @@ -205,6 +225,8 @@ pub fn init( .cols = cols, .rows = rows, .max_scrollback = opts.max_scrollback, + .kitty_image_storage_limit = opts.kitty_image_storage_limit, + .kitty_image_loading_limits = opts.kitty_image_loading_limits, }); errdefer screen_set.deinit(alloc); @@ -551,20 +573,25 @@ pub fn print(self: *Terminal, c: u21) !void { // it. if (self.modes.get(.grapheme_cluster)) return; - // If we're at cell zero, then this is malformed data and we don't - // print anything or even store this. Zero-width characters are ALWAYS - // attached to some other non-zero-width character at the time of - // writing. - if (self.screens.active.cursor.x == 0) { + // If we have wraparound enabled and a pending wrap, the character + // we're attaching to is still under the cursor. Otherwise, it's the + // cell to the left. + const left: size.CellCountInt = if (self.modes.get(.wraparound) and self.screens.active.cursor.pending_wrap) 0 else 1; + + // If we're at cell zero and not pending a wrap, then this is malformed + // data and we don't print anything or even store this. Zero-width + // characters are ALWAYS attached to some other non-zero-width + // character at the time of writing. + if (self.screens.active.cursor.x == 0 and left == 1) { log.warn("zero-width character with no prior character, ignoring", .{}); return; } // Find our previous cell const prev = prev: { - const immediate = self.screens.active.cursorCellLeft(1); + const immediate = self.screens.active.cursorCellLeft(left); if (immediate.wide != .spacer_tail) break :prev immediate; - break :prev self.screens.active.cursorCellLeft(2); + break :prev self.screens.active.cursorCellLeft(left + 1); }; // If our previous cell has no text, just ignore the zero-width character @@ -2693,6 +2720,34 @@ pub fn kittyGraphics( return kitty.graphics.execute(alloc, self, cmd); } +/// Set the storage size limit for Kitty graphics across all screens. +pub fn setKittyGraphicsSizeLimit( + self: *Terminal, + alloc: Allocator, + limit: usize, +) !void { + if (comptime !build_options.kitty_graphics) return; + var it = self.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *Screen = entry.value.*; + try screen.kitty_images.setLimit(alloc, screen, limit); + } +} + +/// Set the allowed medium types for Kitty graphics image loading +/// across all screens. +pub fn setKittyGraphicsLoadingLimits( + self: *Terminal, + limits: kitty.graphics.LoadingImage.Limits, +) void { + if (comptime !build_options.kitty_graphics) return; + var it = self.screens.all.iterator(); + while (it.next()) |entry| { + const screen: *Screen = entry.value.*; + screen.kitty_images.image_limits = limits; + } +} + /// Set a style attribute. pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { try self.screens.active.setAttribute(attr); @@ -2941,12 +2996,15 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { .alternate => 0, }, - // Inherit our Kitty image storage limit from the primary + // Inherit our Kitty image settings from the primary // screen if we have to initialize. .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) primary.kitty_images.total_limit else 0, + .kitty_image_loading_limits = if (comptime build_options.kitty_graphics) + primary.kitty_images.image_limits + else {}, }, ); }; @@ -3260,6 +3318,23 @@ test "Terminal: zero-width character at start" { try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); } +// https://github.com/ghostty-org/ghostty/issues/12581 +test "Terminal: zero-width character attaches to pending wrap cell" { + var t = try init(testing.allocator, .{ .cols = 2, .rows = 2 }); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering to exercise the fallback path. + t.modes.set(.grapheme_cluster, false); + + try t.print('x'); + try t.print('å'); + try t.print(0x0332); // Combining low line. + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("xå̲", str); +} + // https://github.com/mitchellh/ghostty/issues/1400 test "Terminal: print single very long line" { var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); diff --git a/vendor/libghostty-vt/src/terminal/apc.zig b/vendor/libghostty-vt/src/terminal/apc.zig index 3ebacbbf..7e6f08a7 100644 --- a/vendor/libghostty-vt/src/terminal/apc.zig +++ b/vendor/libghostty-vt/src/terminal/apc.zig @@ -10,7 +10,15 @@ const log = std.log.scoped(.terminal_apc); /// The start/feed/end functions are meant to be called from the terminal.Stream /// apcStart, apcPut, and apcEnd functions, respectively. pub const Handler = struct { - state: State = .{ .inactive = {} }, + state: State = .inactive, + + /// Maximum bytes each APC protocol can buffer. This is to prevent + /// malicious input from causing us to allocate too much memory. + /// If you want to be lazy and set a single value for all protocols, + /// use `.initFull`. + max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{ + .kitty = Protocol.defaultMaxBytes(.kitty), + }), pub fn deinit(self: *Handler) void { self.state.deinit(); @@ -34,19 +42,24 @@ pub const Handler = struct { switch (byte) { // Kitty graphics protocol 'G' => self.state = if (comptime build_options.kitty_graphics) - .{ .kitty = kitty_gfx.CommandParser.init(alloc) } + .{ .kitty = .init( + alloc, + self.max_bytes.get(.kitty) orelse + Protocol.defaultMaxBytes(.kitty), + ) } else - .{ .ignore = {} }, + .ignore, // Unknown - else => self.state = .{ .ignore = {} }, + else => self.state = .ignore, } }, .kitty => |*p| if (comptime build_options.kitty_graphics) { p.feed(byte) catch |err| { log.warn("kitty graphics protocol error: {}", .{err}); - self.state = .{ .ignore = {} }; + p.deinit(); + self.state = .ignore; }; } else unreachable, } @@ -55,7 +68,7 @@ pub const Handler = struct { pub fn end(self: *Handler) ?Command { defer { self.state.deinit(); - self.state = .{ .inactive = {} }; + self.state = .inactive; } return switch (self.state) { @@ -106,8 +119,22 @@ pub const State = union(enum) { } }; +/// Possible APC command types. +pub const Protocol = enum { + kitty, + + /// Returns the default maximum bytes for the given protocol. + pub fn defaultMaxBytes(self: Protocol) usize { + return switch (self) { + // Kitty graphics payloads can be very large (e.g. full images + // encoded as base64), so the default is set to 65 MiB. + .kitty => 65 * 1024 * 1024, + }; + } +}; + /// Possible APC commands. -pub const Command = union(enum) { +pub const Command = union(Protocol) { kitty: if (build_options.kitty_graphics) kitty_gfx.Command else @@ -169,6 +196,41 @@ test "Kitty command with overflow i32" { try testing.expect(h.end() == null); } +test "kitty feed error deinits parser" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + // Feed a valid kitty command start to allocate parser state, then + // trigger an error during feed via an integer overflow. The testing + // allocator will detect leaks if deinit is not called. + var h: Handler = .{}; + defer h.deinit(); + h.start(); + for ("Ga=p,i=10000000000;") |c| h.feed(alloc, c); + try testing.expect(h.state == .ignore); +} + +test "kitty max bytes exceeded" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const testing = std.testing; + const alloc = testing.allocator; + + var h: Handler = .{ .max_bytes = .init(.{ .kitty = 4 }) }; + defer h.deinit(); + h.start(); + // 'G' identifies kitty, 'a=t;' moves to data state, then feed exceeds max_bytes. + for ("Ga=t;") |c| h.feed(alloc, c); + try testing.expect(h.state != .ignore); + for ("abcd") |c| h.feed(alloc, c); + try testing.expect(h.state != .ignore); + // The 5th data byte exceeds the 4-byte limit. + h.feed(alloc, 'e'); + try testing.expect(h.state == .ignore); +} + test "valid Kitty command" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; diff --git a/vendor/libghostty-vt/src/terminal/build_options.zig b/vendor/libghostty-vt/src/terminal/build_options.zig index 6c0a4df6..136e0f10 100644 --- a/vendor/libghostty-vt/src/terminal/build_options.zig +++ b/vendor/libghostty-vt/src/terminal/build_options.zig @@ -2,6 +2,14 @@ const std = @import("std"); /// Options set by Zig build.zig and exposed via `terminal_options`. pub const Options = struct { + pub const Artifact = enum { + /// Ghostty application + ghostty, + + /// libghostty-vt, Zig module + lib, + }; + /// The target artifact to build. This will gate some functionality. artifact: Artifact, @@ -47,34 +55,40 @@ pub const Options = struct { opts.addOption(bool, "simd", self.simd); opts.addOption(bool, "slow_runtime_safety", self.slow_runtime_safety); + // Kitty graphics is almost always true. This used to be conditional on + // some other factors but we've since generalized the implementation + // to support optional PNG decoding, OS capabilities like filesystems, + // etc. So its safe to always enable it and just have the + // implementation deal with unsupported features as needed. + // + // We disable it on wasm32-freestanding because we at the least + // require the ability to get timestamps and there is no way to + // do that with freestanding targets. + const target = m.resolved_target.?.result; + opts.addOption( + bool, + "kitty_graphics", + !(target.cpu.arch == .wasm32 and target.os.tag == .freestanding), + ); + // These are synthesized based on other options. - opts.addOption(bool, "kitty_graphics", self.oniguruma); opts.addOption(bool, "tmux_control_mode", self.oniguruma); // Version information. - var buf: [1024]u8 = undefined; opts.addOption( []const u8, "version_string", - std.fmt.bufPrint( - &buf, + b.fmt( "{f}", .{self.version}, - ) catch @panic("version string too long"), + ), ); opts.addOption(usize, "version_major", self.version.major); opts.addOption(usize, "version_minor", self.version.minor); opts.addOption(usize, "version_patch", self.version.patch); + opts.addOption(?[]const u8, "version_pre", self.version.pre); opts.addOption(?[]const u8, "version_build", self.version.build); m.addOptions("terminal_options", opts); } }; - -pub const Artifact = enum { - /// Ghostty application - ghostty, - - /// libghostty-vt, Zig module - lib, -}; diff --git a/vendor/libghostty-vt/src/terminal/c/AGENTS.md b/vendor/libghostty-vt/src/terminal/c/AGENTS.md index 63f7fc6c..c7e9068a 100644 --- a/vendor/libghostty-vt/src/terminal/c/AGENTS.md +++ b/vendor/libghostty-vt/src/terminal/c/AGENTS.md @@ -5,7 +5,12 @@ via `lib.TaggedUnion`. - Any functions must be updated all the way through from here to `src/terminal/c/main.zig` to `src/lib_vt.zig` and the headers - in `include/ghostty/vt.h`. + in `include/ghostty/vt.h`. Specifically: + 1. Define the function in `src/terminal/c/<module>.zig`. + 2. Re-export it via a `pub const` in `src/terminal/c/main.zig`. + 3. Add an `@export` call in `src/lib_vt.zig` with the + `ghostty_` prefixed symbol name. + 4. Declare it in the corresponding header under `include/ghostty/vt/`. - In `include/ghostty/vt.h`, always sort the header contents by: (1) macros, (2) forward declarations, (3) types, (4) functions diff --git a/vendor/libghostty-vt/src/terminal/c/build_info.zig b/vendor/libghostty-vt/src/terminal/c/build_info.zig index bef6760f..8e0cd4d6 100644 --- a/vendor/libghostty-vt/src/terminal/c/build_info.zig +++ b/vendor/libghostty-vt/src/terminal/c/build_info.zig @@ -25,7 +25,8 @@ pub const BuildInfo = enum(c_int) { version_major = 6, version_minor = 7, version_patch = 8, - version_build = 9, + version_pre = 9, + version_build = 10, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: BuildInfo) type { @@ -33,7 +34,7 @@ pub const BuildInfo = enum(c_int) { .invalid => void, .simd, .kitty_graphics, .tmux_control_mode => bool, .optimize => OptimizeMode, - .version_string, .version_build => lib.String, + .version_string, .version_pre, .version_build => lib.String, .version_major, .version_minor, .version_patch => usize, }; } @@ -78,6 +79,13 @@ fn getTyped( .version_major => out.* = build_options.version_major, .version_minor => out.* = build_options.version_minor, .version_patch => out.* = build_options.version_patch, + .version_pre => { + if (build_options.version_pre) |b| { + out.* = .{ .ptr = b.ptr, .len = b.len }; + } else { + out.* = .{ .ptr = "", .len = 0 }; + } + }, .version_build => { if (build_options.version_build) |b| { out.* = .{ .ptr = b.ptr, .len = b.len }; @@ -151,6 +159,12 @@ test "get version_patch" { try testing.expectEqual(build_options.version_patch, value); } +test "get version_pre" { + const testing = std.testing; + var value: lib.String = undefined; + try testing.expectEqual(Result.success, get(.version_pre, @ptrCast(&value))); +} + test "get version_build" { const testing = std.testing; var value: lib.String = undefined; diff --git a/vendor/libghostty-vt/src/terminal/c/cell.zig b/vendor/libghostty-vt/src/terminal/c/cell.zig index c9746da7..b59567df 100644 --- a/vendor/libghostty-vt/src/terminal/c/cell.zig +++ b/vendor/libghostty-vt/src/terminal/c/cell.zig @@ -120,6 +120,27 @@ pub fn get( }; } +pub fn get_multi( + cell_: CCell, + count: usize, + keys: ?[*]const CellData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(cell_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + fn getTyped( cell_: CCell, comptime data: CellData, @@ -176,3 +197,42 @@ test "get wide" { try testing.expectEqual(Result.success, get(cell, .wide, @ptrCast(&w))); try testing.expectEqual(Wide.wide, w); } + +test "get_multi success" { + const cell: CCell = @bitCast(Cell.init('B')); + var cp: u32 = 0; + var has_text: bool = false; + var written: usize = 0; + + const keys = [_]CellData{ .codepoint, .has_text }; + var values = [_]?*anyopaque{ @ptrCast(&cp), @ptrCast(&has_text) }; + try testing.expectEqual(Result.success, get_multi(cell, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(@as(u32, 'B'), cp); + try testing.expect(has_text); +} + +test "get_multi error sets out_written" { + const cell: CCell = @bitCast(Cell.init('C')); + var cp: u32 = 0; + var written: usize = 99; + + const keys = [_]CellData{ .codepoint, .invalid }; + var values = [_]?*anyopaque{ @ptrCast(&cp), @ptrCast(&cp) }; + try testing.expectEqual(Result.invalid_value, get_multi(cell, keys.len, &keys, &values, &written)); + try testing.expectEqual(1, written); + try testing.expectEqual(@as(u32, 'C'), cp); +} + +test "get_multi null keys returns invalid_value" { + const cell: CCell = @bitCast(Cell.init('A')); + var cp: u32 = 0; + var values = [_]?*anyopaque{@ptrCast(&cp)}; + try testing.expectEqual(Result.invalid_value, get_multi(cell, 1, null, &values, null)); +} + +test "get_multi null values returns invalid_value" { + const cell: CCell = @bitCast(Cell.init('A')); + const keys = [_]CellData{.codepoint}; + try testing.expectEqual(Result.invalid_value, get_multi(cell, 1, &keys, null, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/formatter.zig b/vendor/libghostty-vt/src/terminal/c/formatter.zig index 11717bc2..5b4504c8 100644 --- a/vendor/libghostty-vt/src/terminal/c/formatter.zig +++ b/vendor/libghostty-vt/src/terminal/c/formatter.zig @@ -3,6 +3,8 @@ const testing = std.testing; const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const terminal_c = @import("terminal.zig"); +const grid_ref = @import("grid_ref.zig"); +const selection_c = @import("selection.zig"); const ZigTerminal = @import("../Terminal.zig"); const formatterpkg = @import("../formatter.zig"); const Result = @import("result.zig").Result; @@ -23,6 +25,8 @@ pub const Formatter = ?*FormatterWrapper; /// C: GhosttyFormatterFormat pub const Format = formatterpkg.Format; +const CSelection = selection_c.CSelection; + /// C: GhosttyFormatterScreenOptions pub const ScreenOptions = extern struct { /// C: GhosttyFormatterScreenExtra @@ -63,6 +67,10 @@ pub const TerminalOptions = extern struct { trim: bool, extra: Extra, + /// Optional selection to restrict output to a range. + /// If null, the entire screen is formatted. + selection: ?*const CSelection = null, + /// C: GhosttyFormatterTerminalExtra pub const Extra = extern struct { size: usize = @sizeOf(Extra), @@ -138,6 +146,12 @@ fn terminal_new_( }); formatter.extra = opts.extra.toZig(); + // Setup the content that we're formatting + if (opts.selection) |sel| formatter.content = .{ + .selection = sel.toZig() orelse + return error.InvalidValue, + }; + ptr.* = .{ .kind = .{ .terminal = formatter }, .alloc = alloc, @@ -389,6 +403,50 @@ test "format vt" { try testing.expect(std.mem.indexOf(u8, buf[0..written], "Test") != null); } +test "format plain with selection" { + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(t); + + terminal_c.vt_write(t, "Hello World", 11); + + // Get grid refs for "World" (columns 6..10 on row 0) + var start_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 6, .y = 0 } }, + }, &start_ref)); + + var end_ref: grid_ref.CGridRef = .{}; + try testing.expectEqual(Result.success, terminal_c.grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 10, .y = 0 } }, + }, &end_ref)); + + const sel: selection_c.CSelection = .{ + .start = start_ref, + .end = end_ref, + }; + + var f: Formatter = null; + try testing.expectEqual(Result.success, terminal_new( + &lib.alloc.test_allocator, + &f, + t, + .{ .emit = .plain, .unwrap = false, .trim = true, .selection = &sel, .extra = .{ .palette = false, .modes = false, .scrolling_region = false, .tabstops = false, .pwd = false, .keyboard = false, .screen = .{ .cursor = false, .style = false, .hyperlink = false, .protection = false, .kitty_keyboard = false, .charsets = false } } }, + )); + defer free(f); + + var buf: [1024]u8 = undefined; + var written: usize = 0; + try testing.expectEqual(Result.success, format_buf(f, &buf, buf.len, &written)); + try testing.expectEqualStrings("World", buf[0..written]); +} + test "format html" { var t: terminal_c.Terminal = null; try testing.expectEqual(Result.success, terminal_c.new( diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig index d029c595..db7ff2e8 100644 --- a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig +++ b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig @@ -3,11 +3,13 @@ const testing = std.testing; const lib = @import("../lib.zig"); const page = @import("../page.zig"); const PageList = @import("../PageList.zig"); +const point = @import("../point.zig"); const size = @import("../size.zig"); const stylepkg = @import("../style.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const style_c = @import("style.zig"); +const terminal_c = @import("terminal.zig"); const Result = @import("result.zig").Result; /// C: GhosttyGridRef @@ -29,7 +31,7 @@ pub const CGridRef = extern struct { }; } - fn toPin(self: CGridRef) ?PageList.Pin { + pub fn toPin(self: CGridRef) ?PageList.Pin { return .{ .node = self.node orelse return null, .x = self.x, @@ -89,6 +91,38 @@ pub fn grid_ref_graphemes( return .success; } +pub fn grid_ref_hyperlink_uri( + ref: *const CGridRef, + out_buf: ?[*]u8, + buf_len: usize, + out_len: *usize, +) callconv(lib.calling_conv) Result { + const p = ref.toPin() orelse return .invalid_value; + const rac = p.node.data.getRowAndCell(p.x, p.y); + const cell = rac.cell; + + if (!cell.hyperlink) { + out_len.* = 0; + return .success; + } + + const link_id = p.node.data.lookupHyperlink(cell) orelse { + out_len.* = 0; + return .success; + }; + const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id); + const uri = entry.uri.slice(p.node.data.memory); + + if (out_buf == null or buf_len < uri.len) { + out_len.* = uri.len; + return .out_of_space; + } + + @memcpy(out_buf.?[0..uri.len], uri); + out_len.* = uri.len; + return .success; +} + pub fn grid_ref_style( ref: *const CGridRef, out: ?*style_c.Style, @@ -154,3 +188,63 @@ test "grid_ref_style null out" { const ref = CGridRef{}; try testing.expectEqual(Result.invalid_value, grid_ref_style(&ref, null)); } + +test "grid_ref_hyperlink_uri null node" { + const ref = CGridRef{}; + var len: usize = undefined; + try testing.expectEqual(Result.invalid_value, grid_ref_hyperlink_uri(&ref, null, 0, &len)); +} + +test "grid_ref_hyperlink_uri no hyperlink" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "hello", 5); + + var ref: CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + + var len: usize = undefined; + try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, null, 0, &len)); + try testing.expectEqual(@as(usize, 0), len); +} + +test "grid_ref_hyperlink_uri with hyperlink" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + // Write OSC 8 hyperlink: \e]8;;uri\e\\text\e]8;;\e\\ + const seq = "\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"; + terminal_c.vt_write(terminal, seq, seq.len); + + var ref: CGridRef = undefined; + try testing.expectEqual(Result.success, terminal_c.grid_ref( + terminal, + point.Point.cval(.{ .active = .{ .x = 0, .y = 0 } }), + &ref, + )); + + // First query length with null buf + var len: usize = undefined; + try testing.expectEqual(Result.out_of_space, grid_ref_hyperlink_uri(&ref, null, 0, &len)); + try testing.expectEqual(@as(usize, 19), len); // "https://example.com" + + // Now read with a properly sized buffer + var buf: [256]u8 = undefined; + try testing.expectEqual(Result.success, grid_ref_hyperlink_uri(&ref, &buf, buf.len, &len)); + try testing.expectEqualStrings("https://example.com", buf[0..len]); +} diff --git a/vendor/libghostty-vt/src/terminal/c/key_encode.zig b/vendor/libghostty-vt/src/terminal/c/key_encode.zig index 15fa74dd..f5d459f0 100644 --- a/vendor/libghostty-vt/src/terminal/c/key_encode.zig +++ b/vendor/libghostty-vt/src/terminal/c/key_encode.zig @@ -52,6 +52,11 @@ pub const Option = enum(c_int) { modify_other_keys_state_2 = 4, kitty_flags = 5, macos_option_as_alt = 6, + /// DEC Backarrow Key Mode (DECBKM) + /// See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170 + /// If `false` (the default), `backspace` emits 0x7f + /// If `true`, `backspace` emits 0x08 + backarrow_key_mode = 7, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -61,6 +66,7 @@ pub const Option = enum(c_int) { .ignore_keypad_with_numlock, .alt_esc_prefix, .modify_other_keys_state_2, + .backarrow_key_mode, => bool, .kitty_flags => u8, .macos_option_as_alt => OptionAsAlt, @@ -114,6 +120,7 @@ fn setoptTyped( } opts.macos_option_as_alt = value.*; }, + .backarrow_key_mode => opts.backarrow_key_mode = value.*, } } diff --git a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig new file mode 100644 index 00000000..d50b8c4c --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig @@ -0,0 +1,1710 @@ +const std = @import("std"); +const testing = std.testing; +const build_options = @import("terminal_options"); +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const kitty_storage = @import("../kitty/graphics_storage.zig"); +const kitty_cmd = @import("../kitty/graphics_command.zig"); +const Image = @import("../kitty/graphics_image.zig").Image; +const grid_ref = @import("grid_ref.zig"); +const selection_c = @import("selection.zig"); +const terminal_c = @import("terminal.zig"); +const Terminal = @import("../Terminal.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttyKittyGraphics +pub const KittyGraphics = if (build_options.kitty_graphics) + *kitty_storage.ImageStorage +else + *anyopaque; + +/// C: GhosttyKittyGraphicsImage +pub const ImageHandle = if (build_options.kitty_graphics) + ?*const Image +else + ?*const anyopaque; + +/// C: GhosttyKittyGraphicsPlacementIterator +pub const PlacementIterator = if (build_options.kitty_graphics) + ?*PlacementIteratorWrapper +else + ?*anyopaque; + +const PlacementMap = if (build_options.kitty_graphics) + std.AutoHashMapUnmanaged( + kitty_storage.ImageStorage.PlacementKey, + kitty_storage.ImageStorage.Placement, + ) +else + void; + +const PlacementIteratorWrapper = if (build_options.kitty_graphics) + struct { + alloc: std.mem.Allocator, + inner: PlacementMap.Iterator = undefined, + entry: ?PlacementMap.Entry = null, + layer_filter: PlacementLayer = .all, + } +else + void; + +/// C: GhosttyKittyGraphicsData +pub const Data = enum(c_int) { + invalid = 0, + placement_iterator = 1, + + pub fn OutType(comptime self: Data) type { + return switch (self) { + .invalid => void, + .placement_iterator => PlacementIterator, + }; + } +}; + +/// C: GhosttyKittyGraphicsPlacementData +pub const PlacementData = enum(c_int) { + invalid = 0, + image_id = 1, + placement_id = 2, + is_virtual = 3, + x_offset = 4, + y_offset = 5, + source_x = 6, + source_y = 7, + source_width = 8, + source_height = 9, + columns = 10, + rows = 11, + z = 12, + + pub fn OutType(comptime self: PlacementData) type { + return switch (self) { + .invalid => void, + .image_id, .placement_id => u32, + .is_virtual => bool, + .x_offset, + .y_offset, + .source_x, + .source_y, + .source_width, + .source_height, + .columns, + .rows, + => u32, + .z => i32, + }; + } +}; + +pub fn get( + graphics_: KittyGraphics, + data: Data, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| getTyped( + graphics_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +fn getTyped( + graphics_: KittyGraphics, + comptime data: Data, + out: *data.OutType(), +) Result { + const storage = graphics_; + switch (data) { + .invalid => return .invalid_value, + .placement_iterator => { + const it = out.* orelse return .invalid_value; + it.* = .{ + .alloc = it.alloc, + .inner = storage.placements.iterator(), + .layer_filter = it.layer_filter, + }; + }, + } + return .success; +} + +/// C: GhosttyKittyPlacementLayer +pub const PlacementLayer = enum(c_int) { + all = 0, + below_bg = 1, + below_text = 2, + above_text = 3, + + fn matches(self: PlacementLayer, z: i32) bool { + return switch (self) { + .all => true, + .below_bg => z < std.math.minInt(i32) / 2, + .below_text => z >= std.math.minInt(i32) / 2 and z < 0, + .above_text => z >= 0, + }; + } +}; + +/// C: GhosttyKittyGraphicsPlacementIteratorOption +pub const PlacementIteratorOption = enum(c_int) { + layer = 0, + + pub fn InType(comptime self: PlacementIteratorOption) type { + return switch (self) { + .layer => PlacementLayer, + }; + } +}; + +/// C: GhosttyKittyImageFormat +pub const ImageFormat = kitty_cmd.Transmission.Format; + +/// C: GhosttyKittyImageCompression +pub const ImageCompression = kitty_cmd.Transmission.Compression; + +/// C: GhosttyKittyGraphicsImageData +pub const ImageData = enum(c_int) { + invalid = 0, + id = 1, + number = 2, + width = 3, + height = 4, + format = 5, + compression = 6, + data_ptr = 7, + data_len = 8, + + pub fn OutType(comptime self: ImageData) type { + return switch (self) { + .invalid => void, + .id, .number, .width, .height => u32, + .format => ImageFormat, + .compression => ImageCompression, + .data_ptr => [*]const u8, + .data_len => usize, + }; + } +}; + +pub fn image_get_handle( + graphics_: KittyGraphics, + image_id: u32, +) callconv(lib.calling_conv) ImageHandle { + if (comptime !build_options.kitty_graphics) return null; + + const storage = graphics_; + return storage.images.getPtr(image_id); +} + +pub fn image_get( + image_: ImageHandle, + data: ImageData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| imageGetTyped( + image_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +pub fn image_get_multi( + image_: ImageHandle, + count: usize, + keys: ?[*]const ImageData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = image_get(image_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + +fn imageGetTyped( + image_: ImageHandle, + comptime data: ImageData, + out: *data.OutType(), +) Result { + const image = image_ orelse return .invalid_value; + + switch (data) { + .invalid => return .invalid_value, + .id => out.* = image.id, + .number => out.* = image.number, + .width => out.* = image.width, + .height => out.* = image.height, + .format => out.* = image.format, + .compression => out.* = image.compression, + .data_ptr => out.* = image.data.ptr, + .data_len => out.* = image.data.len, + } + + return .success; +} + +pub fn placement_iterator_new( + alloc_: ?*const CAllocator, + out: *PlacementIterator, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) { + out.* = null; + return .no_value; + } + const alloc = lib.alloc.default(alloc_); + const ptr = alloc.create(PlacementIteratorWrapper) catch { + out.* = null; + return .out_of_memory; + }; + ptr.* = .{ .alloc = alloc }; + out.* = ptr; + return .success; +} + +pub fn placement_iterator_free(iter_: PlacementIterator) callconv(lib.calling_conv) void { + if (comptime !build_options.kitty_graphics) return; + const iter = iter_ orelse return; + iter.alloc.destroy(iter); +} + +pub fn placement_iterator_set( + iter_: PlacementIterator, + option: PlacementIteratorOption, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(PlacementIteratorOption, @intFromEnum(option)) catch { + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| placementIteratorSetTyped( + iter_, + comptime_option, + @ptrCast(@alignCast(value orelse return .invalid_value)), + ), + }; +} + +fn placementIteratorSetTyped( + iter_: PlacementIterator, + comptime option: PlacementIteratorOption, + value: *const option.InType(), +) Result { + const iter = iter_ orelse return .invalid_value; + switch (option) { + .layer => iter.layer_filter = value.*, + } + return .success; +} + +pub fn placement_iterator_next(iter_: PlacementIterator) callconv(lib.calling_conv) bool { + if (comptime !build_options.kitty_graphics) return false; + + const iter = iter_ orelse return false; + while (iter.inner.next()) |entry| { + if (iter.layer_filter.matches(entry.value_ptr.z)) { + iter.entry = entry; + return true; + } + } + return false; +} + +pub fn placement_get( + iter_: PlacementIterator, + data: PlacementData, + out: ?*anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + return switch (data) { + .invalid => .invalid_value, + inline else => |comptime_data| placementGetTyped( + iter_, + comptime_data, + @ptrCast(@alignCast(out)), + ), + }; +} + +pub fn placement_get_multi( + iter_: PlacementIterator, + count: usize, + keys: ?[*]const PlacementData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = placement_get(iter_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + +fn placementGetTyped( + iter_: PlacementIterator, + comptime data: PlacementData, + out: *data.OutType(), +) Result { + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + + const key = entry.key_ptr; + const val = entry.value_ptr; + + switch (data) { + .invalid => return .invalid_value, + .image_id => out.* = key.image_id, + .placement_id => out.* = key.placement_id.id, + .is_virtual => out.* = val.location == .virtual, + .x_offset => out.* = val.x_offset, + .y_offset => out.* = val.y_offset, + .source_x => out.* = val.source_x, + .source_y => out.* = val.source_y, + .source_width => out.* = val.source_width, + .source_height => out.* = val.source_height, + .columns => out.* = val.columns, + .rows => out.* = val.rows, + .z => out.* = val.z, + } + + return .success; +} + +pub fn placement_rect( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out: *selection_c.CSelection, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const r = entry.value_ptr.rect( + image.*, + wrapper.terminal, + ) orelse return .no_value; + + out.* = .{ + .start = grid_ref.CGridRef.fromPin(r.top_left), + .end = grid_ref.CGridRef.fromPin(r.bottom_right), + .rectangle = true, + }; + + return .success; +} + +pub fn placement_pixel_size( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_width: *u32, + out_height: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const s = entry.value_ptr.pixelSize(image.*, wrapper.terminal); + + out_width.* = s.width; + out_height.* = s.height; + + return .success; +} + +pub fn placement_grid_size( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_cols: *u32, + out_rows: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const s = entry.value_ptr.gridSize(image.*, wrapper.terminal); + + out_cols.* = s.cols; + out_rows.* = s.rows; + + return .success; +} + +pub fn placement_viewport_pos( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_col: *i32, + out_row: *i32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + + const vp = computeViewportPos(entry.value_ptr, image, wrapper.terminal); + if (!vp.visible) return .no_value; + + out_col.* = vp.col; + out_row.* = vp.row; + + return .success; +} + +pub fn placement_source_rect( + iter_: PlacementIterator, + image_: ImageHandle, + out_x: *u32, + out_y: *u32, + out_width: *u32, + out_height: *u32, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const p = entry.value_ptr; + + // Apply "0 = full image dimension" convention, then clamp to image bounds. + const x = @min(p.source_x, image.width); + const y = @min(p.source_y, image.height); + const w = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x); + const h = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y); + + out_x.* = x; + out_y.* = y; + out_width.* = w; + out_height.* = h; + + return .success; +} + +/// C: GhosttyKittyGraphicsPlacementRenderInfo +pub const PlacementRenderInfo = extern struct { + size: usize = @sizeOf(PlacementRenderInfo), + pixel_width: u32 = 0, + pixel_height: u32 = 0, + grid_cols: u32 = 0, + grid_rows: u32 = 0, + viewport_col: i32 = 0, + viewport_row: i32 = 0, + viewport_visible: bool = false, + source_x: u32 = 0, + source_y: u32 = 0, + source_width: u32 = 0, + source_height: u32 = 0, +}; + +pub fn placement_render_info( + iter_: PlacementIterator, + image_: ImageHandle, + terminal_: terminal_c.Terminal, + out_: ?*PlacementRenderInfo, +) callconv(lib.calling_conv) Result { + if (comptime !build_options.kitty_graphics) return .no_value; + + const wrapper = terminal_ orelse return .invalid_value; + const image = image_ orelse return .invalid_value; + const iter = iter_ orelse return .invalid_value; + const entry = iter.entry orelse return .invalid_value; + const out = out_ orelse return .invalid_value; + if (out.size < @sizeOf(PlacementRenderInfo)) return .invalid_value; + + const p = entry.value_ptr; + + const ps = p.pixelSize(image.*, wrapper.terminal); + out.pixel_width = ps.width; + out.pixel_height = ps.height; + + const gs = p.gridSize(image.*, wrapper.terminal); + out.grid_cols = gs.cols; + out.grid_rows = gs.rows; + + const vp = computeViewportPos(p, image, wrapper.terminal); + out.viewport_col = vp.col; + out.viewport_row = vp.row; + out.viewport_visible = vp.visible; + + const x = @min(p.source_x, image.width); + const y = @min(p.source_y, image.height); + out.source_x = x; + out.source_y = y; + out.source_width = @min(if (p.source_width > 0) p.source_width else image.width, image.width - x); + out.source_height = @min(if (p.source_height > 0) p.source_height else image.height, image.height - y); + + return .success; +} + +/// Compute viewport-relative position of a placement. +/// +/// Converts the placement's internal pin to viewport-relative column +/// and row coordinates by getting screen-absolute coordinates for +/// both the pin and the viewport origin, then subtracting to get +/// viewport-relative coordinates. The row value can be negative when +/// the placement's origin has scrolled above the top of the viewport. +/// +/// A placement is considered not visible if it is a virtual (unicode +/// placeholder) placement, or if it is fully off-screen (its bottom +/// edge is above the viewport or its top edge is at or below the +/// viewport's last row). +fn computeViewportPos( + p: *const kitty_storage.ImageStorage.Placement, + image: *const Image, + t: *Terminal, +) struct { col: i32, row: i32, visible: bool } { + // Virtual placements use unicode placeholders and don't have a + // screen position — they are rendered inline by the text layout. + const pin = switch (p.location) { + .pin => |pin| pin, + .virtual => return .{ .col = 0, .row = 0, .visible = false }, + }; + + // Convert both the placement's pin and the viewport's top-left + // corner to screen-absolute coordinates so we can subtract them + // to get viewport-relative coordinates. + const pages = &t.screens.active.pages; + const pin_screen = pages.pointFromPin(.screen, pin.*) orelse + return .{ .col = 0, .row = 0, .visible = false }; + const vp_tl = pages.getTopLeft(.viewport); + const vp_screen = pages.pointFromPin(.screen, vp_tl) orelse + return .{ .col = 0, .row = 0, .visible = false }; + + // Subtracting viewport origin from the pin gives us viewport- + // relative coordinates. The row can be negative when the + // placement has partially scrolled above the viewport. + const vp_row: i32 = @as(i32, @intCast(pin_screen.screen.y)) - + @as(i32, @intCast(vp_screen.screen.y)); + const vp_col: i32 = @intCast(pin_screen.screen.x); + + // A placement is invisible if its bottom edge (row + height) + // is above the viewport, or its top edge is at or below the + // viewport's last row. + const grid_size = p.gridSize(image.*, t); + const rows_i32: i32 = @intCast(grid_size.rows); + const term_rows: i32 = @intCast(t.rows); + const visible = vp_row + rows_i32 > 0 and vp_row < term_rows; + + return .{ .col = vp_col, .row = vp_row, .visible = visible }; +} + +test "placement_iterator new/free" { + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + try testing.expect(iter != null); + placement_iterator_free(iter); +} + +test "placement_iterator free null" { + placement_iterator_free(null); +} + +test "placement_iterator next on empty storage" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(!placement_iterator_next(iter)); +} + +test "placement_iterator get before next returns invalid" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + var image_id: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_get(iter, .image_id, @ptrCast(&image_id))); +} + +test "placement_iterator with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit and display a 1x2 RGB image (image_id=1, placement_id=1). + // a=T (transmit+display), t=d (direct), f=24 (RGB), i=1, p=1 + // s=1,v=2 (1x2 pixels), c=10,r=1 (10 cols, 1 row) + // //////// = 8 base64 chars = 6 bytes = 1*2*3 RGB bytes + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + // Should have exactly one placement. + try testing.expect(placement_iterator_next(iter)); + + var image_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id))); + try testing.expectEqual(1, image_id); + + var placement_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id))); + try testing.expectEqual(1, placement_id); + + var is_virtual: bool = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .is_virtual, @ptrCast(&is_virtual))); + try testing.expect(!is_virtual); + + // No more placements. + try testing.expect(!placement_iterator_next(iter)); +} + +test "placement_iterator with multiple placements" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit image 1 then display it twice with different placement IDs. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display1 = "\x1b_Ga=p,i=1,p=1,c=10,r=1;\x1b\\"; + const display2 = "\x1b_Ga=p,i=1,p=2,c=5,r=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display1.ptr, display1.len); + terminal_c.vt_write(t, display2.ptr, display2.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + // Count placements and collect image IDs. + var count: usize = 0; + var seen_p1 = false; + var seen_p2 = false; + while (placement_iterator_next(iter)) { + count += 1; + + var image_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .image_id, @ptrCast(&image_id))); + try testing.expectEqual(1, image_id); + + var placement_id: u32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .placement_id, @ptrCast(&placement_id))); + if (placement_id == 1) seen_p1 = true; + if (placement_id == 2) seen_p2 = true; + } + + try testing.expectEqual(2, count); + try testing.expect(seen_p1); + try testing.expect(seen_p2); +} + +test "placement_iterator_set layer filter" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit image 1. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + + // Display with z=5 (above text), z=-1 (below text), z=-1073741825 (below bg). + // INT32_MIN/2 = -1073741824, so -1073741825 < INT32_MIN/2. + const d1 = "\x1b_Ga=p,i=1,p=1,z=5;\x1b\\"; + const d2 = "\x1b_Ga=p,i=1,p=2,z=-1;\x1b\\"; + const d3 = "\x1b_Ga=p,i=1,p=3,z=-1073741825;\x1b\\"; + terminal_c.vt_write(t, d1.ptr, d1.len); + terminal_c.vt_write(t, d2.ptr, d2.len); + terminal_c.vt_write(t, d3.ptr, d3.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + // Filter: above_text (z >= 0) — should yield only p=1. + var layer = PlacementLayer.above_text; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + var count: u32 = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z >= 0); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: below_text (INT32_MIN/2 <= z < 0) — should yield only p=2. + layer = .below_text; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z >= std.math.minInt(i32) / 2 and z < 0); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: below_bg (z < INT32_MIN/2) — should yield only p=3. + layer = .below_bg; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) { + var z: i32 = undefined; + try testing.expectEqual(Result.success, placement_get(iter, .z, @ptrCast(&z))); + try testing.expect(z < std.math.minInt(i32) / 2); + count += 1; + } + try testing.expectEqual(1, count); + + // Filter: all — should yield all 3. + layer = .all; + try testing.expectEqual(Result.success, placement_iterator_set(iter, .layer, @ptrCast(&layer))); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + + count = 0; + while (placement_iterator_next(iter)) count += 1; + try testing.expectEqual(3, count); +} + +test "image_get_handle returns null for missing id" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + try testing.expectEqual(@as(ImageHandle, null), image_get_handle(graphics, 999)); +} + +test "image_get_handle and image_get with transmitted image" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Transmit a 1x2 RGB image with image_id=1. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var id: u32 = undefined; + try testing.expectEqual(Result.success, image_get(img, .id, @ptrCast(&id))); + try testing.expectEqual(1, id); + + var w: u32 = undefined; + try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w))); + try testing.expectEqual(1, w); + + var h: u32 = undefined; + try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h))); + try testing.expectEqual(2, h); + + var fmt: ImageFormat = undefined; + try testing.expectEqual(Result.success, image_get(img, .format, @ptrCast(&fmt))); + try testing.expectEqual(.rgb, fmt); + + var comp: ImageCompression = undefined; + try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp))); + try testing.expectEqual(.none, comp); + + var data_len: usize = undefined; + try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len))); + try testing.expect(data_len > 0); +} + +test "placement_rect with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // Set cell size so grid calculations are deterministic. + // 80 cols * 10px = 800px, 24 rows * 20px = 480px. + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image at cursor (0,0). + // c=10,r=1 => 10 columns, 1 row. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.success, placement_rect(iter, img, t, &sel)); + + // Placement starts at cursor origin (0,0). + try testing.expectEqual(0, sel.start.x); + try testing.expectEqual(0, sel.start.y); + + // 10 columns wide, 1 row tall => bottom-right is (9, 0). + try testing.expectEqual(9, sel.end.x); + try testing.expectEqual(0, sel.end.y); + + try testing.expect(sel.rectangle); +} + +test "placement_rect null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var sel: selection_c.CSelection = undefined; + try testing.expectEqual(Result.invalid_value, placement_rect(null, null, null, &sel)); +} + +test "placement_pixel_size with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // 80 cols * 10px = 800px, 24 rows * 20px = 480px. + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with c=10,r=1. + // 10 cols * 10px = 100px width, 1 row * 20px = 20px height. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_pixel_size(iter, img, t, &w, &h)); + + try testing.expectEqual(100, w); + try testing.expectEqual(20, h); +} + +test "placement_pixel_size null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_pixel_size(null, null, null, &w, &h)); +} + +test "placement_grid_size with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + // 80 cols * 10px = 800px, 24 rows * 20px = 480px. + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with c=10,r=1. + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var cols: u32 = undefined; + var rows: u32 = undefined; + try testing.expectEqual(Result.success, placement_grid_size(iter, img, t, &cols, &rows)); + + try testing.expectEqual(10, cols); + try testing.expectEqual(1, rows); +} + +test "placement_grid_size null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var cols: u32 = undefined; + var rows: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_grid_size(null, null, null, &cols, &rows)); +} + +test "placement_viewport_pos with transmit and display" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display at cursor (0,0). + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get( + t, + .kitty_graphics, + @ptrCast(&graphics), + )); + + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new( + &lib.alloc.test_allocator, + &iter, + )); + defer placement_iterator_free(iter); + + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + + try testing.expectEqual(0, col); + try testing.expectEqual(0, row); +} + +test "placement_viewport_pos fully off-screen above" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, then display at cursor (0,0) spanning 1 row. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll the image completely off: 10 newlines in a 5-row terminal + // scrolls by 5+ rows, so a 1-row image at row 0 is fully gone. + const scroll = "\n\n\n\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.no_value, placement_viewport_pos(iter, img, t, &col, &row)); +} + +test "placement_viewport_pos top off-screen" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, display at cursor (0,0) spanning 4 rows. + // C=1 prevents cursor movement after display. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll by 2: cursor starts at row 0, 4 newlines to reach bottom, + // then 2 more to scroll by 2. Image top-left moves to vp_row=-2, + // but bottom rows -2+4=2 > 0 so it's still partially visible. + const scroll = "\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(-2, row); +} + +test "placement_viewport_pos bottom off-screen" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, move cursor to row 3 (1-based: row 4), display spanning 4 rows. + // C=1 prevents cursor movement after display. + // Image occupies rows 3-6 but viewport only has rows 0-4, so bottom is clipped. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const cursor = "\x1b[4;1H"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=4,C=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, cursor.ptr, cursor.len); + terminal_c.vt_write(t, display.ptr, display.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(3, row); +} + +test "placement_viewport_pos top and bottom off-screen" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + // Transmit image, display at cursor (0,0) spanning 10 rows. + // C=1 prevents cursor movement after display. + // After scrolling by 3, image occupies vp rows -3..6, viewport is 0..4, + // so both top and bottom are clipped but center is visible. + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=10,C=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll by 3: 4 newlines to reach bottom + 3 more to scroll. + const scroll = "\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.success, placement_viewport_pos(iter, img, t, &col, &row)); + try testing.expectEqual(0, col); + try testing.expectEqual(-3, row); +} + +test "placement_viewport_pos null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var col: i32 = undefined; + var row: i32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_viewport_pos(null, null, null, &col, &row)); +} + +test "placement_source_rect defaults to full image" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit and display a 1x2 RGB image with no source rect specified. + // source_width=0 and source_height=0 should resolve to full image (1x2). + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(0, x); + try testing.expectEqual(0, y); + try testing.expectEqual(1, w); + try testing.expectEqual(2, h); +} + +test "placement_source_rect with explicit source rect" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). + // Base64 of 64 zero bytes: 88 chars (21 full groups + AA== padding). + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++ + "\x1b\\"; + // Display with explicit source rect: x=1, y=1, w=2, h=2. + const display = "\x1b_Ga=p,i=1,p=1,x=1,y=1,w=2,h=2;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(1, x); + try testing.expectEqual(1, y); + try testing.expectEqual(2, w); + try testing.expectEqual(2, h); +} + +test "placement_source_rect clamps to image bounds" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + // Transmit a 4x4 RGBA image (64 bytes = 4*4*4). + const transmit = "\x1b_Ga=t,t=d,f=32,i=1,s=4,v=4;" ++ + "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA==" ++ + "\x1b\\"; + // Display with source rect that exceeds image bounds: x=3, y=3, w=10, h=10. + // Should clamp to x=3, y=3, w=1, h=1. + const display = "\x1b_Ga=p,i=1,p=1,x=3,y=3,w=10,h=10;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.success, placement_source_rect(iter, img, &x, &y, &w, &h)); + try testing.expectEqual(3, x); + try testing.expectEqual(3, y); + try testing.expectEqual(1, w); + try testing.expectEqual(1, h); +} + +test "placement_source_rect null args return invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var x: u32 = undefined; + var y: u32 = undefined; + var w: u32 = undefined; + var h: u32 = undefined; + try testing.expectEqual(Result.invalid_value, placement_source_rect(null, null, &x, &y, &w, &h)); +} + +test "image_get on null returns invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var id: u32 = undefined; + try testing.expectEqual(Result.invalid_value, image_get(null, .id, @ptrCast(&id))); +} + +test "placement_render_info returns all fields" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var ri: PlacementRenderInfo = .{}; + try testing.expectEqual(Result.success, placement_render_info(iter, img, t, &ri)); + try testing.expect(ri.viewport_visible); + try testing.expectEqual(0, ri.viewport_col); + try testing.expectEqual(0, ri.viewport_row); + try testing.expectEqual(10, ri.grid_cols); + try testing.expectEqual(1, ri.grid_rows); + try testing.expectEqual(0, ri.source_x); + try testing.expectEqual(0, ri.source_y); + try testing.expectEqual(1, ri.source_width); + try testing.expectEqual(2, ri.source_height); +} + +test "placement_render_info off-screen sets viewport_visible false" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 5, .max_scrollback = 100 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 5, 10, 20)); + + const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\"; + const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\"; + terminal_c.vt_write(t, transmit.ptr, transmit.len); + terminal_c.vt_write(t, display.ptr, display.len); + + // Scroll the image completely off-screen. + const scroll = "\n\n\n\n\n\n\n\n\n\n"; + terminal_c.vt_write(t, scroll.ptr, scroll.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var ri: PlacementRenderInfo = .{}; + try testing.expectEqual(Result.success, placement_render_info(iter, img, t, &ri)); + try testing.expect(!ri.viewport_visible); + // Other fields should still be populated. + try testing.expectEqual(1, ri.grid_cols); + try testing.expectEqual(1, ri.grid_rows); + try testing.expectEqual(1, ri.source_width); + try testing.expectEqual(2, ri.source_height); +} + +test "placement_render_info null returns invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var ri: PlacementRenderInfo = .{}; + try testing.expectEqual(Result.invalid_value, placement_render_info(null, null, null, &ri)); +} + +test "image_get_multi success" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + const img = image_get_handle(graphics, 1); + try testing.expect(img != null); + + var id: u32 = 0; + var width: u32 = 0; + var height: u32 = 0; + var written: usize = 0; + + const keys = [_]ImageData{ .id, .width, .height }; + var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&width), @ptrCast(&height) }; + try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(1, id); + try testing.expectEqual(1, width); + try testing.expectEqual(2, height); +} + +test "image_get_multi error sets out_written" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var id: u32 = 0; + var written: usize = 99; + + const keys = [_]ImageData{ .id, .invalid }; + var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&id) }; + try testing.expectEqual(Result.invalid_value, image_get_multi(null, keys.len, &keys, &values, &written)); + try testing.expectEqual(0, written); +} + +test "image_get_multi null keys returns invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var id: u32 = 0; + var values = [_]?*anyopaque{@ptrCast(&id)}; + try testing.expectEqual(Result.invalid_value, image_get_multi(null, 1, null, &values, null)); +} + +test "placement_get_multi success" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer terminal_c.free(t); + try testing.expectEqual(Result.success, terminal_c.resize(t, 80, 24, 10, 20)); + + const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2,c=10,r=1;////////\x1b\\"; + terminal_c.vt_write(t, cmd.ptr, cmd.len); + + var graphics: KittyGraphics = undefined; + try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics))); + + var iter: PlacementIterator = null; + try testing.expectEqual(Result.success, placement_iterator_new(&lib.alloc.test_allocator, &iter)); + defer placement_iterator_free(iter); + try testing.expectEqual(Result.success, get(graphics, .placement_iterator, @ptrCast(&iter))); + try testing.expect(placement_iterator_next(iter)); + + var image_id: u32 = 0; + var columns: u32 = 0; + var z: i32 = 99; + var written: usize = 0; + + const keys = [_]PlacementData{ .image_id, .columns, .z }; + var values = [_]?*anyopaque{ @ptrCast(&image_id), @ptrCast(&columns), @ptrCast(&z) }; + try testing.expectEqual(Result.success, placement_get_multi(iter, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(1, image_id); + try testing.expectEqual(10, columns); + try testing.expectEqual(0, z); +} + +test "placement_get_multi error sets out_written" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var id: u32 = 0; + var written: usize = 99; + + const keys = [_]PlacementData{ .image_id, .invalid }; + var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&id) }; + try testing.expectEqual(Result.invalid_value, placement_get_multi(null, keys.len, &keys, &values, &written)); + try testing.expectEqual(0, written); +} + +test "placement_get_multi null keys returns invalid_value" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var id: u32 = 0; + var values = [_]?*anyopaque{@ptrCast(&id)}; + try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig index 34d1a8f4..126b6640 100644 --- a/vendor/libghostty-vt/src/terminal/c/main.zig +++ b/vendor/libghostty-vt/src/terminal/c/main.zig @@ -8,10 +8,28 @@ pub const color = @import("color.zig"); pub const focus = @import("focus.zig"); pub const formatter = @import("formatter.zig"); pub const grid_ref = @import("grid_ref.zig"); +pub const kitty_graphics = @import("kitty_graphics.zig"); +pub const kitty_graphics_get = kitty_graphics.get; +pub const kitty_graphics_image = kitty_graphics.image_get_handle; +pub const kitty_graphics_image_get = kitty_graphics.image_get; +pub const kitty_graphics_image_get_multi = kitty_graphics.image_get_multi; +pub const kitty_graphics_placement_iterator_new = kitty_graphics.placement_iterator_new; +pub const kitty_graphics_placement_iterator_free = kitty_graphics.placement_iterator_free; +pub const kitty_graphics_placement_iterator_set = kitty_graphics.placement_iterator_set; +pub const kitty_graphics_placement_next = kitty_graphics.placement_iterator_next; +pub const kitty_graphics_placement_get = kitty_graphics.placement_get; +pub const kitty_graphics_placement_get_multi = kitty_graphics.placement_get_multi; +pub const kitty_graphics_placement_rect = kitty_graphics.placement_rect; +pub const kitty_graphics_placement_pixel_size = kitty_graphics.placement_pixel_size; +pub const kitty_graphics_placement_grid_size = kitty_graphics.placement_grid_size; +pub const kitty_graphics_placement_viewport_pos = kitty_graphics.placement_viewport_pos; +pub const kitty_graphics_placement_source_rect = kitty_graphics.placement_source_rect; +pub const kitty_graphics_placement_render_info = kitty_graphics.placement_render_info; pub const types = @import("types.zig"); pub const modes = @import("modes.zig"); pub const osc = @import("osc.zig"); pub const render = @import("render.zig"); +pub const selection = @import("selection.zig"); pub const key_event = @import("key_event.zig"); pub const key_encode = @import("key_encode.zig"); pub const mouse_event = @import("mouse_event.zig"); @@ -21,6 +39,7 @@ pub const row = @import("row.zig"); pub const sgr = @import("sgr.zig"); pub const size_report = @import("size_report.zig"); pub const style = @import("style.zig"); +pub const sys = @import("sys.zig"); pub const terminal = @import("terminal.zig"); // The full C API, unexported. @@ -49,17 +68,20 @@ pub const render_state_new = render.new; pub const render_state_free = render.free; pub const render_state_update = render.update; pub const render_state_get = render.get; +pub const render_state_get_multi = render.get_multi; pub const render_state_set = render.set; pub const render_state_colors_get = render.colors_get; pub const render_state_row_iterator_new = render.row_iterator_new; pub const render_state_row_iterator_next = render.row_iterator_next; pub const render_state_row_get = render.row_get; +pub const render_state_row_get_multi = render.row_get_multi; pub const render_state_row_set = render.row_set; pub const render_state_row_iterator_free = render.row_iterator_free; pub const render_state_row_cells_new = render.row_cells_new; pub const render_state_row_cells_next = render.row_cells_next; pub const render_state_row_cells_select = render.row_cells_select; pub const render_state_row_cells_get = render.row_cells_get; +pub const render_state_row_cells_get_multi = render.row_cells_get_multi; pub const render_state_row_cells_free = render.row_cells_free; pub const sgr_new = sgr.new; @@ -125,12 +147,17 @@ pub const alloc_free = allocator.free; pub const size_report_encode = size_report.encode; pub const cell_get = cell.get; +pub const cell_get_multi = cell.get_multi; pub const row_get = row.get; +pub const row_get_multi = row.get_multi; pub const style_default = style.default_style; pub const style_is_default = style.style_is_default; +pub const sys_log_stderr = sys.logStderr; +pub const sys_set = sys.set; + pub const terminal_new = terminal.new; pub const terminal_free = terminal.free; pub const terminal_reset = terminal.reset; @@ -141,14 +168,16 @@ pub const terminal_scroll_viewport = terminal.scroll_viewport; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; +pub const terminal_get_multi = terminal.get_multi; pub const terminal_grid_ref = terminal.grid_ref; -pub const terminal_read_text = terminal.read_text; +pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref; pub const type_json = types.get_json; pub const grid_ref_cell = grid_ref.grid_ref_cell; pub const grid_ref_row = grid_ref.grid_ref_row; pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes; +pub const grid_ref_hyperlink_uri = grid_ref.grid_ref_hyperlink_uri; pub const grid_ref_style = grid_ref.grid_ref_style; test { @@ -157,12 +186,14 @@ test { _ = cell; _ = color; _ = grid_ref; + _ = kitty_graphics; _ = row; _ = focus; _ = formatter; _ = modes; _ = osc; _ = render; + _ = selection; _ = key_event; _ = key_encode; _ = mouse_event; @@ -171,6 +202,7 @@ test { _ = sgr; _ = size_report; _ = style; + _ = sys; _ = terminal; _ = types; diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig index b95bedd5..af82ddfa 100644 --- a/vendor/libghostty-vt/src/terminal/c/render.zig +++ b/vendor/libghostty-vt/src/terminal/c/render.zig @@ -201,6 +201,27 @@ pub fn get( }; } +pub fn get_multi( + state_: RenderState, + count: usize, + keys: ?[*]const Data, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(state_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + fn getTyped( state_: RenderState, comptime data: Data, @@ -468,6 +489,27 @@ pub fn row_cells_get( }; } +pub fn row_cells_get_multi( + cells_: RowCells, + count: usize, + keys: ?[*]const RowCellsData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = row_cells_get(cells_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + fn rowCellsGetTyped( cells_: RowCells, comptime data: RowCellsData, @@ -568,6 +610,27 @@ pub fn row_get( }; } +pub fn row_get_multi( + iterator_: RowIterator, + count: usize, + keys: ?[*]const RowData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = row_get(iterator_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + fn rowGetTyped( iterator_: RowIterator, comptime data: RowData, @@ -1380,3 +1443,137 @@ test "render: colors get supports truncated sized struct" { try testing.expectEqual(state_colors.palette[1].cval(), colors.palette[1]); try testing.expectEqual(sentinel, colors.palette[2]); } + +test "render: get_multi success" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var cols: u16 = 0; + var rows: u16 = 0; + var written: usize = 0; + + const keys = [_]Data{ .cols, .rows }; + var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&rows) }; + try testing.expectEqual(Result.success, get_multi(state, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(80, cols); + try testing.expectEqual(24, rows); +} + +test "render: get_multi null returns invalid_value" { + var cols: u16 = 0; + var values = [_]?*anyopaque{@ptrCast(&cols)}; + try testing.expectEqual(Result.invalid_value, get_multi(null, 1, null, &values, null)); +} + +test "render: row_get_multi success" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var dirty: bool = true; + var written: usize = 0; + + const keys = [_]RowData{.dirty}; + var values = [_]?*anyopaque{@ptrCast(&dirty)}; + try testing.expectEqual(Result.success, row_get_multi(it, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); +} + +test "render: row_get_multi null returns invalid_value" { + var dirty: bool = false; + var values = [_]?*anyopaque{@ptrCast(&dirty)}; + try testing.expectEqual(Result.invalid_value, row_get_multi(null, 1, null, &values, null)); +} + +test "render: row_cells_get_multi success" { + var terminal: terminal_c.Terminal = null; + try testing.expectEqual(Result.success, terminal_c.new( + &lib.alloc.test_allocator, + &terminal, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer terminal_c.free(terminal); + + terminal_c.vt_write(terminal, "A", 1); + + var state: RenderState = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &state, + )); + defer free(state); + + try testing.expectEqual(Result.success, update(state, terminal)); + + var it: RowIterator = null; + try testing.expectEqual(Result.success, row_iterator_new( + &lib.alloc.test_allocator, + &it, + )); + defer row_iterator_free(it); + + try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it))); + try testing.expect(row_iterator_next(it)); + + var cells: RowCells = null; + try testing.expectEqual(Result.success, row_cells_new( + &lib.alloc.test_allocator, + &cells, + )); + defer row_cells_free(cells); + + try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells))); + try testing.expect(row_cells_next(cells)); + + var raw: row.CRow = undefined; + var written: usize = 0; + + const keys = [_]RowCellsData{.raw}; + var values = [_]?*anyopaque{@ptrCast(&raw)}; + try testing.expectEqual(Result.success, row_cells_get_multi(cells, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); +} + +test "render: row_cells_get_multi null returns invalid_value" { + var raw: row.CRow = undefined; + var values = [_]?*anyopaque{@ptrCast(&raw)}; + try testing.expectEqual(Result.invalid_value, row_cells_get_multi(null, 1, null, &values, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/row.zig b/vendor/libghostty-vt/src/terminal/c/row.zig index d4532706..66c398c8 100644 --- a/vendor/libghostty-vt/src/terminal/c/row.zig +++ b/vendor/libghostty-vt/src/terminal/c/row.zig @@ -83,6 +83,27 @@ pub fn get( }; } +pub fn get_multi( + row_: CRow, + count: usize, + keys: ?[*]const RowData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(row_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + fn getTyped( row_: CRow, comptime data: RowData, @@ -130,3 +151,43 @@ test "get dirty" { try testing.expectEqual(Result.success, get(row, .dirty, @ptrCast(&dirty))); try testing.expect(dirty); } + +test "get_multi success" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.wrap = true; + zig_row.dirty = true; + const row_val: CRow = @bitCast(zig_row); + + var wrap: bool = false; + var dirty: bool = false; + var written: usize = 0; + + const keys = [_]RowData{ .wrap, .dirty }; + var values = [_]?*anyopaque{ @ptrCast(&wrap), @ptrCast(&dirty) }; + try testing.expectEqual(Result.success, get_multi(row_val, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expect(wrap); + try testing.expect(dirty); +} + +test "get_multi error sets out_written" { + var zig_row: Row = @bitCast(@as(u64, 0)); + zig_row.wrap = true; + const row_val: CRow = @bitCast(zig_row); + + var wrap: bool = false; + var written: usize = 99; + + const keys = [_]RowData{ .wrap, .invalid }; + var values = [_]?*anyopaque{ @ptrCast(&wrap), @ptrCast(&wrap) }; + try testing.expectEqual(Result.invalid_value, get_multi(row_val, keys.len, &keys, &values, &written)); + try testing.expectEqual(1, written); + try testing.expect(wrap); +} + +test "get_multi null keys returns invalid_value" { + const row_val: CRow = @bitCast(@as(u64, 0)); + var wrap: bool = false; + var values = [_]?*anyopaque{@ptrCast(&wrap)}; + try testing.expectEqual(Result.invalid_value, get_multi(row_val, 1, null, &values, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/selection.zig b/vendor/libghostty-vt/src/terminal/c/selection.zig new file mode 100644 index 00000000..74e96598 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/selection.zig @@ -0,0 +1,16 @@ +const grid_ref = @import("grid_ref.zig"); +const Selection = @import("../Selection.zig"); + +/// C: GhosttySelection +pub const CSelection = extern struct { + size: usize = @sizeOf(CSelection), + start: grid_ref.CGridRef, + end: grid_ref.CGridRef, + rectangle: bool = false, + + pub fn toZig(self: CSelection) ?Selection { + const start_pin = self.start.toPin() orelse return null; + const end_pin = self.end.toPin() orelse return null; + return Selection.init(start_pin, end_pin, self.rectangle); + } +}; diff --git a/vendor/libghostty-vt/src/terminal/c/sys.zig b/vendor/libghostty-vt/src/terminal/c/sys.zig new file mode 100644 index 00000000..c4b2b17f --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/c/sys.zig @@ -0,0 +1,430 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const lib = @import("../lib.zig"); +const CAllocator = lib.alloc.Allocator; +const terminal_sys = @import("../sys.zig"); +const Result = @import("result.zig").Result; + +/// C: GhosttySysImage +pub const Image = extern struct { + width: u32, + height: u32, + data: ?[*]u8, + data_len: usize, +}; + +/// C: GhosttySysDecodePngFn +pub const DecodePngFn = *const fn ( + ?*anyopaque, + *const CAllocator, + [*]const u8, + usize, + *Image, +) callconv(lib.calling_conv) bool; + +/// C: GhosttySysLogLevel +pub const LogLevel = enum(c_int) { + @"error" = 0, + warning = 1, + info = 2, + debug = 3, + + pub fn fromStd(level: std.log.Level) LogLevel { + return switch (level) { + .err => .@"error", + .warn => .warning, + .info => .info, + .debug => .debug, + }; + } +}; + +/// C: GhosttySysLogFn +pub const LogFn = *const fn ( + ?*anyopaque, + LogLevel, + [*]const u8, + usize, + [*]const u8, + usize, +) callconv(lib.calling_conv) void; + +/// C: GhosttySysOption +pub const Option = enum(c_int) { + userdata = 0, + decode_png = 1, + log = 2, + + pub fn InType(comptime self: Option) type { + return switch (self) { + .userdata => ?*const anyopaque, + .decode_png => ?DecodePngFn, + .log => ?LogFn, + }; + } +}; + +/// Global state for the sys interface so we can call through to the C +/// callbacks from Zig. +const Global = struct { + userdata: ?*anyopaque = null, + decode_png: ?DecodePngFn = null, + log: ?LogFn = null, +}; + +/// Global state for the C sys interface. +var global: Global = .{}; + +/// Zig-compatible wrapper that calls through to the stored C callback. +/// The C callback allocates the pixel data through the provided allocator, +/// so we can take ownership directly. +fn decodePngWrapper( + alloc: std.mem.Allocator, + data: []const u8, +) terminal_sys.DecodeError!terminal_sys.Image { + const func = global.decode_png orelse return error.InvalidData; + + const c_alloc = CAllocator.fromZig(&alloc); + var out: Image = undefined; + if (!func(global.userdata, &c_alloc, data.ptr, data.len, &out)) return error.InvalidData; + + const result_data = out.data orelse return error.InvalidData; + + return .{ + .width = out.width, + .height = out.height, + .data = result_data[0..out.data_len], + }; +} + +pub fn set( + option: Option, + value: ?*const anyopaque, +) callconv(lib.calling_conv) Result { + if (comptime std.debug.runtime_safety) { + _ = std.meta.intToEnum(Option, @intFromEnum(option)) catch { + return .invalid_value; + }; + } + + return switch (option) { + inline else => |comptime_option| setTyped( + comptime_option, + @ptrCast(@alignCast(value)), + ), + }; +} + +fn setTyped( + comptime option: Option, + value: option.InType(), +) Result { + switch (option) { + .userdata => global.userdata = @constCast(value), + .decode_png => { + global.decode_png = value; + terminal_sys.decode_png = if (value != null) &decodePngWrapper else null; + }, + .log => global.log = value, + } + return .success; +} + +/// Dispatch a log message to the installed C callback, if any. +fn emitLog(level: LogLevel, scope: []const u8, message: []const u8) void { + const func = global.log orelse return; + func( + global.userdata, + level, + scope.ptr, + scope.len, + message.ptr, + message.len, + ); +} + +/// Emits logs in chunks. Almost all logs will be less than the chunk size +/// but this allows emitting larger logs without heap allocation. +const LogEmitter = struct { + c_level: LogLevel, + scope_text: []const u8, + buf: [2048]u8 = undefined, + pos: usize = 0, + + fn write(self: *@This(), bytes: []const u8) error{}!usize { + var remaining = bytes; + while (remaining.len > 0) { + const space = self.buf.len - self.pos; + if (space == 0) { + self.flush(); + continue; + } + + const n = @min(remaining.len, space); + @memcpy(self.buf[self.pos..][0..n], remaining[0..n]); + self.pos += n; + remaining = remaining[n..]; + } + + return bytes.len; + } + + fn flush(self: *@This()) void { + if (self.pos == 0) return; + emitLog( + self.c_level, + self.scope_text, + self.buf[0..self.pos], + ); + self.pos = 0; + } +}; + +/// Custom std.log sink for C ABI builds. +/// +/// When a log callback is installed via ghostty_sys_set(), messages are +/// dispatched through it. When no callback is installed, messages are +/// silently discarded. Large messages that exceed the stack buffer are +/// delivered across multiple callback invocations. +pub fn logFn( + comptime level: std.log.Level, + comptime scope: @TypeOf(.EnumLiteral), + comptime format: []const u8, + args: anytype, +) void { + if (global.log == null) return; + + const scope_text: []const u8 = if (scope == .default) "" else @tagName(scope); + const c_level = LogLevel.fromStd(level); + + var ctx: LogEmitter = .{ + .c_level = c_level, + .scope_text = scope_text, + }; + const writer: std.io.GenericWriter( + *LogEmitter, + error{}, + LogEmitter.write, + ) = .{ .context = &ctx }; + + nosuspend writer.print(format, args) catch {}; + ctx.flush(); +} + +/// Built-in log callback that writes to stderr. +/// +/// Formats each message as "[level](scope): message\n". Can be passed +/// directly to ghostty_sys_set(GHOSTTY_SYS_OPT_LOG, &ghostty_sys_log_stderr). +/// +/// Uses std.debug.lockStderrWriter for thread-safe, mutex-protected output. +/// On freestanding/wasm targets this is a no-op (no stderr available). +pub fn logStderr( + _: ?*anyopaque, + level: LogLevel, + scope_ptr: [*]const u8, + scope_len: usize, + message_ptr: [*]const u8, + message_len: usize, +) callconv(lib.calling_conv) void { + if (comptime builtin.target.cpu.arch.isWasm()) return; + + const scope = scope_ptr[0..scope_len]; + const message = message_ptr[0..message_len]; + + const level_text = switch (level) { + .@"error" => "error", + .warning => "warning", + .info => "info", + .debug => "debug", + }; + + var buffer: [64]u8 = undefined; + const writer = std.debug.lockStderrWriter(&buffer); + defer std.debug.unlockStderrWriter(); + nosuspend { + if (scope.len > 0) { + writer.print("[{s}]({s}): {s}\n", .{ level_text, scope, message }) catch {}; + } else { + writer.print("[{s}]: {s}\n", .{ level_text, message }) catch {}; + } + } +} + +test "set decode_png with null clears" { + // Start from a known state. + global.decode_png = null; + terminal_sys.decode_png = null; + + try std.testing.expectEqual(Result.success, set(.decode_png, null)); + try std.testing.expect(terminal_sys.decode_png == null); +} + +test "set decode_png installs wrapper" { + const S = struct { + fn decode(_: ?*anyopaque, _: *const CAllocator, _: [*]const u8, _: usize, out: *Image) callconv(lib.calling_conv) bool { + out.* = .{ .width = 1, .height = 1, .data = null, .data_len = 0 }; + return true; + } + }; + + try std.testing.expectEqual(Result.success, set( + .decode_png, + @ptrCast(&S.decode), + )); + try std.testing.expect(terminal_sys.decode_png != null); + + // Clear it again. + try std.testing.expectEqual(Result.success, set(.decode_png, null)); + try std.testing.expect(terminal_sys.decode_png == null); +} + +test "set log with null clears" { + global.log = null; + + try std.testing.expectEqual(Result.success, set(.log, null)); + try std.testing.expect(global.log == null); +} + +test "set log installs callback" { + const S = struct { + var called: bool = false; + fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, _: [*]const u8, _: usize) callconv(lib.calling_conv) void { + called = true; + } + }; + + try std.testing.expectEqual(Result.success, set(.log, @ptrCast(&S.logCb))); + try std.testing.expect(global.log != null); + + emitLog(.info, "test", "hello"); + try std.testing.expect(S.called); + + // Clear it again. + S.called = false; + try std.testing.expectEqual(Result.success, set(.log, null)); + try std.testing.expect(global.log == null); + + emitLog(.info, "test", "should not arrive"); + try std.testing.expect(!S.called); +} + +test "logFn small message single chunk" { + const S = struct { + var call_count: usize = 0; + var total_len: usize = 0; + + fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void { + _ = msg; + call_count += 1; + total_len += msg_len; + } + }; + + S.call_count = 0; + S.total_len = 0; + global.log = @ptrCast(&S.logCb); + defer { + global.log = null; + } + + logFn(.info, .default, "hello", .{}); + + try std.testing.expectEqual(@as(usize, 1), S.call_count); + try std.testing.expectEqual(@as(usize, 5), S.total_len); +} + +test "logFn message exceeding chunk size is split" { + const S = struct { + var call_count: usize = 0; + var total_len: usize = 0; + + fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void { + _ = msg; + call_count += 1; + total_len += msg_len; + // Each chunk must not exceed the buffer size. + std.debug.assert(msg_len <= 2048); + } + }; + + S.call_count = 0; + S.total_len = 0; + global.log = @ptrCast(&S.logCb); + defer { + global.log = null; + } + + // Format a message larger than the 2048-byte buffer. + // 'A' repeated 3000 times via a fill format. + const fill: [3000]u8 = .{0x41} ** 3000; + logFn(.info, .default, "{s}", .{@as([]const u8, &fill)}); + + try std.testing.expect(S.call_count >= 2); + try std.testing.expectEqual(@as(usize, 3000), S.total_len); +} + +test "logFn message exactly at chunk boundary" { + const S = struct { + var call_count: usize = 0; + var total_len: usize = 0; + + fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void { + _ = msg; + call_count += 1; + total_len += msg_len; + std.debug.assert(msg_len <= 2048); + } + }; + + S.call_count = 0; + S.total_len = 0; + global.log = @ptrCast(&S.logCb); + defer { + global.log = null; + } + + // Exactly 2048 bytes — should emit one full chunk, no remainder. + const fill: [2048]u8 = .{0x42} ** 2048; + logFn(.info, .default, "{s}", .{@as([]const u8, &fill)}); + + try std.testing.expectEqual(@as(usize, 1), S.call_count); + try std.testing.expectEqual(@as(usize, 2048), S.total_len); +} + +test "logFn message exactly double chunk size" { + const S = struct { + var call_count: usize = 0; + var total_len: usize = 0; + + fn logCb(_: ?*anyopaque, _: LogLevel, _: [*]const u8, _: usize, msg: [*]const u8, msg_len: usize) callconv(lib.calling_conv) void { + _ = msg; + call_count += 1; + total_len += msg_len; + std.debug.assert(msg_len <= 2048); + } + }; + + S.call_count = 0; + S.total_len = 0; + global.log = @ptrCast(&S.logCb); + defer { + global.log = null; + } + + // Exactly 4096 bytes — should emit exactly two full chunks. + const fill: [4096]u8 = .{0x43} ** 4096; + logFn(.info, .default, "{s}", .{@as([]const u8, &fill)}); + + try std.testing.expectEqual(@as(usize, 2), S.call_count); + try std.testing.expectEqual(@as(usize, 4096), S.total_len); +} + +test "set userdata" { + var data: u32 = 42; + try std.testing.expectEqual(Result.success, set(.userdata, @ptrCast(&data))); + try std.testing.expect(global.userdata == @as(?*anyopaque, @ptrCast(&data))); + + // Clear it. + try std.testing.expectEqual(Result.success, set(.userdata, null)); + try std.testing.expect(global.userdata == null); +} diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig index fa421f21..d3293f3b 100644 --- a/vendor/libghostty-vt/src/terminal/c/terminal.zig +++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig @@ -1,20 +1,21 @@ const std = @import("std"); const testing = std.testing; +const build_options = @import("terminal_options"); const lib = @import("../lib.zig"); const CAllocator = lib.alloc.Allocator; const ZigTerminal = @import("../Terminal.zig"); const Stream = @import("../stream_terminal.zig").Stream; const ScreenSet = @import("../ScreenSet.zig"); const PageList = @import("../PageList.zig"); +const apc = @import("../apc.zig"); const kitty = @import("../kitty/key.zig"); +const kitty_gfx_c = @import("kitty_graphics.zig"); const modes = @import("../modes.zig"); const point = @import("../point.zig"); const size = @import("../size.zig"); const device_attributes = @import("../device_attributes.zig"); const device_status = @import("../device_status.zig"); const size_report = @import("../size_report.zig"); -const formatterpkg = @import("../formatter.zig"); -const selectionpkg = @import("../Selection.zig"); const cell_c = @import("cell.zig"); const row_c = @import("row.zig"); const grid_ref_c = @import("grid_ref.zig"); @@ -306,6 +307,12 @@ pub const Option = enum(c_int) { color_background = 12, color_cursor = 13, color_palette = 14, + kitty_image_storage_limit = 15, + kitty_image_medium_file = 16, + kitty_image_medium_temp_file = 17, + kitty_image_medium_shared_mem = 18, + apc_max_bytes = 19, + apc_max_bytes_kitty = 20, /// Input type expected for setting the option. pub fn InType(comptime self: Option) type { @@ -322,6 +329,12 @@ pub const Option = enum(c_int) { .title, .pwd => ?*const lib.String, .color_foreground, .color_background, .color_cursor => ?*const color.RGB.C, .color_palette => ?*const color.PaletteC, + .kitty_image_storage_limit => ?*const u64, + .kitty_image_medium_file, + .kitty_image_medium_temp_file, + .kitty_image_medium_shared_mem, + => ?*const bool, + .apc_max_bytes, .apc_max_bytes_kitty => ?*const usize, }; } }; @@ -390,6 +403,45 @@ fn setTyped( ); wrapper.terminal.flags.dirty.palette = true; }, + .kitty_image_storage_limit => { + if (comptime !build_options.kitty_graphics) return .success; + const limit: usize = if (value) |v| @intCast(v.*) else 0; + var it = wrapper.terminal.screens.all.iterator(); + while (it.next()) |entry| { + const screen = entry.value.*; + screen.kitty_images.setLimit(screen.alloc, screen, limit) catch return .out_of_memory; + } + }, + .kitty_image_medium_file, + .kitty_image_medium_temp_file, + .kitty_image_medium_shared_mem, + => { + if (comptime !build_options.kitty_graphics) return .success; + const val = (value orelse return .success).*; + var it = wrapper.terminal.screens.all.iterator(); + while (it.next()) |entry| { + const screen = entry.value.*; + switch (option) { + .kitty_image_medium_file => screen.kitty_images.image_limits.file = val, + .kitty_image_medium_temp_file => screen.kitty_images.image_limits.temporary_file = val, + .kitty_image_medium_shared_mem => screen.kitty_images.image_limits.shared_memory = val, + else => unreachable, + } + } + }, + .apc_max_bytes => { + wrapper.stream.handler.apc_handler.max_bytes = if (value) |ptr| + .initFull(ptr.*) + else + .{}; + }, + .apc_max_bytes_kitty => { + if (value) |ptr| { + wrapper.stream.handler.apc_handler.max_bytes.put(.kitty, ptr.*); + } else { + wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty); + } + }, } return .success; } @@ -400,21 +452,6 @@ pub const DeviceAttributes = Effects.CDeviceAttributes; /// C: GhosttyTerminalScrollViewport pub const ScrollViewport = ZigTerminal.ScrollViewport.C; -/// C: GhosttyTerminalSelection -pub const Selection = extern struct { - start: point.Point.C, - end: point.Point.C, - rectangle: bool, - - fn toZig(self: Selection, terminal: *const ZigTerminal) ?selectionpkg { - return selectionpkg.init( - pinFromPoint(terminal, self.start) orelse return null, - pinFromPoint(terminal, self.end) orelse return null, - self.rectangle, - ); - } -}; - pub fn scroll_viewport( terminal_: Terminal, behavior: ScrollViewport, @@ -496,6 +533,9 @@ pub fn mode_set( return .success; } +/// C: GhosttyKittyGraphics +pub const KittyGraphics = kitty_gfx_c.KittyGraphics; + /// C: GhosttyTerminalScreen pub const TerminalScreen = ScreenSet.Key; @@ -530,6 +570,11 @@ pub const TerminalData = enum(c_int) { color_background_default = 23, color_cursor_default = 24, color_palette_default = 25, + kitty_image_storage_limit = 26, + kitty_image_medium_file = 27, + kitty_image_medium_temp_file = 28, + kitty_image_medium_shared_mem = 29, + kitty_graphics = 30, /// Output type expected for querying the data of the given kind. pub fn OutType(comptime self: TerminalData) type { @@ -552,6 +597,12 @@ pub const TerminalData = enum(c_int) { .color_cursor_default, => color.RGB.C, .color_palette, .color_palette_default => color.PaletteC, + .kitty_image_storage_limit => u64, + .kitty_image_medium_file, + .kitty_image_medium_temp_file, + .kitty_image_medium_shared_mem, + => bool, + .kitty_graphics => KittyGraphics, }; } }; @@ -578,6 +629,27 @@ pub fn get( }; } +pub fn get_multi( + terminal_: Terminal, + count: usize, + keys: ?[*]const TerminalData, + values: ?[*]?*anyopaque, + out_written: ?*usize, +) callconv(lib.calling_conv) Result { + const k = keys orelse return .invalid_value; + const v = values orelse return .invalid_value; + + for (0..count) |i| { + const result = get(terminal_, k[i], v[i]); + if (result != .success) { + if (out_written) |w| w.* = i; + return result; + } + } + if (out_written) |w| w.* = count; + return .success; +} + fn getTyped( terminal_: Terminal, comptime data: TerminalData, @@ -620,63 +692,60 @@ fn getTyped( .color_cursor_default => out.* = (t.colors.cursor.default orelse return .no_value).cval(), .color_palette => out.* = color.paletteCval(&t.colors.palette.current), .color_palette_default => out.* = color.paletteCval(&t.colors.palette.original), + .kitty_image_storage_limit => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = @intCast(t.screens.active.kitty_images.total_limit); + }, + .kitty_image_medium_file => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = t.screens.active.kitty_images.image_limits.file; + }, + .kitty_image_medium_temp_file => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = t.screens.active.kitty_images.image_limits.temporary_file; + }, + .kitty_image_medium_shared_mem => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = t.screens.active.kitty_images.image_limits.shared_memory; + }, + .kitty_graphics => { + if (comptime !build_options.kitty_graphics) return .no_value; + out.* = &t.screens.active.kitty_images; + }, } return .success; } -fn pointToZig(pt: point.Point.C) point.Point { - return switch (pt.tag) { - .active => .{ .active = pt.value.active }, - .viewport => .{ .viewport = pt.value.viewport }, - .screen => .{ .screen = pt.value.screen }, - .history => .{ .history = pt.value.history }, - }; -} - -fn pinFromPoint(terminal: *const ZigTerminal, pt: point.Point.C) ?PageList.Pin { - return terminal.screens.active.pages.pin(pointToZig(pt)); -} - pub fn grid_ref( terminal_: Terminal, pt: point.Point.C, out_ref: ?*grid_ref_c.CGridRef, ) callconv(lib.calling_conv) Result { const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const p = pinFromPoint(t, pt) orelse return .invalid_value; + const zig_pt: point.Point = switch (pt.tag) { + .active => .{ .active = pt.value.active }, + .viewport => .{ .viewport = pt.value.viewport }, + .screen => .{ .screen = pt.value.screen }, + .history => .{ .history = pt.value.history }, + }; + const p = t.screens.active.pages.pin(zig_pt) orelse + return .invalid_value; if (out_ref) |out| out.* = grid_ref_c.CGridRef.fromPin(p); return .success; } -pub fn read_text( +pub fn point_from_grid_ref( terminal_: Terminal, - selection: Selection, - alloc_: ?*const CAllocator, - out_ptr: *?[*]u8, - out_len: *usize, + ref: *const grid_ref_c.CGridRef, + tag: point.Tag, + out: ?*point.Coordinate, ) callconv(lib.calling_conv) Result { - out_ptr.* = null; - out_len.* = 0; - const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; - const alloc = lib.alloc.default(alloc_); - const sel = selection.toZig(t) orelse return .invalid_value; - - var formatter: formatterpkg.ScreenFormatter = .init(t.screens.active, .{ - .emit = .plain, - .unwrap = true, - .trim = true, - }); - formatter.content = .{ .selection = sel }; - - var aw: std.Io.Writer.Allocating = .init(alloc); - defer aw.deinit(); - formatter.format(&aw.writer) catch return .out_of_memory; - - const buf = aw.toOwnedSlice() catch return .out_of_memory; - out_ptr.* = buf.ptr; - out_len.* = buf.len; + const p = ref.toPin() orelse return .invalid_value; + const pt = t.screens.active.pages.pointFromPin(tag, p) orelse + return .no_value; + if (out) |o| o.* = pt.coord(); return .success; } @@ -983,6 +1052,29 @@ test "vt_write split escape sequence" { try testing.expectEqualStrings("Hello Bold", str); } +test "vt_write split combining mark after base at right edge" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 2, + .rows = 2, + .max_scrollback = 0, + }, + )); + defer free(t); + + // Put "å" in the final column, then send its combining low line in a + // separate write so the mark arrives while the cursor has a pending wrap. + vt_write(t, "xå", 3); + vt_write(t, "\xcc\xb2", 2); + + const str = try t.?.terminal.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("xå̲", str); +} + test "get cols and rows" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -1244,6 +1336,102 @@ test "grid_ref null terminal" { }, &out_ref)); } +test "point_from_grid_ref roundtrip active" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + // Get a grid ref at (2, 0) in active coords + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .active, + .value = .{ .active = .{ .x = 2, .y = 0 } }, + }, &ref)); + + // Convert back to active coords + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .active, &coord)); + try testing.expectEqual(@as(size.CellCountInt, 2), coord.x); + try testing.expectEqual(@as(u32, 0), coord.y); +} + +test "point_from_grid_ref roundtrip viewport" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 10_000 }, + )); + defer free(t); + + vt_write(t, "Hello", 5); + + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .viewport, + .value = .{ .viewport = .{ .x = 0, .y = 0 } }, + }, &ref)); + + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .viewport, &coord)); + try testing.expectEqual(@as(size.CellCountInt, 0), coord.x); + try testing.expectEqual(@as(u32, 0), coord.y); +} + +test "point_from_grid_ref history ref to active returns no_value" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 4, .max_scrollback = 10_000 }, + )); + defer free(t); + + // Write enough lines to push content into scrollback + for (0..10) |_| { + vt_write(t, "line\n", 5); + } + + // Get a ref to the first line (now in scrollback) + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.success, grid_ref(t, .{ + .tag = .screen, + .value = .{ .screen = .{ .x = 0, .y = 0 } }, + }, &ref)); + + // Should succeed for screen coords + var coord: point.Coordinate = undefined; + try testing.expectEqual(Result.success, point_from_grid_ref(t, &ref, .screen, &coord)); + try testing.expectEqual(@as(u32, 0), coord.y); + + // Should fail for active coords (it's in scrollback) + try testing.expectEqual(Result.no_value, point_from_grid_ref(t, &ref, .active, &coord)); +} + +test "point_from_grid_ref null terminal" { + var ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, point_from_grid_ref(null, &ref, .active, null)); +} + +test "point_from_grid_ref null node" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer free(t); + + const ref: grid_ref_c.CGridRef = .{}; + try testing.expectEqual(Result.invalid_value, point_from_grid_ref(t, &ref, .active, null)); +} + test "set write_pty callback" { var t: Terminal = null; try testing.expectEqual(Result.success, new( @@ -2410,3 +2598,49 @@ test "set color sets dirty flag" { try testing.expectEqual(Result.success, set(t, .color_foreground, @ptrCast(&fg))); try testing.expect(zt.flags.dirty.palette); } + +test "get_multi success" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer free(t); + + var cols: u16 = 0; + var rows: u16 = 0; + var written: usize = 0; + + const keys = [_]TerminalData{ .cols, .rows }; + var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&rows) }; + try testing.expectEqual(Result.success, get_multi(t, keys.len, &keys, &values, &written)); + try testing.expectEqual(keys.len, written); + try testing.expectEqual(80, cols); + try testing.expectEqual(24, rows); +} + +test "get_multi error sets out_written" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ .cols = 80, .rows = 24, .max_scrollback = 0 }, + )); + defer free(t); + + var cols: u16 = 0; + var written: usize = 99; + + const keys = [_]TerminalData{ .cols, .invalid }; + var values = [_]?*anyopaque{ @ptrCast(&cols), @ptrCast(&cols) }; + try testing.expectEqual(Result.invalid_value, get_multi(t, keys.len, &keys, &values, &written)); + try testing.expectEqual(1, written); + try testing.expectEqual(80, cols); +} + +test "get_multi null keys returns invalid_value" { + var cols: u16 = 0; + var values = [_]?*anyopaque{@ptrCast(&cols)}; + try testing.expectEqual(Result.invalid_value, get_multi(null, 1, null, &values, null)); +} diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig index 51184b47..500809d9 100644 --- a/vendor/libghostty-vt/src/terminal/c/types.zig +++ b/vendor/libghostty-vt/src/terminal/c/types.zig @@ -13,6 +13,7 @@ const size_report = @import("size_report.zig"); const terminal = @import("terminal.zig"); const formatter = @import("formatter.zig"); +const selection = @import("selection.zig"); const render = @import("render.zig"); const style_c = @import("style.zig"); const mouse_encode = @import("mouse_encode.zig"); @@ -26,6 +27,7 @@ pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{ .{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) }, .{ "GhosttyDeviceAttributesTertiary", StructInfo.init(terminal.DeviceAttributes.Tertiary) }, .{ "GhosttyFormatterTerminalOptions", StructInfo.init(formatter.TerminalOptions) }, + .{ "GhosttySelection", StructInfo.init(selection.CSelection) }, .{ "GhosttyFormatterTerminalExtra", StructInfo.init(formatter.TerminalOptions.Extra) }, .{ "GhosttyFormatterScreenExtra", StructInfo.init(formatter.ScreenOptions.Extra) }, .{ "GhosttyGridRef", StructInfo.init(grid_ref.CGridRef) }, @@ -41,7 +43,6 @@ pub const structs: std.StaticStringMap(StructInfo) = .initComptime(.{ .{ "GhosttyTerminalOptions", StructInfo.init(terminal.Options) }, .{ "GhosttyTerminalScrollbar", StructInfo.init(terminal.TerminalScrollbar) }, .{ "GhosttyTerminalScrollViewport", StructInfo.init(terminal.ScrollViewport) }, - .{ "GhosttyTerminalSelection", StructInfo.init(terminal.Selection) }, }); /// The comptime-generated JSON string of all structs. diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig index c710f81a..6659cd31 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig @@ -25,6 +25,7 @@ pub const unicode = @import("graphics_unicode.zig"); pub const Command = command.Command; pub const CommandParser = command.Parser; pub const Image = image.Image; +pub const LoadingImage = image.LoadingImage; pub const ImageStorage = storage.ImageStorage; pub const RenderPlacement = render.Placement; pub const Response = command.Response; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig index dfce56e3..aa35fbeb 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig @@ -3,6 +3,7 @@ const assert = @import("../../quirks.zig").inlineAssert; const Allocator = std.mem.Allocator; const ArenaAllocator = std.heap.ArenaAllocator; const simd = @import("../../simd/main.zig"); +const lib = @import("../lib.zig"); const log = std.log.scoped(.kitty_gfx); @@ -37,6 +38,10 @@ pub const Parser = struct { /// here instead of a fixed buffer so that we can too. data: std.ArrayList(u8), + /// Maximum bytes the data payload can take. This is to prevent + /// malicious input from causing us to allocate too much memory. + max_bytes: usize, + /// Internal state for parsing. state: State, @@ -54,7 +59,7 @@ pub const Parser = struct { /// Initialize the parser. The allocator given will be used for both /// temporary data and long-lived values such as the final image blob. - pub fn init(alloc: Allocator) Parser { + pub fn init(alloc: Allocator, max_bytes: usize) Parser { var arena = ArenaAllocator.init(alloc); errdefer arena.deinit(); var result: Parser = .{ @@ -63,6 +68,7 @@ pub const Parser = struct { .kv = .{}, .kv_temp_len = 0, .kv_current = 0, + .max_bytes = max_bytes, .state = .control_key, .kv_temp = undefined, @@ -83,7 +89,7 @@ pub const Parser = struct { /// Parse a complete command string. pub fn parseString(alloc: Allocator, data: []const u8) !Command { - var parser = init(alloc); + var parser = init(alloc, 1024 * 1024); defer parser.deinit(); for (data) |c| try parser.feed(c); return try parser.complete(alloc); @@ -136,7 +142,10 @@ pub const Parser = struct { else => {}, }, - .data => try self.data.append(self.arena.child_allocator, c), + .data => { + if (self.data.items.len >= self.max_bytes) return error.OutOfMemory; + try self.data.append(self.arena.child_allocator, c); + }, } } @@ -394,39 +403,38 @@ pub const Transmission = struct { compression: Compression = .none, // o more_chunks: bool = false, // m - pub const Format = enum { - rgb, // 24 - rgba, // 32 - png, // 100 - + pub const Format = lib.Enum(lib.target, &.{ + "rgb", // 24 + "rgba", // 32 + "png", // 100 // The following are not supported directly via the protocol // but they are formats that a png may decode to that we // support. - gray_alpha, - gray, - - pub fn bpp(self: Format) u8 { - return switch (self) { - .gray => 1, - .gray_alpha => 2, - .rgb => 3, - .rgba => 4, - .png => unreachable, // Must be validated before - }; - } - }; - - pub const Medium = enum { - direct, // d - file, // f - temporary_file, // t - shared_memory, // s - }; - - pub const Compression = enum { - none, - zlib_deflate, // z - }; + "gray_alpha", + "gray", + }); + + pub const Medium = lib.Enum(lib.target, &.{ + "direct", // d + "file", // f + "temporary_file", // t + "shared_memory", // s + }); + + pub const Compression = lib.Enum(lib.target, &.{ + "none", + "zlib_deflate", // z + }); + + pub fn formatBpp(format: Format) u8 { + return switch (format) { + .gray => 1, + .gray_alpha => 2, + .rgb => 3, + .rgba => 4, + .png => unreachable, // Must be validated before + }; + } fn parse(kv: KV) !Transmission { var result: Transmission = .{}; @@ -964,7 +972,7 @@ pub const CompositionMode = enum { test "transmission command" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "f=24,s=10,v=20"; @@ -982,7 +990,7 @@ test "transmission command" { test "transmission ignores 'm' if medium is not direct" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=t,t=t,m=1"; @@ -999,7 +1007,7 @@ test "transmission ignores 'm' if medium is not direct" { test "transmission respects 'm' if medium is direct" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=t,t=d,m=1"; @@ -1016,7 +1024,7 @@ test "transmission respects 'm' if medium is direct" { test "query command" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "i=31,s=1,v=1,a=q,t=d,f=24;QUFBQQ"; @@ -1036,7 +1044,7 @@ test "query command" { test "display command" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,U=1,i=31,c=80,r=120"; @@ -1054,7 +1062,7 @@ test "display command" { test "delete command" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=d,d=p,x=3,y=4"; @@ -1074,7 +1082,7 @@ test "delete command" { test "no control data" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = ";QUFBQQ"; @@ -1089,7 +1097,7 @@ test "no control data" { test "ignore unknown keys (long)" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "f=24,s=10,v=20,hello=world"; @@ -1107,7 +1115,7 @@ test "ignore unknown keys (long)" { test "ignore very long values" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "f=24,s=10,v=2000000000000000000000000000000000000000"; @@ -1125,7 +1133,7 @@ test "ignore very long values" { test "ensure very large negative values don't get skipped" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,i=1,z=-2000000000"; @@ -1142,7 +1150,7 @@ test "ensure very large negative values don't get skipped" { test "ensure proper overflow error for u32" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,i=10000000000"; @@ -1153,7 +1161,7 @@ test "ensure proper overflow error for u32" { test "ensure proper overflow error for i32" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,i=1,z=-9999999999"; @@ -1167,7 +1175,7 @@ test "all i32 values" { { // 'z' (usually z-axis values) - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,i=1,z=-1"; for (input) |c| try p.feed(c); @@ -1182,7 +1190,7 @@ test "all i32 values" { { // 'H' (relative placement, horizontal offset) - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,i=1,H=-1"; for (input) |c| try p.feed(c); @@ -1197,7 +1205,7 @@ test "all i32 values" { { // 'V' (relative placement, vertical offset) - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=p,i=1,V=-1"; for (input) |c| try p.feed(c); @@ -1254,7 +1262,7 @@ test "response: encode with image ID and number" { test "delete range command 1" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=d,d=r,x=3,y=4"; @@ -1274,7 +1282,7 @@ test "delete range command 1" { test "delete range command 2" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=d,d=R,x=5,y=11"; @@ -1294,7 +1302,7 @@ test "delete range command 2" { test "delete range command 3" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=d,d=R,x=5,y=4"; @@ -1305,7 +1313,7 @@ test "delete range command 3" { test "delete range command 4" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=d,d=R,x=5"; @@ -1316,7 +1324,7 @@ test "delete range command 4" { test "delete range command 5" { const testing = std.testing; const alloc = testing.allocator; - var p = Parser.init(alloc); + var p = Parser.init(alloc, 1024 * 1024); defer p.deinit(); const input = "a=d,d=R,y=5"; diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig index 5b3ab915..a6a879e5 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig @@ -44,7 +44,7 @@ pub fn execute( var quiet = cmd.quiet; const resp_: ?Response = switch (cmd.control) { - .query => query(alloc, cmd), + .query => query(alloc, terminal, cmd), .display => display(alloc, terminal, cmd), .delete => delete(alloc, terminal, cmd), @@ -94,7 +94,11 @@ pub fn execute( /// This command is used to attempt to load an image and respond with /// success/error but does not persist any of the command to the terminal /// state. -fn query(alloc: Allocator, cmd: *const Command) Response { +fn query( + alloc: Allocator, + terminal: *const Terminal, + cmd: *const Command, +) Response { const t = cmd.control.query; // Query requires image ID. We can't actually send a response without @@ -112,7 +116,8 @@ fn query(alloc: Allocator, cmd: *const Command) Response { }; // Attempt to load the image. If we cannot, then set an appropriate error. - var loading = LoadingImage.init(alloc, cmd) catch |err| { + const storage = &terminal.screens.active.kitty_images; + var loading = LoadingImage.init(alloc, cmd, storage.image_limits) catch |err| { encodeError(&result, err); return result; }; @@ -322,7 +327,7 @@ fn loadAndAddImage( } break :loading loading.*; - } else try .init(alloc, cmd); + } else try .init(alloc, cmd, storage.image_limits); // We only want to deinit on error. If we're chunking, then we don't // want to deinit at all. If we're not chunking, then we'll deinit diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig index d2877cfc..8243a632 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig @@ -8,7 +8,7 @@ const posix = std.posix; const fastmem = @import("../../fastmem.zig"); const command = @import("graphics_command.zig"); const PageList = @import("../PageList.zig"); -const wuffs = @import("wuffs"); +const sys = @import("../sys.zig"); const temp_dir = struct { const TempDir = @import("../../os/TempDir.zig"); @@ -44,10 +44,38 @@ pub const LoadingImage = struct { /// used if q isn't set on subsequent chunks. quiet: command.Command.Quiet, + /// The limits of the Kitty Graphics protocol we should allow. + /// + /// This can be used to restrict the type of images and other + /// parameters for resource or security reasons. Note that depending + /// on how libghostty is compiled, some of these may be fully unsupported + /// and ignored (e.g. "file" on wasm32-freestanding). + pub const Limits = packed struct { + file: bool, + temporary_file: bool, + shared_memory: bool, + + pub const all: Limits = .{ + .file = true, + .temporary_file = true, + .shared_memory = true, + }; + + pub const direct: Limits = .{ + .file = false, + .temporary_file = false, + .shared_memory = false, + }; + }; + /// Initialize a chunked immage from the first image transmission. /// If this is a multi-chunk image, this should only be the FIRST /// chunk. - pub fn init(alloc: Allocator, cmd: *const command.Command) !LoadingImage { + pub fn init( + alloc: Allocator, + cmd: *const command.Command, + limits: Limits, + ) !LoadingImage { // Build our initial image from the properties sent via the control. // These can be overwritten by the data loading process. For example, // PNG loading sets the width/height from the data. @@ -72,6 +100,26 @@ pub const LoadingImage = struct { return result; } + // Verify our capabilities and limits allow this. + { + // Special case if we don't support decoding PNGs and the format + // is a PNG we can save a lot of memory/effort buffering the + // data but failing up front. + if (t.format == .png and + sys.decode_png == null) + { + return error.UnsupportedMedium; + } + + // Verify the medium is allowed + switch (t.medium) { + .direct => unreachable, + .file => if (!limits.file) return error.UnsupportedMedium, + .temporary_file => if (!limits.temporary_file) return error.UnsupportedMedium, + .shared_memory => if (!limits.shared_memory) return error.UnsupportedMedium, + } + } + // Otherwise, the payload data is guaranteed to be a path. if (comptime builtin.os.tag != .windows) { @@ -111,8 +159,9 @@ pub const LoadingImage = struct { t: command.Transmission, path: []const u8, ) !void { + // android does not support POSIX shared memory. // windows is currently unsupported, does it support shm? - if (comptime builtin.target.os.tag == .windows) { + if (comptime builtin.abi.isAndroid() or builtin.target.os.tag == .windows) { return error.UnsupportedMedium; } @@ -154,8 +203,8 @@ pub const LoadingImage = struct { .png => stat_size, // For these formats we have a size we must have. - .gray, .gray_alpha, .rgb, .rgba => |f| size: { - const bpp = f.bpp(); + .gray, .gray_alpha, .rgb, .rgba => size: { + const bpp = command.Transmission.formatBpp(self.image.format); break :size self.image.width * self.image.height * bpp; }, }; @@ -281,17 +330,16 @@ pub const LoadingImage = struct { fn isPathInTempDir(path: []const u8) bool { if (std.mem.startsWith(u8, path, "/tmp")) return true; if (std.mem.startsWith(u8, path, "/dev/shm")) return true; - if (temp_dir.allocTmpDir(std.heap.page_allocator)) |dir| { - defer temp_dir.freeTmpDir(std.heap.page_allocator, dir); - if (std.mem.startsWith(u8, path, dir)) return true; - - // The temporary dir is sometimes a symlink. On macOS for - // example /tmp is /private/var/... - var buf: [std.fs.max_path_bytes]u8 = undefined; - if (posix.realpath(dir, &buf)) |real_dir| { - if (std.mem.startsWith(u8, path, real_dir)) return true; - } else |_| {} - } + const dir = temp_dir.allocTmpDir(std.heap.page_allocator) catch return false; + defer temp_dir.freeTmpDir(std.heap.page_allocator, dir); + if (std.mem.startsWith(u8, path, dir)) return true; + + // The temporary dir is sometimes a symlink. On macOS for + // example /tmp is /private/var/... + var buf: [std.fs.max_path_bytes]u8 = undefined; + if (posix.realpath(dir, &buf)) |real_dir| { + if (std.mem.startsWith(u8, path, real_dir)) return true; + } else |_| {} return false; } @@ -342,7 +390,7 @@ pub const LoadingImage = struct { if (img.width > max_dimension or img.height > max_dimension) return error.DimensionsTooLarge; // Data length must be what we expect - const bpp = img.format.bpp(); + const bpp = command.Transmission.formatBpp(img.format); const expected_len = img.width * img.height * bpp; const actual_len = self.data.items.len; if (actual_len != expected_len) { @@ -426,13 +474,14 @@ pub const LoadingImage = struct { fn decodePng(self: *LoadingImage, alloc: Allocator) !void { assert(self.image.format == .png); - const result = wuffs.png.decode( + const decode_png_fn = sys.decode_png orelse + return error.UnsupportedFormat; + const result = decode_png_fn( alloc, self.data.items, ) catch |err| switch (err) { - error.WuffsError => return error.InvalidData, + error.InvalidData => return error.InvalidData, error.OutOfMemory => return error.OutOfMemory, - error.Overflow => return error.InvalidData, }; defer alloc.free(result.data); @@ -522,7 +571,7 @@ test "image load with invalid RGB data" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); } @@ -540,7 +589,7 @@ test "image load with image too wide" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -559,7 +608,7 @@ test "image load with image too tall" { .data = try alloc.dupe(u8, "AAAA"), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); try testing.expectError(error.DimensionsTooLarge, loading.complete(alloc)); } @@ -583,7 +632,7 @@ test "image load: rgb, zlib compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -611,7 +660,7 @@ test "image load: rgb, not compressed, direct" { ), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -640,7 +689,7 @@ test "image load: rgb, zlib compressed, direct, chunked" { .data = try alloc.dupe(u8, data[0..1024]), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); // Read our remaining chunks @@ -676,7 +725,7 @@ test "image load: rgb, zlib compressed, direct, chunked with zero initial chunk" } }, }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .direct); defer loading.deinit(alloc); // Read our remaining chunks @@ -720,7 +769,7 @@ test "image load: temporary file without correct path" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd)); + try testing.expectError(error.TemporaryFileNotNamedCorrectly, LoadingImage.init(alloc, &cmd, .all)); // Temporary file should still be there try tmp_dir.dir.access(path, .{}); @@ -753,7 +802,7 @@ test "image load: rgb, not compressed, temporary file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -790,7 +839,7 @@ test "image load: rgb, not compressed, regular file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -799,6 +848,8 @@ test "image load: rgb, not compressed, regular file" { } test "image load: png, not compressed, regular file" { + if (sys.decode_png == null) return error.SkipZigTest; + const testing = std.testing; const alloc = testing.allocator; @@ -825,7 +876,7 @@ test "image load: png, not compressed, regular file" { .data = try alloc.dupe(u8, path), }; defer cmd.deinit(alloc); - var loading = try LoadingImage.init(alloc, &cmd); + var loading = try LoadingImage.init(alloc, &cmd, .all); defer loading.deinit(alloc); var img = try loading.complete(alloc); defer img.deinit(alloc); @@ -833,3 +884,161 @@ test "image load: png, not compressed, regular file" { try testing.expect(img.format == .rgba); try tmp_dir.dir.access(path, .{}); } + +test "limits: direct medium always allowed" { + const testing = std.testing; + const alloc = testing.allocator; + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .direct, + .width = 1, + .height = 1, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, "AAAA"), + }; + defer cmd.deinit(alloc); + + // Direct medium should work even with the most restrictive limits + var loading = try LoadingImage.init(alloc, &cmd, .direct); + defer loading.deinit(alloc); +} + +test "limits: file medium blocked by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .direct)); +} + +test "limits: file medium allowed by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd, .{ + .file = true, + .temporary_file = false, + .shared_memory = false, + }); + defer loading.deinit(alloc); +} + +test "limits: temporary file medium blocked by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + try testing.expectError(error.UnsupportedMedium, LoadingImage.init(alloc, &cmd, .{ + .file = true, + .temporary_file = false, + .shared_memory = true, + })); + + // File should still exist since we blocked before reading + try tmp_dir.dir.access("tty-graphics-protocol-image.data", .{}); +} + +test "limits: temporary file medium allowed by limits" { + const testing = std.testing; + const alloc = testing.allocator; + + var tmp_dir = try temp_dir.TempDir.init(); + defer tmp_dir.deinit(); + const data = @embedFile("testdata/image-rgb-none-20x15-2147483647-raw.data"); + try tmp_dir.dir.writeFile(.{ + .sub_path = "tty-graphics-protocol-image.data", + .data = data, + }); + + var buf: [std.fs.max_path_bytes]u8 = undefined; + const path = try tmp_dir.dir.realpath("tty-graphics-protocol-image.data", &buf); + + var cmd: command.Command = .{ + .control = .{ .transmit = .{ + .format = .rgb, + .medium = .temporary_file, + .compression = .none, + .width = 20, + .height = 15, + .image_id = 31, + } }, + .data = try alloc.dupe(u8, path), + }; + defer cmd.deinit(alloc); + var loading = try LoadingImage.init(alloc, &cmd, .{ + .file = false, + .temporary_file = true, + .shared_memory = false, + }); + defer loading.deinit(alloc); +} diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig index 8ff68e3f..e017d5f7 100644 --- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig +++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig @@ -51,6 +51,9 @@ pub const ImageStorage = struct { /// Non-null if there is an in-progress loading image. loading: ?*LoadingImage = null, + /// The limits of what medium types are allowed for image loading. + image_limits: LoadingImage.Limits = .direct, + /// The total bytes of image data that have been loaded and the limit. /// If the limit is reached, the oldest images will be evicted to make /// space. Unused images take priority. @@ -89,8 +92,9 @@ pub const ImageStorage = struct { ) !void { // Special case disabling by quickly deleting all if (limit == 0) { + const image_limits = self.image_limits; self.deinit(alloc, s); - self.* = .{}; + self.* = .{ .image_limits = image_limits }; } // If we re lowering our limit, check if we need to evict. @@ -658,9 +662,10 @@ pub const ImageStorage = struct { } } - /// Calculates the size of this placement's image in pixels, - /// taking in to account the specified rows and columns. - pub fn calculatedSize( + /// Returns the size of this placement's image in pixels, + /// taking into account the source rectangle, specified + /// rows/columns, and aspect ratio. + pub fn pixelSize( self: Placement, image: Image, t: *const terminal.Terminal, @@ -755,7 +760,7 @@ pub const ImageStorage = struct { // Otherwise we calculate the pixel size, divide by // cell size, and round up to the nearest integer. - const calc_size = self.calculatedSize(image, t); + const calc_size = self.pixelSize(image, t); return .{ .cols = std.math.divCeil( u32, @@ -1334,7 +1339,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" { // that's 100px width. 100px * (9 / 16) = 56.25, which should round // to a height of 56px. - const calc_size = placement.calculatedSize(image, &t); + const calc_size = placement.pixelSize(image, &t); try testing.expectEqual(@as(u32, 100), calc_size.width); try testing.expectEqual(@as(u32, 56), calc_size.height); } @@ -1352,7 +1357,7 @@ test "storage: aspect ratio calculation when only columns or rows specified" { // 100px height. 100px * (16 / 9) = 177.77..., which should round to // a width of 178px. - const calc_size = placement.calculatedSize(image, &t); + const calc_size = placement.pixelSize(image, &t); try testing.expectEqual(@as(u32, 178), calc_size.width); try testing.expectEqual(@as(u32, 100), calc_size.height); } diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig index 9f5b65e3..87a9aded 100644 --- a/vendor/libghostty-vt/src/terminal/main.zig +++ b/vendor/libghostty-vt/src/terminal/main.zig @@ -23,6 +23,7 @@ pub const search = @import("search.zig"); pub const sgr = @import("sgr.zig"); pub const size = @import("size.zig"); pub const size_report = @import("size_report.zig"); +pub const sys = @import("sys.zig"); pub const tmux = if (options.tmux_control_mode) @import("tmux.zig") else struct {}; pub const x11_color = @import("x11_color.zig"); diff --git a/vendor/libghostty-vt/src/terminal/modes.zig b/vendor/libghostty-vt/src/terminal/modes.zig index 0d8cde5b..c92f97b0 100644 --- a/vendor/libghostty-vt/src/terminal/modes.zig +++ b/vendor/libghostty-vt/src/terminal/modes.zig @@ -270,6 +270,11 @@ const entries: []const ModeEntry = &.{ .{ .name = "reverse_wrap", .value = 45 }, .{ .name = "alt_screen_legacy", .value = 47 }, .{ .name = "keypad_keys", .value = 66 }, + // DEC Backarrow Key Mode (DECBKM) + // See https://vt100.net/dec/ek-vt3xx-tp-002.pdf page 170 + // If `false` (the default), `backspace` emits 0x7f + // If `true`, `backspace` emits 0x08 + .{ .name = "backarrow_key_mode", .value = 67 }, .{ .name = "enable_left_and_right_margin", .value = 69 }, .{ .name = "mouse_event_normal", .value = 1000 }, .{ .name = "mouse_event_button", .value = 1002 }, diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig index 8e0f9111..f68f088b 100644 --- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig +++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig @@ -1,5 +1,7 @@ const std = @import("std"); +const build_options = @import("terminal_options"); const testing = std.testing; +const apc = @import("apc.zig"); const csi = @import("csi.zig"); const device_attributes = @import("device_attributes.zig"); const device_status = @import("device_status.zig"); @@ -35,6 +37,12 @@ pub const Handler = struct { /// effects. effects: Effects = .readonly, + /// The APC command handler maintains the APC state. APC is like + /// CSI or OSC, but it is a private escape sequence that is used + /// to send commands to the terminal emulator. This is used by + /// the kitty graphics protocol. + apc_handler: apc.Handler = .{}, + pub const Effects = struct { /// Called when the terminal needs to write data back to the pty, /// e.g. in response to a DECRQM query. The data is only valid @@ -98,9 +106,7 @@ pub const Handler = struct { } pub fn deinit(self: *Handler) void { - // Currently does nothing but may in the future so callers should - // call this. - _ = self; + self.apc_handler.deinit(); } pub fn vt( @@ -230,6 +236,11 @@ pub const Handler = struct { .color_operation => try self.colorOperation(value.op, &value.requests), .kitty_color_report => try self.kittyColorOperation(value), + // APC + .apc_start => self.apc_handler.start(), + .apc_put => self.apc_handler.feed(self.terminal.gpa(), value), + .apc_end => self.apcEnd(), + // Effect-based handlers .bell => self.bell(), .device_attributes => self.reportDeviceAttributes(value), @@ -249,13 +260,6 @@ pub const Handler = struct { .dcs_unhook, => {}, - // APC can modify terminal state (Kitty graphics) but we don't - // currently support it in the readonly stream. - .apc_start, - .apc_end, - .apc_put, - => {}, - // Have no terminal-modifying effect .report_pwd, .show_desktop_notification, @@ -650,6 +654,33 @@ pub const Handler = struct { } } } + + fn apcEnd(self: *Handler) void { + const alloc = self.terminal.gpa(); + var cmd = self.apc_handler.end() orelse return; + defer cmd.deinit(alloc); + + switch (cmd) { + .kitty => |*kitty_cmd| if (comptime build_options.kitty_graphics) { + if (self.terminal.kittyGraphics( + alloc, + kitty_cmd, + )) |resp| resp: { + // Don't waste time encoding if we can't write responses + // anyways. + if (self.effects.write_pty == null) break :resp; + + // Encode and write the response if we have one. + var buf: [1024]u8 = undefined; + var writer: std.Io.Writer = .fixed(&buf); + resp.encode(&writer) catch return; + writer.writeByte(0) catch return; + const final = writer.buffered(); + if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]); + } + }, + } + } }; test "basic print" { @@ -2069,3 +2100,52 @@ test "device attributes: custom response" { s.nextSlice("\x1B[>c"); try testing.expectEqualStrings("\x1b[>41;100;0c", S.written.?); } + +test "kitty graphics APC response" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const S = struct { + var written: ?[]const u8 = null; + fn writePty(_: *Handler, data: [:0]const u8) void { + if (written) |old| testing.allocator.free(old); + written = testing.allocator.dupe(u8, data) catch @panic("OOM"); + } + }; + S.written = null; + defer if (S.written) |old| testing.allocator.free(old); + + var handler: Handler = .init(&t); + handler.effects.write_pty = &S.writePty; + + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Send a kitty graphics transmit command with image id 1 + s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\"); + + // Should have written a response back + try testing.expectEqualStrings("\x1b_Gi=1;OK\x1b\\", S.written.?); +} + +test "kitty graphics via APC" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + const handler: Handler = .init(&t); + var s: Stream = .initAlloc(testing.allocator, handler); + defer s.deinit(); + + // Send a kitty graphics transmit command via APC: + // ESC _ G <payload> ESC \ + // a=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;//////// (1x2 RGB direct) + s.nextSlice("\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2,c=10,r=1;////////\x1b\\"); + + const storage = &t.screens.active.kitty_images; + const img = storage.imageById(1).?; + try testing.expectEqual(.rgb, img.format); +} diff --git a/vendor/libghostty-vt/src/terminal/sys.zig b/vendor/libghostty-vt/src/terminal/sys.zig new file mode 100644 index 00000000..f0c64da5 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/sys.zig @@ -0,0 +1,54 @@ +//! System interface for the terminal package. +//! +//! This provides runtime-swappable function pointers for operations that +//! depend on external implementations (e.g. image decoding). Each function +//! pointer is initialized with a default implementation if available. +//! +//! This exists so that the terminal package doesn't have hard dependencies +//! on specific libraries and enables embedders of the terminal package to +//! swap out implementations as needed at startup to provide their own +//! implementations. +const std = @import("std"); +const Allocator = std.mem.Allocator; +const build_options = @import("terminal_options"); + +/// Decode PNG data into RGBA pixels. If null, PNG decoding is unsupported +/// and the exact semantics are up to callers. For example, the Kitty Graphics +/// Protocol will work but cannot accept PNG images. +pub var decode_png: ?DecodePngFn = png: { + if (build_options.artifact == .lib) break :png null; + break :png &decodePngWuffs; +}; + +pub const DecodeError = Allocator.Error || error{InvalidData}; +pub const DecodePngFn = *const fn (Allocator, []const u8) DecodeError!Image; + +/// The result of decoding an image. The caller owns the returned data +/// and must free it with the same allocator that was passed to the +/// decode function. +pub const Image = struct { + width: u32, + height: u32, + data: []u8, +}; + +fn decodePngWuffs( + alloc: Allocator, + data: []const u8, +) DecodeError!Image { + const wuffs = @import("wuffs"); + const result = wuffs.png.decode( + alloc, + data, + ) catch |err| switch (err) { + error.WuffsError => return error.InvalidData, + error.OutOfMemory => return error.OutOfMemory, + error.Overflow => return error.InvalidData, + }; + + return .{ + .width = result.width, + .height = result.height, + .data = result.data, + }; +} |
