Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 46 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Binary file added assets/cat.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
168 changes: 157 additions & 11 deletions examples/hello_world.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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,
Expand Down
44 changes: 44 additions & 0 deletions src/core/command.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading