diff --git a/README.md b/README.md index 0dade7b..f97433d 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ 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 +- **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 - **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 @@ -123,6 +123,16 @@ return .show_cursor; // Show terminal cursor return .hide_cursor; // Hide terminal cursor return .{ .set_title = "My App" }; // Set terminal window title return .{ .println = "Log message" }; // Print above the program output +return .{ .image_file = .{ // Draw image via Kitty/iTerm2/Sixel when available + .path = "assets/cat.png", + .width_cells = 40, + .height_cells = 20, + .placement = .center, // .cursor, .top_left, .top_center, .center + .row_offset = -6, // Negative = higher, positive = lower + .col_offset = 0, // Negative = left, positive = right + // .row = 2, .col = 10, // Optional absolute position override + .move_cursor = false, // Helpful for iTerm2 placement +} }; ``` ### Styling @@ -515,6 +525,41 @@ pub const Msg = union(enum) { }; ``` +### Images (Kitty + iTerm2 + Sixel) + +Image commands are automatically no-ops on unsupported terminals. + +```zig +if (ctx.supportsImages()) { + _ = try ctx.drawImageFromFile("assets/cat.png", .{ + .width_cells = 40, + .height_cells = 20, + }); +} +``` + +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. +- Sixel: iTerm/WezTerm `TERM_FEATURES` (`Sx`) and primary device attributes (`CSI c`, param `4`). + +Common terminals supported by default: +- Kitty and Ghostty via Kitty graphics protocol. +- 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`. + +For iTerm2, large images are sent with multipart OSC 1337 sequences automatically. + ## Layout ### Join diff --git a/assets/cat.png b/assets/cat.png new file mode 100644 index 0000000..fffcf47 Binary files /dev/null and b/assets/cat.png differ diff --git a/examples/hello_world.zig b/examples/hello_world.zig index 93bfa9d..2842bae 100644 --- a/examples/hello_world.zig +++ b/examples/hello_world.zig @@ -5,37 +5,117 @@ const std = @import("std"); const zz = @import("zigzag"); const Model = struct { + image_supported: bool, + image_visible: bool, + image_attempted: bool, + image_size_cells: u16, + image_path: []const u8, + + const image_gap_lines: u16 = 1; + + const ImageLayout = struct { + size_cells: u16, + row: u16, + col: u16, + }; + /// The message type for this model pub const Msg = union(enum) { key: zz.KeyEvent, + window_size: zz.msg.WindowSize, }; /// Initialize the model - pub fn init(self: *Model, _: *zz.Context) zz.Cmd(Msg) { - _ = self; + pub fn init(self: *Model, ctx: *zz.Context) zz.Cmd(Msg) { + self.* = .{ + .image_supported = ctx.supportsImages(), + .image_visible = false, + .image_attempted = false, + .image_size_cells = 0, + .image_path = "assets/cat.png", + }; return .none; } /// Handle messages and update state - pub fn update(self: *Model, msg: Msg, _: *zz.Context) zz.Cmd(Msg) { - _ = self; + 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| if (c == 'q') return .quit, + .char => |c| switch (c) { + 'q' => return .quit, + 'i' => { + self.image_attempted = true; + if (self.image_supported) { + self.image_visible = true; + return self.imageCommand(ctx); + } + }, + else => {}, + }, .escape => return .quit, else => {}, } }, + .window_size => { + if (self.image_supported and self.image_visible) { + return self.imageCommand(ctx); + } + }, } return .none; } + fn imageCommand(self: *Model, ctx: *const zz.Context) zz.Cmd(Msg) { + const layout = self.computeImageLayout(ctx); + return .{ .image_file = .{ + .path = self.image_path, + .width_cells = layout.size_cells, + .height_cells = layout.size_cells, + .placement = .top_left, + .row = layout.row, + .col = layout.col, + .move_cursor = false, + } }; + } + + fn pickImageSize(_: *const Model, ctx: *const zz.Context) u16 { + return @max( + @as(u16, 6), + @min(@as(u16, 16), @min(ctx.width -| 2, ctx.height -| 2)), + ); + } + + fn textBlockLineCount(_: *const Model) u16 { + // title + blank + subtitle + blank + hint + image-hint + status + return 7; + } + + fn computeImageLayout(self: *Model, ctx: *const zz.Context) ImageLayout { + const size_cells = self.pickImageSize(ctx); + self.image_size_cells = size_cells; + + const text_lines = self.textBlockLineCount(); + const slot_height = size_cells +| image_gap_lines; + const container_height = text_lines +| slot_height; + const container_top: u16 = if (ctx.height > container_height) + (ctx.height - container_height) / 2 + else + 0; + + const row = @min(container_top +| text_lines +| image_gap_lines, ctx.height -| 1); + const col: u16 = if (ctx.width > size_cells) (ctx.width - size_cells) / 2 else 0; + + return .{ + .size_cells = size_cells, + .row = row, + .col = @min(col, ctx.width -| 1), + }; + } + /// Render the view pub fn view(self: *const Model, ctx: *const zz.Context) []const u8 { - _ = self; - var title_style = zz.Style{}; title_style = title_style.bold(true); title_style = title_style.fg(zz.Color.cyan()); @@ -50,28 +130,94 @@ const Model = struct { hint_style = hint_style.fg(zz.Color.gray(12)); hint_style = hint_style.inline_style(true); + var image_hint_style = zz.Style{}; + image_hint_style = image_hint_style.fg(zz.Color.gray(16)); + image_hint_style = image_hint_style.inline_style(true); + const title = title_style.render(ctx.allocator, "Hello, ZigZag!") catch "Hello, ZigZag!"; const subtitle = subtitle_style.render(ctx.allocator, "A TUI library for Zig") catch ""; 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" + 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 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) + "Image skipped: unsupported terminal protocol" + else + ""; + 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 max_width = @max(title_width, @max(subtitle_width, hint_width)); + const image_hint_width = zz.measure.width(image_hint); + const status_width = zz.measure.width(status); + const max_width = @max( + title_width, + @max( + subtitle_width, + @max(hint_width, @max(image_hint_width, status_width)), + ), + ); // Center each element const centered_title = zz.place.place(ctx.allocator, max_width, 1, .center, .top, title) catch title; 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_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}", - .{ centered_title, centered_subtitle, centered_hint }, + "{s}\n\n{s}\n\n{s}\n{s}\n{s}", + .{ centered_title, centered_subtitle, centered_hint, centered_image_hint, centered_status }, ) catch "Error rendering view"; - // Center in terminal + if (self.image_supported and self.image_visible) { + const image_size = if (self.image_size_cells > 0) self.image_size_cells else self.pickImageSize(ctx); + const slot_height = @as(usize, image_size) + image_gap_lines; + const container_width = @max(max_width, @as(usize, image_size)); + const text_height = zz.measure.height(content); + + const centered_text = zz.place.place( + ctx.allocator, + container_width, + text_height, + .center, + .top, + content, + ) catch content; + + const image_slot = zz.place.place( + ctx.allocator, + container_width, + slot_height, + .left, + .top, + "", + ) catch ""; + + const container = zz.joinVertical( + ctx.allocator, + &.{ centered_text, image_slot }, + ) catch centered_text; + + return zz.place.place( + ctx.allocator, + ctx.width, + ctx.height, + .center, + .middle, + container, + ) catch container; + } + return zz.place.place( ctx.allocator, ctx.width, diff --git a/src/core/command.zig b/src/core/command.zig index 21a0384..9743d67 100644 --- a/src/core/command.zig +++ b/src/core/command.zig @@ -3,6 +3,42 @@ const std = @import("std"); +/// Parameters for image rendering by file path. +pub const ImagePlacement = enum { + /// Use the current cursor position. + cursor, + /// Draw from top-left corner. + top_left, + /// Draw from top-center using width hint. + top_center, + /// Center using provided width/height cell hints. + center, +}; + +/// Parameters for image rendering by file path. +pub const ImageFile = struct { + path: []const u8, + width_cells: ?u16 = null, + height_cells: ?u16 = null, + placement: ImagePlacement = .top_left, + /// Optional absolute row (0-indexed). Overrides anchor row when provided. + row: ?u16 = null, + /// Optional absolute column (0-indexed). Overrides anchor column when provided. + col: ?u16 = null, + /// Signed row offset (in terminal cells) applied after anchor/absolute position. + row_offset: i16 = 0, + /// Signed column offset (in terminal cells) applied after anchor/absolute position. + col_offset: i16 = 0, + preserve_aspect_ratio: bool = true, + image_id: ?u32 = null, + placement_id: ?u32 = null, + move_cursor: bool = true, + quiet: bool = true, +}; + +/// Backward-compatible alias for existing Kitty-only APIs. +pub const KittyImageFile = ImageFile; + /// Command type parameterized by the user's message type pub fn Cmd(comptime Msg: type) type { return union(enum) { @@ -45,6 +81,12 @@ pub fn Cmd(comptime Msg: type) type { /// Print a line above the program output println: []const u8, + /// Draw an image file using the best available protocol (Kitty, iTerm2, Sixel) + image_file: ImageFile, + + /// Draw an image file via Kitty graphics protocol (no-op if unsupported) + kitty_image_file: KittyImageFile, + const Self = @This(); /// Create a none command @@ -121,6 +163,8 @@ pub const StandardCmd = union(enum) { hide_cursor, enter_alt_screen, exit_alt_screen, + image_file: ImageFile, + kitty_image_file: KittyImageFile, }; /// Combine multiple commands into a batch diff --git a/src/core/context.zig b/src/core/context.zig index 7c97215..86f04ed 100644 --- a/src/core/context.zig +++ b/src/core/context.zig @@ -132,6 +132,65 @@ pub const Context = struct { if (y >= self.height) return self.height -| 1; return @intCast(y); } + + /// Returns whether Kitty graphics protocol is available. + pub fn supportsKittyGraphics(self: *const Context) bool { + if (self._terminal) |term| { + return term.supportsKittyGraphics(); + } + return false; + } + + /// Returns whether iTerm2 inline images are available. + pub fn supportsIterm2InlineImages(self: *const Context) bool { + if (self._terminal) |term| { + return term.supportsIterm2InlineImages(); + } + return false; + } + + /// Returns whether Sixel graphics are available. + pub fn supportsSixel(self: *const Context) bool { + if (self._terminal) |term| { + return term.supportsSixel(); + } + return false; + } + + /// Returns whether any inline image protocol is available. + pub fn supportsImages(self: *const Context) bool { + if (self._terminal) |term| { + return term.supportsImages(); + } + return false; + } + + /// Draw a PNG image file via Kitty graphics protocol (`t=f`). + /// Returns false when unsupported or path is empty. + pub fn drawKittyImageFromFile(self: *Context, path: []const u8, options: Terminal.KittyImageFileOptions) !bool { + if (self._terminal) |term| { + return term.drawKittyImageFromFile(path, options); + } + 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 { + if (self._terminal) |term| { + return term.drawSixelFromFile(path, options); + } + return false; + } + + /// Draw an image file using the best available protocol. + /// Returns false when unsupported or path is empty. + pub fn drawImageFromFile(self: *Context, path: []const u8, options: Terminal.ImageFileOptions) !bool { + if (self._terminal) |term| { + return term.drawImageFromFile(path, options); + } + return false; + } }; /// Options that can be modified during runtime diff --git a/src/core/program.zig b/src/core/program.zig index 592437c..5995807 100644 --- a/src/core/program.zig +++ b/src/core/program.zig @@ -16,6 +16,11 @@ const unicode = @import("../unicode.zig"); pub const Cmd = command.Cmd; pub const Msg = message; +const PendingImage = union(enum) { + auto: command.ImageFile, + kitty: command.KittyImageFile, +}; + /// Program runtime that manages the application lifecycle pub fn Program(comptime Model: type) type { // Ensure Model has required declarations @@ -53,6 +58,7 @@ pub fn Program(comptime Model: type) type { last_every_tick: u64, last_view_hash: u64, last_line_count: usize, + pending_image: ?PendingImage, logger: ?Logger, /// Message filter function @@ -88,6 +94,7 @@ pub fn Program(comptime Model: type) type { .last_every_tick = 0, .last_view_hash = 0, .last_line_count = 0, + .pending_image = null, .logger = null, .filter = null, }; @@ -290,6 +297,7 @@ pub fn Program(comptime Model: type) type { // Render try self.render(); + try self.flushPendingImage(); } /// Dispatch a message to the model, applying the filter if set @@ -496,9 +504,120 @@ pub fn Program(comptime Model: type) type { try term.flush(); } }, + .image_file => |image| { + self.pending_image = .{ .auto = image }; + }, + .kitty_image_file => |image| { + self.pending_image = .{ .kitty = image }; + }, } } + fn flushPendingImage(self: *Self) !void { + const pending = self.pending_image orelse return; + self.pending_image = null; + if (self.terminal) |*term| { + switch (pending) { + .auto => |image| { + if (!image.move_cursor) { + try term.writer().writeAll(ansi.cursor_save); + } + try self.positionPendingImage(term, image); + _ = try term.drawImageFromFile(image.path, .{ + .width_cells = image.width_cells, + .height_cells = image.height_cells, + .preserve_aspect_ratio = image.preserve_aspect_ratio, + .image_id = image.image_id, + .placement_id = image.placement_id, + .move_cursor = image.move_cursor, + .quiet = image.quiet, + }); + if (!image.move_cursor) { + try term.writer().writeAll(ansi.cursor_restore); + } + }, + .kitty => |image| { + if (!image.move_cursor) { + try term.writer().writeAll(ansi.cursor_save); + } + try self.positionPendingImage(term, image); + _ = try term.drawKittyImageFromFile(image.path, .{ + .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, + }); + if (!image.move_cursor) { + try term.writer().writeAll(ansi.cursor_restore); + } + }, + } + try term.flush(); + } + } + + fn positionPendingImage(self: *Self, term: *Terminal, image: command.ImageFile) !void { + var row: u16 = 0; + var col: u16 = 0; + + switch (image.placement) { + .cursor => return, + .top_left => { + row = 0; + col = 0; + }, + .top_center => { + if (image.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); + } + } + row = 0; + }, + .center => { + if (image.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| { + const term_height = @as(usize, self.context.height); + const image_height = @as(usize, h_cells); + if (term_height > image_height) { + row = @intCast((term_height - image_height) / 2); + } + } + }, + } + + if (image.row) |r| row = r; + if (image.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); + + try term.moveTo(row, col); + } + + fn applySignedOffsetClamped(base: u16, offset: i16, max: u16) u16 { + const base_i32 = @as(i32, @intCast(base)); + const offset_i32 = @as(i32, offset); + const max_i32 = @as(i32, @intCast(max)); + var value = base_i32 + offset_i32; + if (value < 0) value = 0; + if (value > max_i32) value = max_i32; + return @intCast(value); + } + fn sleepNs(nanoseconds: u64) void { if (nanoseconds == 0) return; const ns_per_s: u64 = if (@hasDecl(std.time, "ns_per_s")) std.time.ns_per_s else 1_000_000_000; diff --git a/src/terminal/ansi.zig b/src/terminal/ansi.zig index 56cb1f0..48484b9 100644 --- a/src/terminal/ansi.zig +++ b/src/terminal/ansi.zig @@ -8,6 +8,8 @@ pub const ESC = "\x1b"; pub const CSI = ESC ++ "["; pub const OSC = ESC ++ "]"; pub const DCS = ESC ++ "P"; +pub const APC = ESC ++ "_"; +pub const ST = ESC ++ "\\"; // Cursor control pub const cursor_hide = CSI ++ "?25l"; @@ -231,3 +233,21 @@ pub fn bgRgb(writer: anytype, r: u8, g: u8, b: u8) !void { pub fn hyperlink(writer: anytype, url: []const u8, text: []const u8) !void { try writer.print(OSC ++ "8;;{s}\x07{s}" ++ OSC ++ "8;;\x07", .{ url, text }); } + +/// Kitty graphics protocol command (APC G ... ST) +pub fn kittyGraphics(writer: anytype, params: []const u8, payload: []const u8) !void { + try writer.writeAll(APC ++ "G"); + try writer.writeAll(params); + try writer.writeAll(";"); + try writer.writeAll(payload); + try writer.writeAll(ST); +} + +/// iTerm2 inline image command (OSC 1337;File=...:... BEL) +pub fn iterm2InlineImage(writer: anytype, params: []const u8, payload: []const u8) !void { + try writer.writeAll(OSC ++ "1337;File="); + try writer.writeAll(params); + try writer.writeAll(":"); + try writer.writeAll(payload); + try writer.writeAll("\x07"); +} diff --git a/src/terminal/terminal.zig b/src/terminal/terminal.zig index 26addf9..95346f6 100644 --- a/src/terminal/terminal.zig +++ b/src/terminal/terminal.zig @@ -22,6 +22,64 @@ pub const UnicodeWidthCapabilities = struct { strategy: unicode.WidthStrategy = .legacy_wcwidth, }; +pub const ImageCapabilities = struct { + kitty_graphics: bool = false, + iterm2_inline_image: bool = false, + sixel: bool = false, +}; + +const Iterm2Capabilities = struct { + inline_image: bool = false, + sixel: bool = false, +}; + +pub const KittyImageFormat = enum(u16) { + rgb = 24, + rgba = 32, + png = 100, +}; + +pub const KittyImageOptions = struct { + format: KittyImageFormat = .png, + width_cells: ?u16 = null, + height_cells: ?u16 = null, + image_id: ?u32 = null, + placement_id: ?u32 = null, + move_cursor: bool = true, + quiet: bool = true, +}; + +pub const KittyImageFileOptions = struct { + width_cells: ?u16 = null, + height_cells: ?u16 = null, + image_id: ?u32 = null, + placement_id: ?u32 = null, + move_cursor: bool = true, + quiet: bool = true, +}; + +pub const Iterm2ImageFileOptions = 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, + preserve_aspect_ratio: bool = true, + image_id: ?u32 = null, + placement_id: ?u32 = null, + move_cursor: bool = true, + quiet: bool = true, +}; + +pub const SixelImageFileOptions = struct { + /// Optional max captured converter output (bytes). + max_output_bytes: usize = 32 * 1024 * 1024, +}; + /// Terminal configuration options pub const Config = struct { /// Use alternate screen buffer @@ -49,6 +107,7 @@ pub const Terminal = struct { write_buffer: [4096]u8 = undefined, write_pos: usize = 0, unicode_width_caps: UnicodeWidthCapabilities = .{}, + image_caps: ImageCapabilities = .{}, pub fn init(config: Config) !Terminal { const stdout = config.output orelse std.fs.File.stdout(); @@ -114,6 +173,7 @@ pub const Terminal = struct { } self.detectUnicodeWidthCapabilities(); + self.detectImageCapabilities(); // Clear screen try self.writeBytes(ansi.screen_clear); @@ -273,6 +333,160 @@ pub const Terminal = struct { return self.unicode_width_caps; } + pub fn getImageCapabilities(self: *const Terminal) ImageCapabilities { + return self.image_caps; + } + + pub fn supportsKittyGraphics(self: *const Terminal) bool { + return self.image_caps.kitty_graphics; + } + + pub fn supportsIterm2InlineImages(self: *const Terminal) bool { + return self.image_caps.iterm2_inline_image; + } + + pub fn supportsImages(self: *const Terminal) bool { + return self.supportsKittyGraphics() or self.supportsIterm2InlineImages() or self.supportsSixel(); + } + + pub fn supportsSixel(self: *const Terminal) bool { + return self.image_caps.sixel; + } + + /// Draw image bytes using Kitty graphics protocol (`t=d`). + /// Returns `false` when unsupported or no data is provided. + 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 stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.print("a=T,t=d,f={d}", .{@intFromEnum(options.format)}); + if (options.quiet) try params_writer.writeAll(",q=2"); + 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.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"); + + try self.sendKittyGraphicsPayload(stream.getWritten(), image_data); + return true; + } + + /// Draw a PNG image by file path using Kitty graphics protocol (`t=f`). + /// Returns `false` when unsupported or path is empty. + 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 stream = std.io.fixedBufferStream(¶ms_buf); + const params_writer = stream.writer(); + + try params_writer.writeAll("a=T,t=f,f=100"); + if (options.quiet) try params_writer.writeAll(",q=2"); + 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.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"); + + try self.sendKittyGraphicsPayload(stream.getWritten(), path); + 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; + + var file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + const stat = try file.stat(); + + var params_buf: [192]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}", .{stat.size}); + const file_name = std.fs.path.basename(path); + const file_name_b64_len = std.base64.standard.Encoder.calcSize(file_name.len); + if (file_name_b64_len <= 512) { + var file_name_b64_buf: [512]u8 = undefined; + const file_name_b64 = std.base64.standard.Encoder.encode(file_name_b64_buf[0..file_name_b64_len], file_name); + try params_writer.print(";name={s}", .{file_name_b64}); + } + + try self.sendIterm2InlineImagePayload(stream.getWritten(), &file, stat.size); + return true; + } + + /// Draw an image file using the best available protocol. + /// Prefers Kitty graphics, then iTerm2 inline images. + 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, + }); + } + if (self.image_caps.sixel) { + return self.drawSixelFromFile(path, .{}); + } + return false; + } + + /// Draw a Sixel image from file. + /// Supports either: + /// - pre-encoded `.sixel`/`.six` data files, or + /// - 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 (isSixelDataPath(path)) { + var file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + try self.sendSixelPayloadFromFile(&file); + return true; + } + + if (!commandExists("img2sixel")) return false; + + const argv = [_][]const u8{ "img2sixel", path }; + const result = try std.process.Child.run(.{ + .allocator = std.heap.page_allocator, + .argv = &argv, + .max_output_bytes = options.max_output_bytes, + }); + defer std.heap.page_allocator.free(result.stdout); + defer std.heap.page_allocator.free(result.stderr); + + switch (result.term) { + .Exited => |code| if (code != 0) return error.BrokenPipe, + else => return error.BrokenPipe, + } + + try self.sendSixelPayload(result.stdout); + return true; + } + fn detectUnicodeWidthCapabilities(self: *Terminal) void { self.unicode_width_caps = .{ .kitty_text_sizing = self.queryKittyTextSizingSupport() catch false, @@ -292,6 +506,323 @@ pub const Terminal = struct { self.unicode_width_caps.strategy = self.selectWidthStrategy(); } + fn detectImageCapabilities(self: *Terminal) void { + if (!self.isTty()) { + self.image_caps = .{}; + return; + } + + const term_features_owned = std.process.getEnvVarOwned(std.heap.page_allocator, "TERM_FEATURES") catch null; + defer if (term_features_owned) |value| std.heap.page_allocator.free(value); + const term_features = if (term_features_owned) |value| value else ""; + + const kitty_candidate = looksLikeKittyTerminal() or + envVarEquals("TERM_PROGRAM", "WezTerm") or + envVarContains("TERM", "wezterm") or + envVarContains("TERM", "ghostty"); + const iterm_candidate = looksLikeIterm2Terminal() or + envVarEquals("TERM_PROGRAM", "WezTerm"); + const in_multiplexer = isInsideMultiplexer(); + + var kitty = false; + var iterm = iterm_candidate or termFeaturesContain(term_features, "F"); + var sixel = looksLikeSixelTerminal() or termFeaturesContain(term_features, "Sx"); + + if (kitty_candidate) { + kitty = self.queryKittyGraphicsSupport() catch false; + // Keep an env fallback only outside multiplexers where probe failures are uncommon. + if (!kitty and !in_multiplexer) { + kitty = envVarExists("KITTY_WINDOW_ID"); + } + } + + if (iterm_candidate or term_features.len > 0) { + if (self.queryIterm2Capabilities() catch null) |caps| { + iterm = iterm or caps.inline_image; + sixel = sixel or caps.sixel; + } + } + + if (!sixel) sixel = self.queryPrimaryDeviceAttributesHasParam(4) catch false; + + self.image_caps = .{ + .kitty_graphics = kitty, + .iterm2_inline_image = iterm, + .sixel = sixel, + }; + } + + fn sendKittyGraphicsPayload(self: *Terminal, first_params: []const u8, payload: []const u8) !void { + const encoder = std.base64.standard.Encoder; + var b64_buf: [4096]u8 = undefined; + const raw_chunk_max: usize = (b64_buf.len / 4) * 3; + + var src_index: usize = 0; + var first = true; + + while (true) { + const remaining = payload.len - src_index; + const take = @min(remaining, raw_chunk_max); + const chunk = payload[src_index .. src_index + take]; + + const encoded_len = encoder.calcSize(chunk.len); + const encoded = encoder.encode(b64_buf[0..encoded_len], chunk); + const has_more = src_index + take < payload.len; + + if (first) { + var first_chunk_params: [192]u8 = undefined; + const params = try std.fmt.bufPrint( + &first_chunk_params, + "{s},m={d}", + .{ first_params, if (has_more) @as(u8, 1) else @as(u8, 0) }, + ); + try ansi.kittyGraphics(self.writer(), params, encoded); + first = false; + } else { + const params = if (has_more) "m=1" else "m=0"; + try ansi.kittyGraphics(self.writer(), params, encoded); + } + + if (!has_more) break; + src_index += take; + } + } + + fn sendIterm2InlineImagePayload(self: *Terminal, params: []const u8, file: *std.fs.File, file_size: u64) !void { + const encoder = std.base64.standard.Encoder; + var raw_buf: [3072]u8 = undefined; + var b64_buf: [4096]u8 = undefined; + const encoded_total = std.math.cast(usize, encoder.calcSize(@intCast(file_size))) orelse std.math.maxInt(usize); + 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(":"); + + while (true) { + const n = try file.read(&raw_buf); + if (n == 0) break; + const encoded_len = encoder.calcSize(n); + const encoded = encoder.encode(b64_buf[0..encoded_len], raw_buf[0..n]); + try self.writeBytes(encoded); + } + try self.writeBytes("\x07"); + return; + } + + // iTerm2 supports multipart transfer to avoid oversized OSC sequences. + try self.writeBytes(ansi.OSC ++ "1337;MultipartFile="); + try self.writeBytes(params); + try self.writeBytes("\x07"); + + while (true) { + const n = try file.read(&raw_buf); + if (n == 0) break; + const encoded_len = encoder.calcSize(n); + const encoded = encoder.encode(b64_buf[0..encoded_len], raw_buf[0..n]); + try self.writeBytes(ansi.OSC ++ "1337;FilePart="); + try self.writeBytes(encoded); + try self.writeBytes("\x07"); + } + + 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; + var wrapped = false; + + while (true) { + const n = try file.read(&payload_buf); + if (n == 0) break; + const chunk = payload_buf[0..n]; + + if (first_read) { + first_read = false; + if (isLikelyFullSixelSequence(chunk)) { + wrapped = true; + } else { + try self.writeBytes(ansi.DCS ++ "q"); + } + } + try self.writeBytes(chunk); + } + + if (!first_read and !wrapped) { + try self.writeBytes(ansi.ST); + } + } + + fn sendSixelPayload(self: *Terminal, bytes: []const u8) !void { + if (bytes.len == 0) return; + if (isLikelyFullSixelSequence(bytes)) { + try self.writeBytes(bytes); + return; + } + try self.writeBytes(ansi.DCS ++ "q"); + try self.writeBytes(bytes); + try self.writeBytes(ansi.ST); + } + + fn queryKittyGraphicsSupport(self: *Terminal) !bool { + const probe_id: u32 = 9931; + self.drainInput(); + try ansi.kittyGraphics(self.writer(), "a=q,i=9931,s=1,v=1,t=d,f=24", "AAAA"); + try self.flush(); + + var collected: [1024]u8 = undefined; + var collected_len: usize = 0; + const deadline_ms = std.time.milliTimestamp() + 180; + + while (std.time.milliTimestamp() < deadline_ms) { + var chunk: [128]u8 = undefined; + const n = self.readInput(&chunk, 30) catch 0; + if (n == 0) continue; + + const copy_len = @min(n, collected.len - collected_len); + if (copy_len > 0) { + @memcpy(collected[collected_len .. collected_len + copy_len], chunk[0..copy_len]); + collected_len += copy_len; + } + + if (parseKittyGraphicsProbeResponse(collected[0..collected_len], probe_id)) |supported| { + return supported; + } + } + + return false; + } + + fn parseKittyGraphicsProbeResponse(bytes: []const u8, probe_id: u32) ?bool { + const prefix = "\x1b_G"; + var search_from: usize = 0; + + var id_buf: [24]u8 = undefined; + const expected_id = std.fmt.bufPrint(&id_buf, "i={d}", .{probe_id}) catch return null; + + while (search_from < bytes.len) { + const start = std.mem.indexOfPos(u8, bytes, search_from, prefix) orelse return null; + const content_start = start + prefix.len; + const semicolon = std.mem.indexOfScalarPos(u8, bytes, content_start, ';') orelse return null; + const st_index = indexOfSt(bytes, semicolon + 1) orelse return null; + + const params = bytes[content_start..semicolon]; + const payload = bytes[semicolon + 1 .. st_index]; + + if (std.mem.indexOf(u8, params, expected_id) != null) { + return std.mem.startsWith(u8, payload, "OK"); + } + + search_from = st_index + 2; + } + + return null; + } + + fn queryIterm2Capabilities(self: *Terminal) !?Iterm2Capabilities { + self.drainInput(); + try self.writeBytes(ansi.OSC ++ "1337;Capabilities\x07"); + try self.flush(); + + var collected: [2048]u8 = undefined; + var collected_len: usize = 0; + const deadline_ms = std.time.milliTimestamp() + 180; + + while (std.time.milliTimestamp() < deadline_ms) { + var chunk: [128]u8 = undefined; + const n = self.readInput(&chunk, 30) catch 0; + if (n == 0) continue; + + const copy_len = @min(n, collected.len - collected_len); + if (copy_len > 0) { + @memcpy(collected[collected_len .. collected_len + copy_len], chunk[0..copy_len]); + collected_len += copy_len; + } + + if (parseIterm2CapabilitiesResponse(collected[0..collected_len])) |caps| { + return caps; + } + } + + return null; + } + + fn parseIterm2CapabilitiesResponse(bytes: []const u8) ?Iterm2Capabilities { + const prefix = "\x1b]1337;Capabilities="; + const start = std.mem.indexOf(u8, bytes, prefix) orelse return null; + const payload_start = start + prefix.len; + const end = indexOfOscTerminator(bytes, payload_start) orelse return null; + const payload = bytes[payload_start..end]; + return .{ + .inline_image = termFeaturesContain(payload, "F"), + .sixel = termFeaturesContain(payload, "Sx"), + }; + } + + fn queryPrimaryDeviceAttributesHasParam(self: *Terminal, needle: u16) !bool { + self.drainInput(); + try self.writeBytes(ansi.CSI ++ "c"); + try self.flush(); + + var collected: [1024]u8 = undefined; + var collected_len: usize = 0; + const deadline_ms = std.time.milliTimestamp() + 120; + + while (std.time.milliTimestamp() < deadline_ms) { + var chunk: [128]u8 = undefined; + const n = self.readInput(&chunk, 25) catch 0; + if (n == 0) continue; + + const copy_len = @min(n, collected.len - collected_len); + if (copy_len > 0) { + @memcpy(collected[collected_len .. collected_len + copy_len], chunk[0..copy_len]); + collected_len += copy_len; + } + + if (parsePrimaryDeviceAttributes(collected[0..collected_len])) |params| { + return primaryDeviceAttributesHasParam(params, needle); + } + } + + return false; + } + + fn parsePrimaryDeviceAttributes(bytes: []const u8) ?[]const u8 { + const prefix = "\x1b["; + var search_from: usize = 0; + + while (search_from < bytes.len) { + const start = std.mem.indexOfPos(u8, bytes, search_from, prefix) orelse return null; + var i = start + prefix.len; + + if (i < bytes.len and bytes[i] == '?') i += 1; + const params_start = i; + + while (i < bytes.len and ((bytes[i] >= '0' and bytes[i] <= '9') or bytes[i] == ';')) : (i += 1) {} + if (i >= bytes.len) return null; + + if (bytes[i] == 'c' and i > params_start) { + return bytes[params_start..i]; + } + + search_from = start + 1; + } + + return null; + } + + fn primaryDeviceAttributesHasParam(params: []const u8, needle: u16) bool { + var it = std.mem.splitScalar(u8, params, ';'); + while (it.next()) |part| { + if (part.len == 0) continue; + const value = std.fmt.parseInt(u16, part, 10) catch continue; + if (value == needle) return true; + } + return false; + } + fn queryMode2027Support(self: *Terminal) !bool { try self.writeBytes(ansi.unicode_width_mode_query); try self.flush(); @@ -443,6 +974,56 @@ pub const Terminal = struct { return envVarExists("TMUX") or envVarExists("ZELLIJ") or envVarContains("TERM", "screen"); } + fn drainInput(self: *Terminal) void { + var buf: [128]u8 = undefined; + while (true) { + const n = self.readInput(&buf, 0) catch return; + if (n == 0) return; + } + } + + fn termFeaturesContain(features: []const u8, needle: []const u8) bool { + if (features.len == 0 or needle.len == 0) return false; + + var i: usize = 0; + while (i < features.len) { + if (!std.ascii.isUpper(features[i])) { + i += 1; + continue; + } + + var j = i + 1; + while (j < features.len and std.ascii.isLower(features[j])) : (j += 1) {} + const code = features[i..j]; + + while (j < features.len and std.ascii.isDigit(features[j])) : (j += 1) {} + if (std.mem.eql(u8, code, needle)) return true; + + i = j; + } + + return false; + } + + fn indexOfOscTerminator(bytes: []const u8, start: usize) ?usize { + var i = start; + while (i < bytes.len) : (i += 1) { + if (bytes[i] == 0x07) return i; + if (bytes[i] == 0x1b and i + 1 < bytes.len and bytes[i + 1] == '\\') { + return i; + } + } + return null; + } + + fn indexOfSt(bytes: []const u8, start: usize) ?usize { + var i = start; + while (i + 1 < bytes.len) : (i += 1) { + if (bytes[i] == 0x1b and bytes[i + 1] == '\\') return i; + } + return null; + } + fn isKnownUnicodeWidthTerminal() bool { // Terminals known to use grapheme-aware width by default. return envVarEquals("TERM_PROGRAM", "WezTerm") or @@ -455,6 +1036,42 @@ pub const Terminal = struct { return envVarExists("KITTY_WINDOW_ID") or envVarContains("TERM", "kitty"); } + fn looksLikeIterm2Terminal() bool { + return envVarEquals("TERM_PROGRAM", "iTerm.app") or + envVarEquals("LC_TERMINAL", "iTerm2"); + } + + fn looksLikeSixelTerminal() bool { + return envVarContains("TERM", "sixel") or + envVarContains("TERM", "mlterm") or + envVarContains("TERM", "yaft") or + envVarContains("TERM", "contour"); + } + + fn isLikelyFullSixelSequence(bytes: []const u8) bool { + if (bytes.len == 0) return false; + return std.mem.startsWith(u8, bytes, ansi.DCS) or bytes[0] == 0x90; + } + + fn isSixelDataPath(path: []const u8) bool { + return std.mem.endsWith(u8, path, ".sixel") or + std.mem.endsWith(u8, path, ".SIXEL") or + std.mem.endsWith(u8, path, ".six") or + std.mem.endsWith(u8, path, ".SIX"); + } + + fn commandExists(name: []const u8) bool { + const argv = [_][]const u8{ name, "--version" }; + const result = std.process.Child.run(.{ + .allocator = std.heap.page_allocator, + .argv = &argv, + .max_output_bytes = 1024, + }) catch return false; + defer std.heap.page_allocator.free(result.stdout); + defer std.heap.page_allocator.free(result.stderr); + return true; + } + fn envVarExists(name: []const u8) bool { const value = std.process.getEnvVarOwned(std.heap.page_allocator, name) catch return false; defer std.heap.page_allocator.free(value);