From 6ac6c2675218b75f376b2dc9215b62de0ea1c022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Mon, 2 Mar 2026 12:19:36 +0100 Subject: [PATCH 1/2] feat: add comprehensive image support with caching, in-memory data, z-index, and protocol selection --- src/core/command.zig | 116 ++++++++++ src/core/context.zig | 82 +++++++ src/core/program.zig | 149 +++++++++++-- src/root.zig | 12 ++ src/terminal/terminal.zig | 442 +++++++++++++++++++++++++++++++++++--- 5 files changed, 762 insertions(+), 39 deletions(-) diff --git a/src/core/command.zig b/src/core/command.zig index 9743d67..bb504aa 100644 --- a/src/core/command.zig +++ b/src/core/command.zig @@ -15,6 +15,25 @@ pub const ImagePlacement = enum { center, }; +/// Preferred image protocol for rendering. +pub const ImageProtocol = enum { + /// Auto-select best available (Kitty > iTerm2 > Sixel). + auto, + /// Force Kitty graphics protocol. + kitty, + /// Force iTerm2 inline image protocol. + iterm2, + /// Force Sixel graphics protocol. + sixel, +}; + +/// Pixel format for in-memory image data. +pub const ImageFormat = enum(u16) { + rgb = 24, + rgba = 32, + png = 100, +}; + /// Parameters for image rendering by file path. pub const ImageFile = struct { path: []const u8, @@ -34,6 +53,87 @@ pub const ImageFile = struct { placement_id: ?u32 = null, move_cursor: bool = true, quiet: bool = true, + /// Preferred protocol (default: auto-select). + protocol: ImageProtocol = .auto, + /// Z-index for layering (Kitty only). Negative = behind text. + z_index: ?i32 = null, + /// Enable unicode placeholders (Kitty only). Images participate in text reflow. + unicode_placeholder: bool = false, +}; + +/// Parameters for rendering in-memory image data. +pub const ImageData = struct { + /// Raw pixel data (RGB, RGBA, or PNG bytes). + data: []const u8, + /// Pixel format of the data. + format: ImageFormat = .png, + /// Pixel width of the image (required for RGB/RGBA). + pixel_width: ?u32 = null, + /// Pixel height of the image (required for RGB/RGBA). + pixel_height: ?u32 = null, + width_cells: ?u16 = null, + height_cells: ?u16 = null, + placement: ImagePlacement = .top_left, + row: ?u16 = null, + col: ?u16 = null, + row_offset: i16 = 0, + col_offset: i16 = 0, + image_id: ?u32 = null, + placement_id: ?u32 = null, + move_cursor: bool = true, + quiet: bool = true, + protocol: ImageProtocol = .auto, + z_index: ?i32 = null, + unicode_placeholder: bool = false, +}; + +/// Transmit an image to the terminal without displaying it (Kitty cache). +pub const CacheImage = struct { + /// File path or in-memory data source. + source: ImageSource, + /// Unique image ID for later reference. Required. + image_id: u32, + /// Pixel format (only for in-memory data). + format: ImageFormat = .png, + /// Pixel width (required for RGB/RGBA in-memory data). + pixel_width: ?u32 = null, + /// Pixel height (required for RGB/RGBA in-memory data). + pixel_height: ?u32 = null, + quiet: bool = true, +}; + +/// Source for an image: file path or in-memory bytes. +pub const ImageSource = union(enum) { + file: []const u8, + data: []const u8, +}; + +/// Display a previously cached image by ID (Kitty virtual placement). +pub const PlaceCachedImage = struct { + /// Image ID from a previous cache_image command. + image_id: u32, + placement_id: ?u32 = null, + width_cells: ?u16 = null, + height_cells: ?u16 = null, + placement: ImagePlacement = .top_left, + row: ?u16 = null, + col: ?u16 = null, + row_offset: i16 = 0, + col_offset: i16 = 0, + move_cursor: bool = true, + quiet: bool = true, + z_index: ?i32 = null, + unicode_placeholder: bool = false, +}; + +/// Delete a cached image or placement (Kitty). +pub const DeleteImage = union(enum) { + /// Delete all placements of a specific image ID. + by_id: u32, + /// Delete a specific placement of an image. + by_placement: struct { image_id: u32, placement_id: u32 }, + /// Delete all images and placements. + all, }; /// Backward-compatible alias for existing Kitty-only APIs. @@ -87,6 +187,18 @@ pub fn Cmd(comptime Msg: type) type { /// Draw an image file via Kitty graphics protocol (no-op if unsupported) kitty_image_file: KittyImageFile, + /// Draw in-memory image data (RGB, RGBA, or PNG bytes) + image_data: ImageData, + + /// Transmit an image to the terminal cache without displaying (Kitty) + cache_image: CacheImage, + + /// Display a previously cached image (Kitty virtual placement) + place_cached_image: PlaceCachedImage, + + /// Delete a cached image or placement (Kitty) + delete_image: DeleteImage, + const Self = @This(); /// Create a none command @@ -165,6 +277,10 @@ pub const StandardCmd = union(enum) { exit_alt_screen, image_file: ImageFile, kitty_image_file: KittyImageFile, + image_data: ImageData, + cache_image: CacheImage, + place_cached_image: PlaceCachedImage, + delete_image: DeleteImage, }; /// Combine multiple commands into a batch diff --git a/src/core/context.zig b/src/core/context.zig index 86f04ed..168bc6a 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -174,6 +174,48 @@ pub const Context = struct { return false; } + /// Draw in-memory image data via Kitty graphics protocol (`t=d`). + /// Returns false when unsupported or data is empty. + pub fn drawKittyImage(self: *Context, data: []const u8, options: Terminal.KittyImageOptions) !bool { + if (self._terminal) |term| { + return term.drawKittyImage(data, options); + } + return false; + } + + /// Transmit an image to the Kitty cache without displaying. + /// Use `placeCachedImage` later to display by ID. + pub fn transmitKittyImage(self: *Context, payload: []const u8, options: Terminal.KittyTransmitOptions) !bool { + if (self._terminal) |term| { + return term.transmitKittyImage(payload, options); + } + return false; + } + + /// Transmit a file to the Kitty cache without displaying. + pub fn transmitKittyImageFromFile(self: *Context, path: []const u8, options: Terminal.KittyTransmitOptions) !bool { + if (self._terminal) |term| { + return term.transmitKittyImageFromFile(path, options); + } + return false; + } + + /// Display a previously cached Kitty image by ID. + pub fn placeKittyImage(self: *Context, options: Terminal.KittyPlaceOptions) !bool { + if (self._terminal) |term| { + return term.placeKittyImage(options); + } + return false; + } + + /// Delete cached Kitty images/placements. + pub fn deleteKittyImage(self: *Context, target: Terminal.KittyDeleteTarget) !bool { + if (self._terminal) |term| { + return term.deleteKittyImage(target); + } + return false; + } + /// Draw a Sixel image from file (or convert via `img2sixel` when available). /// Returns false when unsupported or path is empty. pub fn drawSixelFromFile(self: *Context, path: []const u8, options: Terminal.SixelImageFileOptions) !bool { @@ -191,6 +233,46 @@ pub const Context = struct { } return false; } + + /// Draw an image file using a specific protocol. + pub fn drawImageFromFileWithProtocol(self: *Context, path: []const u8, options: Terminal.ImageFileOptions, protocol: Terminal.ImageProtocol) !bool { + if (self._terminal) |term| { + return term.drawImageFromFileWithProtocol(path, options, protocol); + } + return false; + } + + /// Draw in-memory image data using the best available protocol. + pub fn drawImageData(self: *Context, data: []const u8, options: Terminal.ImageDataOptions) !bool { + if (self._terminal) |term| { + return term.drawImageData(data, options); + } + return false; + } + + /// Draw in-memory image data using a specific protocol. + pub fn drawImageDataWithProtocol(self: *Context, data: []const u8, options: Terminal.ImageDataOptions, protocol: Terminal.ImageProtocol) !bool { + if (self._terminal) |term| { + return term.drawImageDataWithProtocol(data, options, protocol); + } + return false; + } + + /// Draw in-memory image data via iTerm2 inline image protocol. + pub fn drawIterm2ImageData(self: *Context, data: []const u8, options: Terminal.Iterm2ImageDataOptions) !bool { + if (self._terminal) |term| { + return term.drawIterm2ImageData(data, options); + } + return false; + } + + /// Get the current image capabilities of the terminal. + pub fn getImageCapabilities(self: *const Context) Terminal.ImageCapabilities { + if (self._terminal) |term| { + return term.getImageCapabilities(); + } + return .{}; + } }; /// Options that can be modified during runtime diff --git a/src/core/program.zig b/src/core/program.zig index 5995807..29d39b6 100644 --- a/src/core/program.zig +++ b/src/core/program.zig @@ -19,6 +19,8 @@ pub const Msg = message; const PendingImage = union(enum) { auto: command.ImageFile, kitty: command.KittyImageFile, + data: command.ImageData, + place_cached: command.PlaceCachedImage, }; /// Program runtime that manages the application lifecycle @@ -510,10 +512,53 @@ pub fn Program(comptime Model: type) type { .kitty_image_file => |image| { self.pending_image = .{ .kitty = image }; }, + .image_data => |image| { + self.pending_image = .{ .data = image }; + }, + .cache_image => |cache| { + if (self.terminal) |*term| { + switch (cache.source) { + .file => |path| { + _ = term.transmitKittyImageFromFile(path, .{ + .image_id = cache.image_id, + .format = @enumFromInt(@intFromEnum(cache.format)), + .quiet = cache.quiet, + .pixel_width = cache.pixel_width, + .pixel_height = cache.pixel_height, + }) catch {}; + }, + .data => |data| { + _ = term.transmitKittyImage(data, .{ + .image_id = cache.image_id, + .format = @enumFromInt(@intFromEnum(cache.format)), + .quiet = cache.quiet, + .pixel_width = cache.pixel_width, + .pixel_height = cache.pixel_height, + }) catch {}; + }, + } + term.flush() catch {}; + } + }, + .place_cached_image => |place| { + self.pending_image = .{ .place_cached = place }; + }, + .delete_image => |del| { + if (self.terminal) |*term| { + const target: @import("../terminal/terminal.zig").KittyDeleteTarget = switch (del) { + .by_id => |id| .{ .by_id = id }, + .by_placement => |bp| .{ .by_placement = .{ .image_id = bp.image_id, .placement_id = bp.placement_id } }, + .all => .all, + }; + _ = term.deleteKittyImage(target) catch {}; + term.flush() catch {}; + } + }, } } fn flushPendingImage(self: *Self) !void { + const TerminalMod = @import("../terminal/terminal.zig"); const pending = self.pending_image orelse return; self.pending_image = null; if (self.terminal) |*term| { @@ -523,7 +568,13 @@ pub fn Program(comptime Model: type) type { try term.writer().writeAll(ansi.cursor_save); } try self.positionPendingImage(term, image); - _ = try term.drawImageFromFile(image.path, .{ + const protocol: TerminalMod.ImageProtocol = switch (image.protocol) { + .auto => .auto, + .kitty => .kitty, + .iterm2 => .iterm2, + .sixel => .sixel, + }; + _ = try term.drawImageFromFileWithProtocol(image.path, .{ .width_cells = image.width_cells, .height_cells = image.height_cells, .preserve_aspect_ratio = image.preserve_aspect_ratio, @@ -531,7 +582,9 @@ pub fn Program(comptime Model: type) type { .placement_id = image.placement_id, .move_cursor = image.move_cursor, .quiet = image.quiet, - }); + .z_index = image.z_index, + .unicode_placeholder = image.unicode_placeholder, + }, protocol); if (!image.move_cursor) { try term.writer().writeAll(ansi.cursor_restore); } @@ -548,28 +601,99 @@ pub fn Program(comptime Model: type) type { .placement_id = image.placement_id, .move_cursor = image.move_cursor, .quiet = image.quiet, + .z_index = image.z_index, + .unicode_placeholder = image.unicode_placeholder, }); if (!image.move_cursor) { try term.writer().writeAll(ansi.cursor_restore); } }, + .data => |image| { + if (!image.move_cursor) { + try term.writer().writeAll(ansi.cursor_save); + } + try self.positionPendingImageData(term, image); + const protocol: TerminalMod.ImageProtocol = switch (image.protocol) { + .auto => .auto, + .kitty => .kitty, + .iterm2 => .iterm2, + .sixel => .sixel, + }; + _ = try term.drawImageDataWithProtocol(image.data, .{ + .format = @enumFromInt(@intFromEnum(image.format)), + .pixel_width = image.pixel_width, + .pixel_height = image.pixel_height, + .width_cells = image.width_cells, + .height_cells = image.height_cells, + .image_id = image.image_id, + .placement_id = image.placement_id, + .move_cursor = image.move_cursor, + .quiet = image.quiet, + .z_index = image.z_index, + .unicode_placeholder = image.unicode_placeholder, + }, protocol); + if (!image.move_cursor) { + try term.writer().writeAll(ansi.cursor_restore); + } + }, + .place_cached => |place| { + if (!place.move_cursor) { + try term.writer().writeAll(ansi.cursor_save); + } + try self.positionPendingCachedImage(term, place); + _ = try term.placeKittyImage(.{ + .image_id = place.image_id, + .placement_id = place.placement_id, + .width_cells = place.width_cells, + .height_cells = place.height_cells, + .move_cursor = place.move_cursor, + .quiet = place.quiet, + .z_index = place.z_index, + .unicode_placeholder = place.unicode_placeholder, + }); + if (!place.move_cursor) { + try term.writer().writeAll(ansi.cursor_restore); + } + }, } try term.flush(); } } fn positionPendingImage(self: *Self, term: *Terminal, image: command.ImageFile) !void { + try self.positionByPlacement(term, image.placement, image.width_cells, image.height_cells, image.row, image.col, image.row_offset, image.col_offset); + } + + fn positionPendingImageData(self: *Self, term: *Terminal, image: command.ImageData) !void { + try self.positionByPlacement(term, image.placement, image.width_cells, image.height_cells, image.row, image.col, image.row_offset, image.col_offset); + } + + fn positionPendingCachedImage(self: *Self, term: *Terminal, place: command.PlaceCachedImage) !void { + try self.positionByPlacement(term, place.placement, place.width_cells, place.height_cells, place.row, place.col, place.row_offset, place.col_offset); + } + + fn positionByPlacement( + self: *Self, + term: *Terminal, + placement: command.ImagePlacement, + width_cells: ?u16, + height_cells: ?u16, + opt_row: ?u16, + opt_col: ?u16, + row_offset: i16, + col_offset: i16, + ) !void { var row: u16 = 0; var col: u16 = 0; - switch (image.placement) { + switch (placement) { .cursor => return, .top_left => { row = 0; col = 0; }, .top_center => { - if (image.width_cells) |w_cells| { + if (width_cells) |w_cells| { const term_width = @as(usize, self.context.width); const image_width = @as(usize, w_cells); if (term_width > image_width) { @@ -579,15 +703,14 @@ pub fn Program(comptime Model: type) type { row = 0; }, .center => { - if (image.width_cells) |w_cells| { + if (width_cells) |w_cells| { const term_width = @as(usize, self.context.width); const image_width = @as(usize, w_cells); if (term_width > image_width) { col = @intCast((term_width - image_width) / 2); } } - - if (image.height_cells) |h_cells| { + if (height_cells) |h_cells| { const term_height = @as(usize, self.context.height); const image_height = @as(usize, h_cells); if (term_height > image_height) { @@ -597,13 +720,13 @@ pub fn Program(comptime Model: type) type { }, } - if (image.row) |r| row = r; - if (image.col) |c| col = c; + if (opt_row) |r| row = r; + if (opt_col) |c| col = c; - const max_row = if (image.height_cells) |h| self.context.height -| h else self.context.height -| 1; - const max_col = if (image.width_cells) |w| self.context.width -| w else self.context.width -| 1; - row = applySignedOffsetClamped(row, image.row_offset, max_row); - col = applySignedOffsetClamped(col, image.col_offset, max_col); + const max_row = if (height_cells) |h| self.context.height -| h else self.context.height -| 1; + const max_col = if (width_cells) |w| self.context.width -| w else self.context.width -| 1; + row = applySignedOffsetClamped(row, row_offset, max_row); + col = applySignedOffsetClamped(col, col_offset, max_col); try term.moveTo(row, col); } diff --git a/src/root.zig b/src/root.zig index 7f7c750..73891ef 100644 --- a/src/root.zig +++ b/src/root.zig @@ -164,6 +164,18 @@ pub fn placeFloat(allocator: std.mem.Allocator, w: usize, h: usize, hpos: f32, v return place.placeFloat(allocator, w, h, hpos, vpos, content); } +// Image types +pub const ImageFile = command.ImageFile; +pub const ImageData = command.ImageData; +pub const ImagePlacement = command.ImagePlacement; +pub const ImageProtocol = command.ImageProtocol; +pub const ImageFormat = command.ImageFormat; +pub const ImageSource = command.ImageSource; +pub const CacheImage = command.CacheImage; +pub const PlaceCachedImage = command.PlaceCachedImage; +pub const DeleteImage = command.DeleteImage; +pub const ImageCapabilities = terminal.ImageCapabilities; + // Color utilities pub const ColorProfile = color.ColorProfile; pub const AdaptiveColor = color.AdaptiveColor; diff --git a/src/terminal/terminal.zig b/src/terminal/terminal.zig index 95346f6..a4ed593 100644 --- a/src/terminal/terminal.zig +++ b/src/terminal/terminal.zig @@ -47,6 +47,14 @@ pub const KittyImageOptions = struct { placement_id: ?u32 = null, move_cursor: bool = true, quiet: bool = true, + /// Z-index for layering. Negative = behind text, positive = above. + z_index: ?i32 = null, + /// Enable unicode placeholders for text-reflow participation. + unicode_placeholder: bool = false, + /// Pixel width of the image (required for RGB/RGBA direct data). + pixel_width: ?u32 = null, + /// Pixel height of the image (required for RGB/RGBA direct data). + pixel_height: ?u32 = null, }; pub const KittyImageFileOptions = struct { @@ -56,6 +64,36 @@ pub const KittyImageFileOptions = struct { placement_id: ?u32 = null, move_cursor: bool = true, quiet: bool = true, + z_index: ?i32 = null, + unicode_placeholder: bool = false, +}; + +/// Options for transmitting an image to the Kitty cache without display. +pub const KittyTransmitOptions = struct { + image_id: u32, + format: KittyImageFormat = .png, + quiet: bool = true, + pixel_width: ?u32 = null, + pixel_height: ?u32 = null, +}; + +/// Options for placing a previously cached Kitty image. +pub const KittyPlaceOptions = struct { + image_id: u32, + placement_id: ?u32 = null, + width_cells: ?u16 = null, + height_cells: ?u16 = null, + move_cursor: bool = true, + quiet: bool = true, + z_index: ?i32 = null, + unicode_placeholder: bool = false, +}; + +/// What to delete from the Kitty image cache. +pub const KittyDeleteTarget = union(enum) { + by_id: u32, + by_placement: struct { image_id: u32, placement_id: u32 }, + all, }; pub const Iterm2ImageFileOptions = struct { @@ -65,6 +103,14 @@ pub const Iterm2ImageFileOptions = struct { move_cursor: bool = true, }; +/// Options for in-memory iTerm2 image rendering. +pub const Iterm2ImageDataOptions = struct { + width_cells: ?u16 = null, + height_cells: ?u16 = null, + preserve_aspect_ratio: bool = true, + move_cursor: bool = true, +}; + pub const ImageFileOptions = struct { width_cells: ?u16 = null, height_cells: ?u16 = null, @@ -73,11 +119,41 @@ pub const ImageFileOptions = struct { placement_id: ?u32 = null, move_cursor: bool = true, quiet: bool = true, + z_index: ?i32 = null, + unicode_placeholder: bool = false, +}; + +/// Options for rendering in-memory image data with auto protocol selection. +pub const ImageDataOptions = struct { + format: KittyImageFormat = .png, + pixel_width: ?u32 = null, + pixel_height: ?u32 = null, + width_cells: ?u16 = null, + height_cells: ?u16 = null, + preserve_aspect_ratio: bool = true, + image_id: ?u32 = null, + placement_id: ?u32 = null, + move_cursor: bool = true, + quiet: bool = true, + z_index: ?i32 = null, + unicode_placeholder: bool = false, +}; + +/// Preferred image protocol for protocol-selection overrides. +pub const ImageProtocol = enum { + auto, + kitty, + iterm2, + sixel, }; pub const SixelImageFileOptions = struct { /// Optional max captured converter output (bytes). max_output_bytes: usize = 32 * 1024 * 1024, + /// Optional pixel width hint for img2sixel (-w flag). + width_pixels: ?u32 = null, + /// Optional pixel height hint for img2sixel (-h flag). + height_pixels: ?u32 = null, }; /// Terminal configuration options @@ -358,7 +434,7 @@ pub const Terminal = struct { pub fn drawKittyImage(self: *Terminal, image_data: []const u8, options: KittyImageOptions) !bool { if (!self.image_caps.kitty_graphics or image_data.len == 0) return false; - var params_buf: [160]u8 = undefined; + var params_buf: [256]u8 = undefined; var stream = std.io.fixedBufferStream(¶ms_buf); const params_writer = stream.writer(); @@ -369,6 +445,10 @@ pub const Terminal = struct { if (options.image_id) |id| try params_writer.print(",i={d}", .{id}); if (options.placement_id) |id| try params_writer.print(",p={d}", .{id}); if (!options.move_cursor) try params_writer.writeAll(",C=1"); + if (options.z_index) |z| try params_writer.print(",z={d}", .{z}); + if (options.unicode_placeholder) try params_writer.writeAll(",U=1"); + if (options.pixel_width) |pw| try params_writer.print(",s={d}", .{pw}); + if (options.pixel_height) |ph| try params_writer.print(",v={d}", .{ph}); try self.sendKittyGraphicsPayload(stream.getWritten(), image_data); return true; @@ -379,7 +459,7 @@ pub const Terminal = struct { pub fn drawKittyImageFromFile(self: *Terminal, path: []const u8, options: KittyImageFileOptions) !bool { if (!self.image_caps.kitty_graphics or path.len == 0) return false; - var params_buf: [160]u8 = undefined; + var params_buf: [256]u8 = undefined; var stream = std.io.fixedBufferStream(¶ms_buf); const params_writer = stream.writer(); @@ -390,21 +470,111 @@ pub const Terminal = struct { if (options.image_id) |id| try params_writer.print(",i={d}", .{id}); if (options.placement_id) |id| try params_writer.print(",p={d}", .{id}); if (!options.move_cursor) try params_writer.writeAll(",C=1"); + if (options.z_index) |z| try params_writer.print(",z={d}", .{z}); + if (options.unicode_placeholder) try params_writer.writeAll(",U=1"); + + try self.sendKittyGraphicsPayload(stream.getWritten(), path); + return true; + } + + /// Transmit an image to the Kitty cache without displaying it (`a=t`). + /// Use `placeKittyImage` later to display it by ID. + pub fn transmitKittyImage(self: *Terminal, payload: []const u8, options: KittyTransmitOptions) !bool { + if (!self.image_caps.kitty_graphics or payload.len == 0) return false; + + var params_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.print("a=t,i={d}", .{options.image_id}); + if (options.quiet) try params_writer.writeAll(",q=2"); + + switch (options.format) { + .png => try params_writer.writeAll(",f=100"), + .rgb => { + try params_writer.writeAll(",f=24"); + if (options.pixel_width) |pw| try params_writer.print(",s={d}", .{pw}); + if (options.pixel_height) |ph| try params_writer.print(",v={d}", .{ph}); + }, + .rgba => { + try params_writer.writeAll(",f=32"); + if (options.pixel_width) |pw| try params_writer.print(",s={d}", .{pw}); + if (options.pixel_height) |ph| try params_writer.print(",v={d}", .{ph}); + }, + } + + try self.sendKittyGraphicsPayload(stream.getWritten(), payload); + return true; + } + + /// Transmit an image file to the Kitty cache without displaying it (`a=t,t=f`). + pub fn transmitKittyImageFromFile(self: *Terminal, path: []const u8, options: KittyTransmitOptions) !bool { + if (!self.image_caps.kitty_graphics or path.len == 0) return false; + if (!fileExists(path)) return false; + + var params_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.print("a=t,t=f,f=100,i={d}", .{options.image_id}); + if (options.quiet) try params_writer.writeAll(",q=2"); try self.sendKittyGraphicsPayload(stream.getWritten(), path); return true; } + /// Display a previously cached image by ID (`a=p`). + pub fn placeKittyImage(self: *Terminal, options: KittyPlaceOptions) !bool { + if (!self.image_caps.kitty_graphics) return false; + + var params_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.print("a=p,i={d}", .{options.image_id}); + if (options.quiet) try params_writer.writeAll(",q=2"); + if (options.placement_id) |id| try params_writer.print(",p={d}", .{id}); + if (options.width_cells) |cols| try params_writer.print(",c={d}", .{cols}); + if (options.height_cells) |rows| try params_writer.print(",r={d}", .{rows}); + if (!options.move_cursor) try params_writer.writeAll(",C=1"); + if (options.z_index) |z| try params_writer.print(",z={d}", .{z}); + if (options.unicode_placeholder) try params_writer.writeAll(",U=1"); + + // Virtual placement has no payload. + try ansi.kittyGraphics(self.writer(), stream.getWritten(), ""); + return true; + } + + /// Delete images/placements from the Kitty cache (`a=d`). + pub fn deleteKittyImage(self: *Terminal, target: KittyDeleteTarget) !bool { + if (!self.image_caps.kitty_graphics) return false; + + var params_buf: [128]u8 = undefined; + var stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.writeAll("a=d,q=2"); + switch (target) { + .by_id => |id| try params_writer.print(",d=I,i={d}", .{id}), + .by_placement => |bp| try params_writer.print(",d=I,i={d},p={d}", .{ bp.image_id, bp.placement_id }), + .all => try params_writer.writeAll(",d=A"), + } + + try ansi.kittyGraphics(self.writer(), stream.getWritten(), ""); + return true; + } + /// Draw a file image via iTerm2 inline image protocol (`OSC 1337`). /// Returns `false` when unsupported or path is empty. pub fn drawIterm2ImageFromFile(self: *Terminal, path: []const u8, options: Iterm2ImageFileOptions) !bool { if (!self.image_caps.iterm2_inline_image or path.len == 0) return false; + if (!fileExists(path)) return false; var file = try std.fs.cwd().openFile(path, .{}); defer file.close(); const stat = try file.stat(); - var params_buf: [192]u8 = undefined; + var params_buf: [256]u8 = undefined; var stream = std.io.fixedBufferStream(¶ms_buf); const params_writer = stream.writer(); @@ -426,31 +596,178 @@ pub const Terminal = struct { return true; } + /// Draw in-memory image data via iTerm2 inline image protocol. + /// Returns `false` when unsupported or data is empty. + pub fn drawIterm2ImageData(self: *Terminal, data: []const u8, options: Iterm2ImageDataOptions) !bool { + if (!self.image_caps.iterm2_inline_image or data.len == 0) return false; + + var params_buf: [256]u8 = undefined; + var stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.writeAll("inline=1"); + if (options.width_cells) |cols| try params_writer.print(";width={d}", .{cols}); + if (options.height_cells) |rows| try params_writer.print(";height={d}", .{rows}); + try params_writer.print(";preserveAspectRatio={d}", .{if (options.preserve_aspect_ratio) @as(u8, 1) else @as(u8, 0)}); + if (!options.move_cursor) try params_writer.writeAll(";doNotMoveCursor=1"); + try params_writer.print(";size={d}", .{data.len}); + + try self.sendIterm2InlineImageDataPayload(stream.getWritten(), data); + return true; + } + /// Draw an image file using the best available protocol. - /// Prefers Kitty graphics, then iTerm2 inline images. + /// Prefers Kitty graphics, then iTerm2 inline images, then Sixel. + /// Use `protocol` to override the auto-selection. pub fn drawImageFromFile(self: *Terminal, path: []const u8, options: ImageFileOptions) !bool { - if (self.image_caps.kitty_graphics) { - return self.drawKittyImageFromFile(path, .{ - .width_cells = options.width_cells, - .height_cells = options.height_cells, - .image_id = options.image_id, - .placement_id = options.placement_id, - .move_cursor = options.move_cursor, - .quiet = options.quiet, - }); - } - if (self.image_caps.iterm2_inline_image) { - return self.drawIterm2ImageFromFile(path, .{ - .width_cells = options.width_cells, - .height_cells = options.height_cells, - .preserve_aspect_ratio = options.preserve_aspect_ratio, - .move_cursor = options.move_cursor, - }); + return self.drawImageFromFileWithProtocol(path, options, .auto); + } + + /// Draw an image file using a specific or auto-selected protocol. + pub fn drawImageFromFileWithProtocol(self: *Terminal, path: []const u8, options: ImageFileOptions, protocol: ImageProtocol) !bool { + if (path.len == 0) return false; + if (!fileExists(path)) return false; + + switch (protocol) { + .kitty => { + if (self.image_caps.kitty_graphics) { + return self.drawKittyImageFromFile(path, .{ + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .image_id = options.image_id, + .placement_id = options.placement_id, + .move_cursor = options.move_cursor, + .quiet = options.quiet, + .z_index = options.z_index, + .unicode_placeholder = options.unicode_placeholder, + }); + } + return false; + }, + .iterm2 => { + if (self.image_caps.iterm2_inline_image) { + return self.drawIterm2ImageFromFile(path, .{ + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .preserve_aspect_ratio = options.preserve_aspect_ratio, + .move_cursor = options.move_cursor, + }); + } + return false; + }, + .sixel => { + if (self.image_caps.sixel) { + return self.drawSixelFromFile(path, .{}); + } + return false; + }, + .auto => { + if (self.image_caps.kitty_graphics) { + return self.drawKittyImageFromFile(path, .{ + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .image_id = options.image_id, + .placement_id = options.placement_id, + .move_cursor = options.move_cursor, + .quiet = options.quiet, + .z_index = options.z_index, + .unicode_placeholder = options.unicode_placeholder, + }); + } + if (self.image_caps.iterm2_inline_image) { + return self.drawIterm2ImageFromFile(path, .{ + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .preserve_aspect_ratio = options.preserve_aspect_ratio, + .move_cursor = options.move_cursor, + }); + } + if (self.image_caps.sixel) { + return self.drawSixelFromFile(path, .{}); + } + return false; + }, } - if (self.image_caps.sixel) { - return self.drawSixelFromFile(path, .{}); + } + + /// Draw in-memory image data using the best available protocol. + pub fn drawImageData(self: *Terminal, data: []const u8, options: ImageDataOptions) !bool { + return self.drawImageDataWithProtocol(data, options, .auto); + } + + /// Draw in-memory image data using a specific or auto-selected protocol. + pub fn drawImageDataWithProtocol(self: *Terminal, data: []const u8, options: ImageDataOptions, protocol: ImageProtocol) !bool { + if (data.len == 0) return false; + + switch (protocol) { + .kitty => { + if (self.image_caps.kitty_graphics) { + return self.drawKittyImage(data, .{ + .format = options.format, + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .image_id = options.image_id, + .placement_id = options.placement_id, + .move_cursor = options.move_cursor, + .quiet = options.quiet, + .z_index = options.z_index, + .unicode_placeholder = options.unicode_placeholder, + .pixel_width = options.pixel_width, + .pixel_height = options.pixel_height, + }); + } + return false; + }, + .iterm2 => { + if (self.image_caps.iterm2_inline_image) { + return self.drawIterm2ImageData(data, .{ + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .preserve_aspect_ratio = options.preserve_aspect_ratio, + .move_cursor = options.move_cursor, + }); + } + return false; + }, + .sixel => { + // Sixel only supports pre-encoded data or file paths. + if (self.image_caps.sixel) { + self.sendSixelPayload(data) catch return false; + return true; + } + return false; + }, + .auto => { + if (self.image_caps.kitty_graphics) { + return self.drawKittyImage(data, .{ + .format = options.format, + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .image_id = options.image_id, + .placement_id = options.placement_id, + .move_cursor = options.move_cursor, + .quiet = options.quiet, + .z_index = options.z_index, + .unicode_placeholder = options.unicode_placeholder, + .pixel_width = options.pixel_width, + .pixel_height = options.pixel_height, + }); + } + if (self.image_caps.iterm2_inline_image) { + return self.drawIterm2ImageData(data, .{ + .width_cells = options.width_cells, + .height_cells = options.height_cells, + .preserve_aspect_ratio = options.preserve_aspect_ratio, + .move_cursor = options.move_cursor, + }); + } + if (self.image_caps.sixel) { + self.sendSixelPayload(data) catch return false; + return true; + } + return false; + }, } - return false; } /// Draw a Sixel image from file. @@ -459,6 +776,7 @@ pub const Terminal = struct { /// - regular image files converted through `img2sixel` when available. pub fn drawSixelFromFile(self: *Terminal, path: []const u8, options: SixelImageFileOptions) !bool { if (!self.image_caps.sixel or path.len == 0) return false; + if (!fileExists(path)) return false; if (isSixelDataPath(path)) { var file = try std.fs.cwd().openFile(path, .{}); @@ -469,10 +787,30 @@ pub const Terminal = struct { if (!commandExists("img2sixel")) return false; - const argv = [_][]const u8{ "img2sixel", path }; + var argv_buf: [6][]const u8 = undefined; + var argc: usize = 0; + argv_buf[argc] = "img2sixel"; + argc += 1; + var w_buf: [16]u8 = undefined; + var h_buf: [16]u8 = undefined; + if (options.width_pixels) |wp| { + argv_buf[argc] = "-w"; + argc += 1; + argv_buf[argc] = std.fmt.bufPrint(&w_buf, "{d}", .{wp}) catch "0"; + argc += 1; + } + if (options.height_pixels) |hp| { + argv_buf[argc] = "-h"; + argc += 1; + argv_buf[argc] = std.fmt.bufPrint(&h_buf, "{d}", .{hp}) catch "0"; + argc += 1; + } + argv_buf[argc] = path; + argc += 1; + const result = try std.process.Child.run(.{ .allocator = std.heap.page_allocator, - .argv = &argv, + .argv = argv_buf[0..argc], .max_output_bytes = options.max_output_bytes, }); defer std.heap.page_allocator.free(result.stdout); @@ -629,6 +967,53 @@ pub const Terminal = struct { try self.writeBytes(ansi.OSC ++ "1337;FileEnd\x07"); } + fn sendIterm2InlineImageDataPayload(self: *Terminal, params: []const u8, data: []const u8) !void { + const encoder = std.base64.standard.Encoder; + const encoded_total = encoder.calcSize(data.len); + const single_sequence_soft_limit: usize = 750 * 1024; + + if (encoded_total <= single_sequence_soft_limit) { + try self.writeBytes(ansi.OSC ++ "1337;File="); + try self.writeBytes(params); + try self.writeBytes(":"); + + var src_index: usize = 0; + var b64_buf: [4096]u8 = undefined; + const raw_chunk_max: usize = (b64_buf.len / 4) * 3; + while (src_index < data.len) { + const take = @min(data.len - src_index, raw_chunk_max); + const chunk = data[src_index .. src_index + take]; + const encoded_len = encoder.calcSize(chunk.len); + const encoded = encoder.encode(b64_buf[0..encoded_len], chunk); + try self.writeBytes(encoded); + src_index += take; + } + try self.writeBytes("\x07"); + return; + } + + // Multipart transfer for large payloads. + try self.writeBytes(ansi.OSC ++ "1337;MultipartFile="); + try self.writeBytes(params); + try self.writeBytes("\x07"); + + var src_index: usize = 0; + var b64_buf: [4096]u8 = undefined; + const raw_chunk_max: usize = (b64_buf.len / 4) * 3; + while (src_index < data.len) { + const take = @min(data.len - src_index, raw_chunk_max); + const chunk = data[src_index .. src_index + take]; + const encoded_len = encoder.calcSize(chunk.len); + const encoded = encoder.encode(b64_buf[0..encoded_len], chunk); + try self.writeBytes(ansi.OSC ++ "1337;FilePart="); + try self.writeBytes(encoded); + try self.writeBytes("\x07"); + src_index += take; + } + + try self.writeBytes(ansi.OSC ++ "1337;FileEnd\x07"); + } + fn sendSixelPayloadFromFile(self: *Terminal, file: *std.fs.File) !void { var payload_buf: [4096]u8 = undefined; var first_read = true; @@ -1060,6 +1445,11 @@ pub const Terminal = struct { std.mem.endsWith(u8, path, ".SIX"); } + fn fileExists(path: []const u8) bool { + std.fs.cwd().access(path, .{}) catch return false; + return true; + } + fn commandExists(name: []const u8) bool { const argv = [_][]const u8{ name, "--version" }; const result = std.process.Child.run(.{ From 5fbeae598f8566738a20f4f8ba3f1a4ca1fd1a2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A1t=C3=A9=20M=C3=A9sz=C3=A1ros=20=28Laptop=29?= Date: Tue, 3 Mar 2026 10:10:36 +0100 Subject: [PATCH 2/2] docs: update README and hello_world with extended image API examples --- README.md | 164 ++++++++++++++++++++++++++++++++++++--- examples/hello_world.zig | 123 +++++++++++++++++++++++++---- src/core/context.zig | 6 +- 3 files changed, 265 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index f97433d..33d3605 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,8 @@ A delightful TUI framework for Zig, inspired by [Bubble Tea](https://github.com/ - **16 Pre-built Components** - TextInput (with autocomplete/word movement), TextArea, List (fuzzy filtering), Table (interactive with row selection), Viewport, Progress (color gradients), Spinner, Tree, StyledList, Sparkline, Notification/Toast, Confirm dialog, Help, Paginator, Timer, FilePicker - **Keybinding Management** - Structured `KeyBinding`/`KeyMap` with matching, display formatting, and Help component integration - **Color System** - ANSI 16, 256, and TrueColor with adaptive colors, color profile detection, and dark background detection -- **Command System** - Quit, tick, repeating tick (`every`), batch, sequence, suspend/resume, runtime terminal control (mouse, cursor, alt screen, title), print above program, Kitty/iTerm2/Sixel image file rendering +- **Command System** - Quit, tick, repeating tick (`every`), batch, sequence, suspend/resume, runtime terminal control (mouse, cursor, alt screen, title), print above program, comprehensive image rendering +- **Image Rendering** - Kitty/iTerm2/Sixel with in-memory data, file paths, image caching (transmit once, display many), z-index layering, unicode placeholders for text reflow, protocol override, and file validation - **Custom I/O** - Pipe-friendly with configurable input/output streams for testing and automation - **Kitty Keyboard Protocol** - Modern keyboard handling with key release events and unambiguous key identification - **Bracketed Paste** - Paste events delivered as a single message instead of individual keystrokes @@ -132,7 +133,31 @@ return .{ .image_file = .{ // Draw image via Kitty/iTerm2/Sixel when .col_offset = 0, // Negative = left, positive = right // .row = 2, .col = 10, // Optional absolute position override .move_cursor = false, // Helpful for iTerm2 placement + .protocol = .auto, // .auto, .kitty, .iterm2, .sixel + .z_index = -1, // Kitty: render behind text + .unicode_placeholder = false, // Kitty: participate in text reflow } }; +return .{ .image_data = .{ // Draw in-memory image data + .data = png_bytes, // Raw RGB, RGBA, or PNG bytes + .format = .png, // .rgb, .rgba, .png + .pixel_width = 100, // Required for RGB/RGBA + .pixel_height = 100, + .width_cells = 20, + .height_cells = 10, + .placement = .center, +} }; +return .{ .cache_image = .{ // Upload to Kitty cache (transmit once) + .source = .{ .file = "assets/logo.png" }, + .image_id = 1, +} }; +return .{ .place_cached_image = .{ // Display cached image (no re-upload) + .image_id = 1, + .placement = .center, + .width_cells = 20, + .height_cells = 10, +} }; +return .{ .delete_image = .{ .by_id = 1 } }; // Free cached image +return .{ .delete_image = .all }; // Free all cached images ``` ### Styling @@ -527,17 +552,136 @@ pub const Msg = union(enum) { ### Images (Kitty + iTerm2 + Sixel) -Image commands are automatically no-ops on unsupported terminals. +Image commands are automatically no-ops on unsupported terminals. All `draw*` functions return `bool` indicating success. + +#### Basic usage ```zig +// Draw from file (auto-selects best protocol) if (ctx.supportsImages()) { _ = try ctx.drawImageFromFile("assets/cat.png", .{ .width_cells = 40, .height_cells = 20, }); } + +// Draw from file with specific protocol +_ = try ctx.drawImageFromFileWithProtocol("assets/cat.png", .{ + .width_cells = 40, + .z_index = -1, // Behind text (Kitty only) +}, .kitty); +``` + +#### In-memory image data + +Render raw pixels or PNG bytes directly from memory, without writing to disk: + +```zig +// Draw PNG bytes from memory +_ = try ctx.drawImageData(png_bytes, .{ + .format = .png, + .width_cells = 20, + .height_cells = 10, +}); + +// Draw raw RGBA pixels +_ = try ctx.drawImageData(rgba_pixels, .{ + .format = .rgba, + .pixel_width = 100, // Required for RGB/RGBA + .pixel_height = 100, + .width_cells = 20, +}); +``` + +#### Image caching (Kitty) + +Transmit an image once, display it many times without re-uploading: + +```zig +// Upload to cache (no display) +_ = try ctx.transmitKittyImageFromFile("assets/logo.png", .{ + .image_id = 1, +}); + +// Display cached image at different positions +_ = try ctx.placeKittyImage(.{ + .image_id = 1, + .width_cells = 10, + .height_cells = 5, +}); + +// Clean up when done +_ = try ctx.deleteKittyImage(.{ .by_id = 1 }); +_ = try ctx.deleteKittyImage(.all); // Delete everything +``` + +#### Z-index and unicode placeholders (Kitty) + +```zig +// Render image behind text +_ = try ctx.drawKittyImageFromFile("assets/bg.png", .{ + .z_index = -1, // Negative = behind text + .unicode_placeholder = true, // Image participates in text reflow/scrolling +}); +``` + +#### Protocol override + +Force a specific protocol instead of auto-selection (Kitty > iTerm2 > Sixel): + +```zig +_ = try ctx.drawImageFromFileWithProtocol("image.png", .{}, .iterm2); +_ = try ctx.drawImageDataWithProtocol(data, .{ .format = .png }, .sixel); +``` + +#### Querying capabilities + +```zig +const caps = ctx.getImageCapabilities(); +// caps.kitty_graphics: bool +// caps.iterm2_inline_image: bool +// caps.sixel: bool + +if (ctx.supportsKittyGraphics()) { /* ... */ } +if (ctx.supportsIterm2InlineImages()) { /* ... */ } +if (ctx.supportsSixel()) { /* ... */ } ``` +#### Command-based API + +All image operations are also available as commands from `update()`: + +```zig +// File image with all options +return .{ .image_file = .{ + .path = "assets/cat.png", + .placement = .center, + .width_cells = 40, + .protocol = .auto, // .auto, .kitty, .iterm2, .sixel + .z_index = -1, // Behind text (Kitty) + .unicode_placeholder = true, // Text reflow (Kitty) +} }; + +// In-memory data +return .{ .image_data = .{ + .data = png_bytes, + .format = .png, // .rgb, .rgba, .png + .width_cells = 20, +} }; + +// Cache + place workflow +return .{ .batch = &.{ + .{ .cache_image = .{ .source = .{ .file = "logo.png" }, .image_id = 1 } }, + .{ .place_cached_image = .{ .image_id = 1, .placement = .center } }, +} }; + +// Delete cached images +return .{ .delete_image = .{ .by_id = 1 } }; +return .{ .delete_image = .all }; +``` + +#### Detection + Detection combines runtime protocol probes with terminal feature/env hints: - Kitty graphics: Kitty query command (`a=q`) for confirmation. - iTerm2 inline images: `OSC 1337;Capabilities`/`TERM_FEATURES` when available. @@ -548,17 +692,13 @@ Common terminals supported by default: - iTerm2 and WezTerm via `OSC 1337` inline images. - Sixel-capable terminals (for example xterm with Sixel, mlterm, contour). -For iTerm2, `alt_screen = false` is optional. Keep `alt_screen = true` (default) -if you want behavior consistent with other ZigZag examples. - -Inside multiplexers (tmux/screen/zellij), inline image passthrough depends on -multiplexer configuration and terminal support. - -For Sixel terminals, provide either: -- a `.sixel`/`.six` file, or -- a regular image with `img2sixel` available in `PATH`. +#### Notes -For iTerm2, large images are sent with multipart OSC 1337 sequences automatically. +- File paths are validated before sending; missing files return `false` instead of erroring. +- For iTerm2, large images (>750KB encoded) are sent with multipart `OSC 1337` sequences automatically. +- For Sixel, provide a `.sixel`/`.six` file or a regular image with `img2sixel` in `PATH`. Optional `-w`/`-h` pixel hints are passed through. +- Inside multiplexers (tmux/screen/zellij), image passthrough depends on multiplexer configuration. +- Image caching, z-index, and unicode placeholders are Kitty-specific features; they are silently ignored on other protocols. ## Layout diff --git a/examples/hello_world.zig b/examples/hello_world.zig index 2842bae..947fb9e 100644 --- a/examples/hello_world.zig +++ b/examples/hello_world.zig @@ -1,5 +1,12 @@ //! ZigZag Hello World Example //! A minimal example showing the basic structure of a ZigZag application. +//! +//! Demonstrates image rendering features: +//! 'i' — draw image from file (auto protocol detection) +//! 'c' — cache image and place via Kitty virtual placement +//! 'z' — toggle z-index (render image behind text, Kitty only) +//! 'd' — delete all cached images +//! 'p' — cycle protocol: auto → kitty → iterm2 → sixel const std = @import("std"); const zz = @import("zigzag"); @@ -8,10 +15,15 @@ const Model = struct { image_supported: bool, image_visible: bool, image_attempted: bool, + image_cached: bool, + image_behind_text: bool, image_size_cells: u16, image_path: []const u8, + protocol: zz.ImageProtocol, + caps: zz.ImageCapabilities, const image_gap_lines: u16 = 1; + const cache_id: u32 = 42; const ImageLayout = struct { size_cells: u16, @@ -31,8 +43,12 @@ const Model = struct { .image_supported = ctx.supportsImages(), .image_visible = false, .image_attempted = false, + .image_cached = false, + .image_behind_text = false, .image_size_cells = 0, .image_path = "assets/cat.png", + .protocol = .auto, + .caps = ctx.getImageCapabilities(), }; return .none; } @@ -41,10 +57,10 @@ const Model = struct { pub fn update(self: *Model, msg: Msg, ctx: *zz.Context) zz.Cmd(Msg) { switch (msg) { .key => |k| { - // Quit on 'q' or Escape switch (k.key) { .char => |c| switch (c) { 'q' => return .quit, + // Draw image from file 'i' => { self.image_attempted = true; if (self.image_supported) { @@ -52,6 +68,56 @@ const Model = struct { return self.imageCommand(ctx); } }, + // Cache image and display via Kitty virtual placement + 'c' => { + if (self.caps.kitty_graphics) { + self.image_attempted = true; + self.image_visible = true; + self.image_cached = true; + const layout = self.computeImageLayout(ctx); + return .{ .batch = &.{ + .{ .cache_image = .{ + .source = .{ .file = self.image_path }, + .image_id = cache_id, + } }, + .{ .place_cached_image = .{ + .image_id = cache_id, + .width_cells = layout.size_cells, + .height_cells = layout.size_cells, + .placement = .top_left, + .row = layout.row, + .col = layout.col, + .move_cursor = false, + } }, + } }; + } + }, + // Toggle z-index (behind text) + 'z' => { + self.image_behind_text = !self.image_behind_text; + if (self.image_visible) { + return self.imageCommand(ctx); + } + }, + // Delete cached images + 'd' => { + if (self.image_cached) { + self.image_cached = false; + return .{ .delete_image = .all }; + } + }, + // Cycle protocol + 'p' => { + self.protocol = switch (self.protocol) { + .auto => .kitty, + .kitty => .iterm2, + .iterm2 => .sixel, + .sixel => .auto, + }; + if (self.image_visible) { + return self.imageCommand(ctx); + } + }, else => {}, }, .escape => return .quit, @@ -77,6 +143,8 @@ const Model = struct { .row = layout.row, .col = layout.col, .move_cursor = false, + .protocol = self.protocol, + .z_index = if (self.image_behind_text) @as(?i32, -1) else null, } }; } @@ -88,8 +156,8 @@ const Model = struct { } fn textBlockLineCount(_: *const Model) u16 { - // title + blank + subtitle + blank + hint + image-hint + status - return 7; + // title + blank + subtitle + blank + hints (4) + status + return 9; } fn computeImageLayout(self: *Model, ctx: *const zz.Context) ImageLayout { @@ -114,6 +182,23 @@ const Model = struct { }; } + fn protocolName(self: *const Model) []const u8 { + return switch (self.protocol) { + .auto => "auto", + .kitty => "kitty", + .iterm2 => "iterm2", + .sixel => "sixel", + }; + } + + fn capsString(self: *const Model, allocator: std.mem.Allocator) []const u8 { + return std.fmt.allocPrint(allocator, "kitty={s} iterm2={s} sixel={s}", .{ + if (self.caps.kitty_graphics) "yes" else "no", + if (self.caps.iterm2_inline_image) "yes" else "no", + if (self.caps.sixel) "yes" else "no", + }) catch "?"; + } + /// Render the view pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { var title_style = zz.Style{}; @@ -139,11 +224,21 @@ const Model = struct { const hint = hint_style.render(ctx.allocator, "Press 'q' to quit") catch ""; const image_hint_text = if (self.image_supported) - "Press 'i' to draw assets/cat.png in remaining lower space" + "'i' draw 'c' cache 'z' z-index 'd' delete 'p' protocol" else "Inline image protocol not detected in this terminal"; const image_hint = image_hint_style.render(ctx.allocator, image_hint_text) catch image_hint_text; + const caps_text = self.capsString(ctx.allocator); + const caps_line = image_hint_style.render(ctx.allocator, caps_text) catch caps_text; + + const protocol_text = std.fmt.allocPrint(ctx.allocator, "protocol: {s} z-index: {s} cached: {s}", .{ + self.protocolName(), + if (self.image_behind_text) "behind" else "normal", + if (self.image_cached) "yes" else "no", + }) catch ""; + const protocol_line = image_hint_style.render(ctx.allocator, protocol_text) catch protocol_text; + const status_text = if (self.image_attempted and self.image_supported) "Image command sent (check assets/cat.png path)" else if (self.image_attempted and !self.image_supported) @@ -153,16 +248,14 @@ const Model = struct { const status = hint_style.render(ctx.allocator, status_text) catch status_text; // Get max width for centering - const title_width = zz.measure.width(title); - const subtitle_width = zz.measure.width(subtitle); - const hint_width = zz.measure.width(hint); - const image_hint_width = zz.measure.width(image_hint); - const status_width = zz.measure.width(status); const max_width = @max( - title_width, + zz.measure.width(title), @max( - subtitle_width, - @max(hint_width, @max(image_hint_width, status_width)), + zz.measure.width(subtitle), + @max(zz.measure.width(hint), @max( + zz.measure.width(image_hint), + @max(zz.measure.width(caps_line), @max(zz.measure.width(protocol_line), zz.measure.width(status))), + )), ), ); @@ -171,12 +264,14 @@ const Model = struct { const centered_subtitle = zz.place.place(ctx.allocator, max_width, 1, .center, .top, subtitle) catch subtitle; const centered_hint = zz.place.place(ctx.allocator, max_width, 1, .center, .top, hint) catch hint; const centered_image_hint = zz.place.place(ctx.allocator, max_width, 1, .center, .top, image_hint) catch image_hint; + const centered_caps = zz.place.place(ctx.allocator, max_width, 1, .center, .top, caps_line) catch caps_line; + const centered_protocol = zz.place.place(ctx.allocator, max_width, 1, .center, .top, protocol_line) catch protocol_line; const centered_status = zz.place.place(ctx.allocator, max_width, 1, .center, .top, status) catch status; const content = std.fmt.allocPrint( ctx.allocator, - "{s}\n\n{s}\n\n{s}\n{s}\n{s}", - .{ centered_title, centered_subtitle, centered_hint, centered_image_hint, centered_status }, + "{s}\n\n{s}\n\n{s}\n{s}\n{s}\n{s}\n{s}", + .{ centered_title, centered_subtitle, centered_hint, centered_image_hint, centered_caps, centered_protocol, centered_status }, ) catch "Error rendering view"; if (self.image_supported and self.image_visible) { diff --git a/src/core/context.zig b/src/core/context.zig index 168bc6a..9a90d83 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -2,7 +2,9 @@ //! Provides access to terminal state and resources. const std = @import("std"); -const Terminal = @import("../terminal/terminal.zig").Terminal; +const terminal_mod = @import("../terminal/terminal.zig"); +const Terminal = terminal_mod.Terminal; +const ImageCapabilities = terminal_mod.ImageCapabilities; const color_mod = @import("../style/color.zig"); const unicode_mod = @import("../unicode.zig"); const Logger = @import("log.zig").Logger; @@ -267,7 +269,7 @@ pub const Context = struct { } /// Get the current image capabilities of the terminal. - pub fn getImageCapabilities(self: *const Context) Terminal.ImageCapabilities { + pub fn getImageCapabilities(self: *const Context) ImageCapabilities { if (self._terminal) |term| { return term.getImageCapabilities(); }