feat: Add terminal extensions and new widgets

Hyperlinks (OSC 8):
- Clickable hyperlinks with URL and display text
- ID support for link grouping
- URL encoding utility

Notifications (OSC 9/777):
- iTerm2-style (OSC 9) and urxvt-style (OSC 777)
- NotificationManager with title/body support

Image display (Kitty/iTerm2):
- Kitty Graphics Protocol with chunked transmission
- iTerm2 inline images
- File and raw data display

Tree widget:
- Hierarchical tree view with expand/collapse
- Customizable symbols (Unicode/ASCII)
- TreeState for selection navigation

FilePicker widget:
- File browser using Tree widget
- Lazy-loading directory contents
- Hidden files toggle, file icons

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
reugenio 2025-12-08 17:34:33 +01:00
parent 8c218a3f0d
commit 5ac74ebff5
6 changed files with 1881 additions and 0 deletions

269
src/hyperlink.zig Normal file
View file

@ -0,0 +1,269 @@
//! Hyperlink support for terminals using OSC 8.
//!
//! OSC 8 is a terminal escape sequence that creates clickable hyperlinks,
//! similar to HTML anchor tags. Supported by modern terminals like:
//! - iTerm2
//! - Windows Terminal
//! - GNOME Terminal (VTE-based)
//! - Kitty
//! - Alacritty
//!
//! ## Example
//!
//! ```zig
//! const hyperlink = @import("hyperlink.zig");
//!
//! // Create a simple link
//! var buf: [256]u8 = undefined;
//! const link = hyperlink.Hyperlink.init("https://example.com", "Click here");
//! const seq = link.format(&buf);
//! writer.writeAll(seq);
//!
//! // Or use the formatter directly
//! try hyperlink.writeLink(writer, "https://example.com", "Visit Example");
//! ```
//!
//! ## Reference
//!
//! - Specification: https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda
//! - Adoption list: https://github.com/Alhadis/OSC8-Adoption
const std = @import("std");
/// OSC 8 hyperlink escape sequences.
pub const Hyperlink = struct {
/// The URL target of the hyperlink.
url: []const u8,
/// Optional display text (if null, URL is displayed).
text: ?[]const u8 = null,
/// Optional ID for grouping links (same URL + different ID = separate highlight).
id: ?[]const u8 = null,
/// Creates a hyperlink with URL and display text.
pub fn init(url: []const u8, text: []const u8) Hyperlink {
return .{
.url = url,
.text = text,
};
}
/// Creates a hyperlink with only URL (URL is displayed).
pub fn initUrl(url: []const u8) Hyperlink {
return .{
.url = url,
};
}
/// Sets the ID parameter.
pub fn setId(self: Hyperlink, id: []const u8) Hyperlink {
var h = self;
h.id = id;
return h;
}
/// Formats the hyperlink as an OSC 8 escape sequence.
/// Format: ESC ] 8 ; params ; URL ST text ESC ] 8 ; ; ST
pub fn format(self: Hyperlink, buf: []u8) []const u8 {
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
self.write(writer) catch return "";
return stream.getWritten();
}
/// Writes the hyperlink escape sequence to a writer.
pub fn write(self: Hyperlink, writer: anytype) !void {
// Start hyperlink: ESC ] 8 ; params ; URL ST
try writer.writeAll("\x1b]8;");
// Write params (id=value if present)
if (self.id) |id| {
try writer.writeAll("id=");
try writer.writeAll(id);
}
try writer.writeAll(";");
try writer.writeAll(self.url);
try writer.writeAll("\x1b\\"); // ST (String Terminator)
// Write display text
if (self.text) |text| {
try writer.writeAll(text);
} else {
try writer.writeAll(self.url);
}
// End hyperlink: ESC ] 8 ; ; ST
try writer.writeAll("\x1b]8;;\x1b\\");
}
/// Returns the length needed to format this hyperlink.
pub fn formatLen(self: Hyperlink) usize {
// "\x1b]8;" + params + ";" + url + "\x1b\\" + text + "\x1b]8;;\x1b\\"
var len: usize = 4; // "\x1b]8;"
if (self.id) |id| {
len += 3 + id.len; // "id=" + id
}
len += 1; // ";"
len += self.url.len;
len += 2; // "\x1b\\"
if (self.text) |text| {
len += text.len;
} else {
len += self.url.len;
}
len += 6; // "\x1b]8;;\x1b\\"
return len;
}
};
/// Writes a hyperlink to the given writer.
pub fn writeLink(writer: anytype, url: []const u8, text: []const u8) !void {
const link = Hyperlink.init(url, text);
try link.write(writer);
}
/// Writes a hyperlink with only URL (URL is displayed).
pub fn writeLinkUrl(writer: anytype, url: []const u8) !void {
const link = Hyperlink.initUrl(url);
try link.write(writer);
}
/// Starts a hyperlink (without ending it).
/// Call `endLink` after the link text.
pub fn startLink(writer: anytype, url: []const u8, id: ?[]const u8) !void {
try writer.writeAll("\x1b]8;");
if (id) |i| {
try writer.writeAll("id=");
try writer.writeAll(i);
}
try writer.writeAll(";");
try writer.writeAll(url);
try writer.writeAll("\x1b\\");
}
/// Ends a hyperlink.
pub fn endLink(writer: anytype) !void {
try writer.writeAll("\x1b]8;;\x1b\\");
}
/// URL-encodes a string for use in hyperlinks.
/// Characters outside 32-126 range must be encoded.
pub fn urlEncode(input: []const u8, buf: []u8) []const u8 {
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
for (input) |c| {
if (c >= 32 and c <= 126 and c != '%' and c != ' ') {
writer.writeByte(c) catch break;
} else {
// Percent-encode
writer.print("%{X:0>2}", .{c}) catch break;
}
}
return stream.getWritten();
}
/// Checks if a terminal likely supports OSC 8 hyperlinks.
/// This is a heuristic based on TERM environment variable.
pub fn isSupported() bool {
const term = std.posix.getenv("TERM") orelse return false;
// Known supporting terminals
const supported = [_][]const u8{
"xterm-256color",
"xterm-kitty",
"alacritty",
"vte",
"gnome",
"konsole",
"iterm2",
"wezterm",
};
for (supported) |s| {
if (std.mem.indexOf(u8, term, s) != null) {
return true;
}
}
// Check TERM_PROGRAM for iTerm2
if (std.posix.getenv("TERM_PROGRAM")) |prog| {
if (std.mem.eql(u8, prog, "iTerm.app")) {
return true;
}
}
// Check for Windows Terminal
if (std.posix.getenv("WT_SESSION") != null) {
return true;
}
return false;
}
// ============================================================================
// Tests
// ============================================================================
test "Hyperlink basic format" {
var buf: [256]u8 = undefined;
const link = Hyperlink.init("https://example.com", "Click");
const result = link.format(&buf);
try std.testing.expectEqualStrings("\x1b]8;;https://example.com\x1b\\Click\x1b]8;;\x1b\\", result);
}
test "Hyperlink with id" {
var buf: [256]u8 = undefined;
const link = Hyperlink.init("https://example.com", "Link").setId("link1");
const result = link.format(&buf);
try std.testing.expectEqualStrings("\x1b]8;id=link1;https://example.com\x1b\\Link\x1b]8;;\x1b\\", result);
}
test "Hyperlink URL only" {
var buf: [256]u8 = undefined;
const link = Hyperlink.initUrl("https://example.com");
const result = link.format(&buf);
try std.testing.expectEqualStrings("\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\", result);
}
test "urlEncode" {
var buf: [256]u8 = undefined;
const result = urlEncode("hello world", &buf);
try std.testing.expectEqualStrings("hello%20world", result);
}
test "Hyperlink formatLen" {
const link = Hyperlink.init("https://example.com", "Click");
const len = link.formatLen();
var buf: [256]u8 = undefined;
const result = link.format(&buf);
try std.testing.expectEqual(result.len, len);
}
test "startLink and endLink" {
var buf: [256]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
const writer = stream.writer();
try startLink(writer, "https://test.com", null);
try writer.writeAll("text");
try endLink(writer);
const result = stream.getWritten();
try std.testing.expectEqualStrings("\x1b]8;;https://test.com\x1b\\text\x1b]8;;\x1b\\", result);
}

394
src/image.zig Normal file
View file

@ -0,0 +1,394 @@
//! Image display support for terminals.
//!
//! Supports multiple terminal image protocols:
//! - **Kitty Graphics Protocol**: Modern, full-featured image protocol
//! - **iTerm2 Inline Images**: Widely supported (also in WezTerm, mintty)
//! - **Sixel**: Legacy protocol for older terminals
//!
//! ## Example
//!
//! ```zig
//! const image = @import("image.zig");
//!
//! // Display an image file using Kitty protocol
//! try image.displayFile(writer, "/path/to/image.png", .{
//! .width = 40,
//! .height = 20,
//! });
//!
//! // Display raw PNG/image data using iTerm2 protocol
//! try image.displayIterm2(writer, png_data, .{});
//! ```
//!
//! ## References
//!
//! - Kitty: https://sw.kovidgoyal.net/kitty/graphics-protocol/
//! - iTerm2: https://iterm2.com/documentation-images.html
const std = @import("std");
const base64 = std.base64.standard;
/// Image display options.
pub const ImageOptions = struct {
/// Width in cells (0 = auto).
width: u16 = 0,
/// Height in cells (0 = auto).
height: u16 = 0,
/// X offset in pixels.
x_offset: u16 = 0,
/// Y offset in pixels.
y_offset: u16 = 0,
/// Preserve aspect ratio.
preserve_aspect: bool = true,
/// Z-index for layering.
z_index: i32 = 0,
};
/// Image format.
pub const ImageFormat = enum {
png,
jpeg,
gif,
rgb,
rgba,
pub fn toKittyFormat(self: ImageFormat) u8 {
return switch (self) {
.png => 100,
.rgb => 24,
.rgba => 32,
else => 100, // Default to PNG
};
}
pub fn toMimeType(self: ImageFormat) []const u8 {
return switch (self) {
.png => "image/png",
.jpeg => "image/jpeg",
.gif => "image/gif",
else => "application/octet-stream",
};
}
};
// ============================================================================
// Kitty Graphics Protocol
// ============================================================================
/// Kitty graphics protocol commands.
pub const Kitty = struct {
/// Action types.
pub const Action = enum(u8) {
transmit = 't',
transmit_display = 'T',
query = 'q',
place = 'p',
delete = 'd',
frame = 'f',
animation = 'a',
};
/// Transmission medium.
pub const Medium = enum(u8) {
direct = 'd', // Data in escape sequence
file = 'f', // Temporary file
temp_file = 't', // Auto-deleted temp file
shared_memory = 's', // POSIX shared memory
};
/// Delete targets.
pub const DeleteTarget = enum(u8) {
all = 'a', // All images
by_id = 'i', // Specific image ID
by_number = 'n', // By image number
cursor_position = 'c', // At cursor
// ... more options
};
/// Displays an image from raw data using Kitty protocol.
pub fn displayData(
writer: anytype,
data: []const u8,
format: ImageFormat,
width: u32,
height: u32,
options: ImageOptions,
) !void {
const encoded_len = base64.Encoder.calcSize(data.len);
const encoded_buf = try std.heap.page_allocator.alloc(u8, encoded_len);
defer std.heap.page_allocator.free(encoded_buf);
const encoded = base64.Encoder.encode(encoded_buf, data);
// Send in chunks (4096 bytes max per chunk)
const chunk_size: usize = 4096;
var offset: usize = 0;
var first = true;
while (offset < encoded.len) {
const end = @min(offset + chunk_size, encoded.len);
const is_last = end == encoded.len;
try writer.writeAll("\x1b_G");
if (first) {
// First chunk: include all parameters
try writer.print("a=T,f={d},s={d},v={d}", .{
format.toKittyFormat(),
width,
height,
});
if (options.width > 0) {
try writer.print(",c={d}", .{options.width});
}
if (options.height > 0) {
try writer.print(",r={d}", .{options.height});
}
if (options.z_index != 0) {
try writer.print(",z={d}", .{options.z_index});
}
first = false;
}
// Chunking control
if (!is_last) {
try writer.writeAll(",m=1");
} else {
try writer.writeAll(",m=0");
}
try writer.writeAll(";");
try writer.writeAll(encoded[offset..end]);
try writer.writeAll("\x1b\\");
offset = end;
}
}
/// Displays an image from a file path.
pub fn displayFile(writer: anytype, path: []const u8, options: ImageOptions) !void {
const encoded_len = base64.Encoder.calcSize(path.len);
var encoded_buf: [4096]u8 = undefined;
const encoded_path = base64.Encoder.encode(encoded_buf[0..encoded_len], path);
try writer.writeAll("\x1b_Ga=T,t=f");
if (options.width > 0) {
try writer.print(",c={d}", .{options.width});
}
if (options.height > 0) {
try writer.print(",r={d}", .{options.height});
}
try writer.writeAll(";");
try writer.writeAll(encoded_path);
try writer.writeAll("\x1b\\");
}
/// Clears all images.
pub fn clearAll(writer: anytype) !void {
try writer.writeAll("\x1b_Ga=d,d=A\x1b\\");
}
/// Clears image at cursor position.
pub fn clearAtCursor(writer: anytype) !void {
try writer.writeAll("\x1b_Ga=d,d=C\x1b\\");
}
/// Queries terminal for graphics support.
pub fn query(writer: anytype) !void {
try writer.writeAll("\x1b_Gi=1,a=q\x1b\\");
}
};
// ============================================================================
// iTerm2 Inline Images
// ============================================================================
/// iTerm2 inline image protocol.
pub const Iterm2 = struct {
/// Display options for iTerm2.
pub const Options = struct {
/// Width (number + unit: auto, px, cells, %).
width: ?[]const u8 = null,
/// Height (number + unit).
height: ?[]const u8 = null,
/// Preserve aspect ratio.
preserve_aspect: bool = true,
/// File name (for download).
name: ?[]const u8 = null,
/// Whether to display inline.
do_inline: bool = true,
};
/// Displays image data using iTerm2 protocol.
pub fn display(writer: anytype, data: []const u8, options: Options) !void {
const encoded_len = base64.Encoder.calcSize(data.len);
const encoded_buf = try std.heap.page_allocator.alloc(u8, encoded_len);
defer std.heap.page_allocator.free(encoded_buf);
const encoded = base64.Encoder.encode(encoded_buf, data);
// Build name param
var name_buf: [256]u8 = undefined;
var name_encoded: []const u8 = "";
if (options.name) |n| {
const name_enc_len = base64.Encoder.calcSize(n.len);
name_encoded = base64.Encoder.encode(name_buf[0..name_enc_len], n);
}
try writer.writeAll("\x1b]1337;File=");
// Parameters
if (name_encoded.len > 0) {
try writer.print("name={s};", .{name_encoded});
}
try writer.print("inline={d}", .{@intFromBool(options.do_inline)});
if (options.width) |w| {
try writer.print(";width={s}", .{w});
}
if (options.height) |h| {
try writer.print(";height={s}", .{h});
}
try writer.print(";preserveAspectRatio={d}", .{@intFromBool(options.preserve_aspect)});
try writer.writeAll(":");
try writer.writeAll(encoded);
try writer.writeByte(0x07); // BEL
}
/// Displays image from file.
pub fn displayFile(writer: anytype, path: []const u8, options: Options) !void {
const file = try std.fs.openFileAbsolute(path, .{});
defer file.close();
const stat = try file.stat();
if (stat.size > 50 * 1024 * 1024) {
return error.FileTooLarge;
}
var data = try std.heap.page_allocator.alloc(u8, stat.size);
defer std.heap.page_allocator.free(data);
const bytes_read = try file.readAll(data);
try display(writer, data[0..bytes_read], options);
}
};
// ============================================================================
// Convenience Functions
// ============================================================================
/// Terminal image protocol to use.
pub const Protocol = enum {
kitty,
iterm2,
auto, // Auto-detect
};
/// Displays an image file.
pub fn displayFile(writer: anytype, path: []const u8, options: ImageOptions) !void {
// Default to Kitty protocol (most capable)
try Kitty.displayFile(writer, path, options);
}
/// Displays raw image data.
pub fn displayData(
writer: anytype,
data: []const u8,
format: ImageFormat,
width: u32,
height: u32,
options: ImageOptions,
) !void {
try Kitty.displayData(writer, data, format, width, height, options);
}
/// Clears all displayed images.
pub fn clearAll(writer: anytype) !void {
try Kitty.clearAll(writer);
}
/// Checks if the terminal likely supports graphics.
pub fn isSupported() bool {
// Check for known supporting terminals
if (std.posix.getenv("KITTY_WINDOW_ID") != null) return true;
if (std.posix.getenv("WEZTERM_PANE") != null) return true;
if (std.posix.getenv("TERM_PROGRAM")) |prog| {
if (std.mem.eql(u8, prog, "iTerm.app")) return true;
if (std.mem.eql(u8, prog, "WezTerm")) return true;
}
if (std.posix.getenv("TERM")) |term| {
if (std.mem.indexOf(u8, term, "kitty") != null) return true;
}
return false;
}
/// Returns the best protocol for the current terminal.
pub fn detectProtocol() Protocol {
if (std.posix.getenv("KITTY_WINDOW_ID") != null) return .kitty;
if (std.posix.getenv("WEZTERM_PANE") != null) return .kitty;
if (std.posix.getenv("TERM_PROGRAM")) |prog| {
if (std.mem.eql(u8, prog, "iTerm.app")) return .iterm2;
if (std.mem.eql(u8, prog, "WezTerm")) return .kitty;
}
if (std.posix.getenv("TERM")) |term| {
if (std.mem.indexOf(u8, term, "kitty") != null) return .kitty;
}
return .kitty; // Default to kitty
}
// ============================================================================
// Tests
// ============================================================================
test "Kitty displayFile generates escape sequence" {
var buf: [4096]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
const writer = stream.writer();
try Kitty.displayFile(writer, "/test/image.png", .{});
const result = stream.getWritten();
try std.testing.expect(std.mem.indexOf(u8, result, "\x1b_G") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "a=T,t=f") != null);
}
test "Kitty clearAll" {
var buf: [256]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
const writer = stream.writer();
try Kitty.clearAll(writer);
const result = stream.getWritten();
try std.testing.expectEqualStrings("\x1b_Ga=d,d=A\x1b\\", result);
}
test "ImageFormat toKittyFormat" {
try std.testing.expectEqual(@as(u8, 100), ImageFormat.png.toKittyFormat());
try std.testing.expectEqual(@as(u8, 24), ImageFormat.rgb.toKittyFormat());
try std.testing.expectEqual(@as(u8, 32), ImageFormat.rgba.toKittyFormat());
}
test "Iterm2 display generates escape sequence" {
var buf: [4096]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
const writer = stream.writer();
try Iterm2.display(writer, "test data", .{});
const result = stream.getWritten();
try std.testing.expect(std.mem.indexOf(u8, result, "\x1b]1337;File=") != null);
}

217
src/notification.zig Normal file
View file

@ -0,0 +1,217 @@
//! Terminal notifications using OSC 9 and OSC 777.
//!
//! These escape sequences allow sending desktop notifications from the terminal.
//! Support varies by terminal:
//! - OSC 9: iTerm2, Kitty, foot
//! - OSC 777: urxvt, foot, contour, Windows Terminal, VTE-based terminals
//!
//! ## Example
//!
//! ```zig
//! const notification = @import("notification.zig");
//!
//! // Simple notification (OSC 9)
//! try notification.notify(writer, "Build completed!");
//!
//! // Notification with title (OSC 777)
//! try notification.notifyWithTitle(writer, "Build Status", "All tests passed!");
//! ```
//!
//! ## Reference
//!
//! - OSC 9: iTerm2 Growl-style notification
//! - OSC 777: urxvt notify extension (notify;title;body)
const std = @import("std");
/// Notification type/protocol to use.
pub const NotificationType = enum {
/// OSC 9 - Simple message (iTerm2 style)
osc9,
/// OSC 777 - Title and body (urxvt style)
osc777,
/// Try both for maximum compatibility
both,
};
/// A terminal notification.
pub const Notification = struct {
/// Notification title (only used for OSC 777).
title: ?[]const u8 = null,
/// Notification body/message.
body: []const u8,
/// Which protocol to use.
protocol: NotificationType = .both,
/// Creates a simple notification.
pub fn init(body: []const u8) Notification {
return .{
.body = body,
};
}
/// Creates a notification with title.
pub fn initWithTitle(title: []const u8, body: []const u8) Notification {
return .{
.title = title,
.body = body,
};
}
/// Sets the protocol to use.
pub fn setProtocol(self: Notification, protocol: NotificationType) Notification {
var n = self;
n.protocol = protocol;
return n;
}
/// Formats the notification as escape sequences.
pub fn format(self: Notification, buf: []u8) []const u8 {
var stream = std.io.fixedBufferStream(buf);
const writer = stream.writer();
self.write(writer) catch return "";
return stream.getWritten();
}
/// Writes the notification escape sequence(s) to a writer.
pub fn write(self: Notification, writer: anytype) !void {
switch (self.protocol) {
.osc9 => try self.writeOsc9(writer),
.osc777 => try self.writeOsc777(writer),
.both => {
try self.writeOsc9(writer);
try self.writeOsc777(writer);
},
}
}
/// Writes OSC 9 format: \033]9;message\007
fn writeOsc9(self: Notification, writer: anytype) !void {
try writer.writeAll("\x1b]9;");
try writer.writeAll(self.body);
try writer.writeByte(0x07); // BEL
}
/// Writes OSC 777 format: \033]777;notify;title;body\007
fn writeOsc777(self: Notification, writer: anytype) !void {
try writer.writeAll("\x1b]777;notify;");
if (self.title) |title| {
try writer.writeAll(title);
} else {
try writer.writeAll("Notification");
}
try writer.writeAll(";");
try writer.writeAll(self.body);
try writer.writeByte(0x07); // BEL
}
};
/// Sends a simple notification (message only).
pub fn notify(writer: anytype, message: []const u8) !void {
const n = Notification.init(message);
try n.write(writer);
}
/// Sends a notification with title and body.
pub fn notifyWithTitle(writer: anytype, title: []const u8, body: []const u8) !void {
const n = Notification.initWithTitle(title, body);
try n.write(writer);
}
/// Sends an OSC 9 notification (iTerm2 style).
pub fn notifyOsc9(writer: anytype, message: []const u8) !void {
const n = Notification.init(message).setProtocol(.osc9);
try n.write(writer);
}
/// Sends an OSC 777 notification (urxvt style).
pub fn notifyOsc777(writer: anytype, title: []const u8, body: []const u8) !void {
const n = Notification.initWithTitle(title, body).setProtocol(.osc777);
try n.write(writer);
}
/// Checks if the terminal likely supports notifications.
/// This is a heuristic based on environment variables.
pub fn isSupported() bool {
// Check for known supporting terminals
if (std.posix.getenv("TERM_PROGRAM")) |prog| {
if (std.mem.eql(u8, prog, "iTerm.app")) return true;
}
// Windows Terminal
if (std.posix.getenv("WT_SESSION") != null) return true;
// Kitty
if (std.posix.getenv("KITTY_WINDOW_ID") != null) return true;
// Check TERM for common supporting terminals
if (std.posix.getenv("TERM")) |term| {
const supported = [_][]const u8{
"xterm-kitty",
"foot",
"contour",
"wezterm",
};
for (supported) |s| {
if (std.mem.indexOf(u8, term, s) != null) {
return true;
}
}
}
return false;
}
// ============================================================================
// Tests
// ============================================================================
test "Notification OSC 9" {
var buf: [256]u8 = undefined;
const n = Notification.init("Test message").setProtocol(.osc9);
const result = n.format(&buf);
try std.testing.expectEqualStrings("\x1b]9;Test message\x07", result);
}
test "Notification OSC 777" {
var buf: [256]u8 = undefined;
const n = Notification.initWithTitle("Title", "Body").setProtocol(.osc777);
const result = n.format(&buf);
try std.testing.expectEqualStrings("\x1b]777;notify;Title;Body\x07", result);
}
test "Notification OSC 777 default title" {
var buf: [256]u8 = undefined;
const n = Notification.init("Body").setProtocol(.osc777);
const result = n.format(&buf);
try std.testing.expectEqualStrings("\x1b]777;notify;Notification;Body\x07", result);
}
test "Notification both protocols" {
var buf: [512]u8 = undefined;
const n = Notification.initWithTitle("Title", "Message").setProtocol(.both);
const result = n.format(&buf);
// Should contain both sequences
try std.testing.expect(std.mem.indexOf(u8, result, "\x1b]9;Message\x07") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "\x1b]777;notify;Title;Message\x07") != null);
}
test "notify helper" {
var buf: [512]u8 = undefined;
var stream = std.io.fixedBufferStream(&buf);
const writer = stream.writer();
try notify(writer, "Hello");
const result = stream.getWritten();
try std.testing.expect(result.len > 0);
}

View file

@ -154,6 +154,18 @@ pub const widgets = struct {
pub const Tooltip = tooltip_mod.Tooltip; pub const Tooltip = tooltip_mod.Tooltip;
pub const TooltipPosition = tooltip_mod.TooltipPosition; pub const TooltipPosition = tooltip_mod.TooltipPosition;
pub const TooltipManager = tooltip_mod.TooltipManager; pub const TooltipManager = tooltip_mod.TooltipManager;
pub const tree_mod = @import("widgets/tree.zig");
pub const Tree = tree_mod.Tree;
pub const TreeItem = tree_mod.TreeItem;
pub const TreeState = tree_mod.TreeState;
pub const TreeSymbols = tree_mod.TreeSymbols;
pub const filepicker_mod = @import("widgets/filepicker.zig");
pub const FilePicker = filepicker_mod.FilePicker;
pub const FileEntry = filepicker_mod.FileEntry;
pub const FileType = filepicker_mod.FileType;
pub const FileIcons = filepicker_mod.FileIcons;
}; };
// Backend // Backend
@ -193,6 +205,21 @@ pub const clipboard = @import("clipboard.zig");
pub const Clipboard = clipboard; pub const Clipboard = clipboard;
pub const ClipboardSelection = clipboard.Selection; pub const ClipboardSelection = clipboard.Selection;
// Hyperlinks (OSC 8)
pub const hyperlink = @import("hyperlink.zig");
pub const Hyperlink = hyperlink.Hyperlink;
// Notifications (OSC 9/777)
pub const notification = @import("notification.zig");
pub const Notification = notification.Notification;
// Image display (Kitty/iTerm2)
pub const image = @import("image.zig");
pub const Kitty = image.Kitty;
pub const Iterm2 = image.Iterm2;
pub const ImageOptions = image.ImageOptions;
pub const ImageFormat = image.ImageFormat;
// ============================================================================ // ============================================================================
// Tests // Tests
// ============================================================================ // ============================================================================

481
src/widgets/filepicker.zig Normal file
View file

@ -0,0 +1,481 @@
//! File picker widget for directory/file selection.
//!
//! Provides a file browser interface using the Tree widget.
//!
//! ## Example
//!
//! ```zig
//! var picker = try FilePicker.init(allocator, "/home/user");
//! defer picker.deinit();
//!
//! picker.render(area, buf);
//!
//! // Handle events
//! switch (event.key.code) {
//! .up => picker.selectPrev(),
//! .down => picker.selectNext(),
//! .enter => {
//! if (picker.getSelectedPath()) |path| {
//! // Use selected file/directory
//! }
//! },
//! }
//! ```
const std = @import("std");
const fs = std.fs;
const Allocator = std.mem.Allocator;
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
// ============================================================================
// FileEntry
// ============================================================================
/// Type of file entry.
pub const FileType = enum {
file,
directory,
symlink,
other,
};
/// A file or directory entry.
pub const FileEntry = struct {
/// Entry name (not full path).
name: []const u8,
/// Full path.
path: []const u8,
/// Entry type.
file_type: FileType,
/// File size (for files).
size: u64 = 0,
/// Whether expanded (for directories).
expanded: bool = false,
/// Child entries (loaded lazily for directories).
children: std.ArrayList(FileEntry),
/// Whether children have been loaded.
children_loaded: bool = false,
/// Allocator for this entry.
allocator: Allocator,
/// Creates a new file entry.
pub fn init(allocator: Allocator, name: []const u8, path: []const u8, file_type: FileType) !FileEntry {
return .{
.name = try allocator.dupe(u8, name),
.path = try allocator.dupe(u8, path),
.file_type = file_type,
.children = std.ArrayList(FileEntry).init(allocator),
.allocator = allocator,
};
}
/// Frees all resources.
pub fn deinit(self: *FileEntry) void {
for (self.children.items) |*child| {
child.deinit();
}
self.children.deinit();
self.allocator.free(self.name);
self.allocator.free(self.path);
}
/// Loads children from filesystem.
pub fn loadChildren(self: *FileEntry) !void {
if (self.children_loaded or self.file_type != .directory) return;
var dir = fs.openDirAbsolute(self.path, .{ .iterate = true }) catch return;
defer dir.close();
var iter = dir.iterate();
while (try iter.next()) |entry| {
const entry_type: FileType = switch (entry.kind) {
.file => .file,
.directory => .directory,
.sym_link => .symlink,
else => .other,
};
// Build full path
const full_path = try fs.path.join(self.allocator, &.{ self.path, entry.name });
defer self.allocator.free(full_path);
var child = try FileEntry.init(
self.allocator,
entry.name,
full_path,
entry_type,
);
// Get file size for files
if (entry_type == .file) {
if (dir.statFile(entry.name)) |stat| {
child.size = stat.size;
} else |_| {}
}
try self.children.append(child);
}
// Sort: directories first, then alphabetically
std.mem.sort(FileEntry, self.children.items, {}, struct {
fn lessThan(_: void, a: FileEntry, b: FileEntry) bool {
// Directories first
if (a.file_type == .directory and b.file_type != .directory) return true;
if (a.file_type != .directory and b.file_type == .directory) return false;
// Then alphabetically (case-insensitive)
return std.ascii.lessThanIgnoreCase(a.name, b.name);
}
}.lessThan);
self.children_loaded = true;
}
/// Toggles expanded state.
pub fn toggle(self: *FileEntry) !void {
if (self.file_type == .directory) {
self.expanded = !self.expanded;
if (self.expanded and !self.children_loaded) {
try self.loadChildren();
}
}
}
};
// ============================================================================
// FilePicker
// ============================================================================
/// File picker icons.
pub const FileIcons = struct {
directory: []const u8 = "📁 ",
directory_open: []const u8 = "📂 ",
file: []const u8 = "📄 ",
symlink: []const u8 = "🔗 ",
other: []const u8 = "",
/// ASCII icons.
pub const ascii = FileIcons{
.directory = "[D] ",
.directory_open = "[D] ",
.file = "[F] ",
.symlink = "[L] ",
.other = "[?] ",
};
};
/// File picker widget.
pub const FilePicker = struct {
/// Allocator.
allocator: Allocator,
/// Root entry.
root: FileEntry,
/// Current path.
current_path: []const u8,
/// Selected item index (flattened).
selected: usize = 0,
/// Scroll offset.
offset: usize = 0,
/// Block wrapper.
block: ?Block = null,
/// Base style.
style: Style = Style.default,
/// Highlight style.
highlight_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Directory style.
dir_style: Style = Style.default.fg(Color.cyan).bold(),
/// File icons.
icons: FileIcons = .{},
/// Show hidden files (starting with .).
show_hidden: bool = false,
/// Filter pattern (glob-like).
filter: ?[]const u8 = null,
/// Creates a file picker rooted at the given path.
pub fn init(allocator: Allocator, root_path: []const u8) !FilePicker {
// Normalize path
const abs_path = try fs.realpathAlloc(allocator, root_path);
var root = try FileEntry.init(allocator, fs.path.basename(abs_path), abs_path, .directory);
root.expanded = true;
try root.loadChildren();
return .{
.allocator = allocator,
.root = root,
.current_path = abs_path,
};
}
/// Frees all resources.
pub fn deinit(self: *FilePicker) void {
self.root.deinit();
self.allocator.free(self.current_path);
}
/// Sets the block wrapper.
pub fn setBlock(self: *FilePicker, blk: Block) void {
self.block = blk;
}
/// Sets whether to show hidden files.
pub fn setShowHidden(self: *FilePicker, show: bool) void {
self.show_hidden = show;
}
/// Sets file icons.
pub fn setIcons(self: *FilePicker, icn: FileIcons) void {
self.icons = icn;
}
/// Uses ASCII icons.
pub fn useAsciiIcons(self: *FilePicker) void {
self.icons = FileIcons.ascii;
}
/// Counts total visible entries.
pub fn countVisible(self: *FilePicker) usize {
return self.countVisibleEntry(&self.root);
}
fn countVisibleEntry(self: *FilePicker, entry: *FileEntry) usize {
var count: usize = 1;
if (entry.expanded) {
for (entry.children.items) |*child| {
if (!self.show_hidden and child.name.len > 0 and child.name[0] == '.') {
continue;
}
count += self.countVisibleEntry(child);
}
}
return count;
}
/// Selects the next entry.
pub fn selectNext(self: *FilePicker) void {
const total = self.countVisible();
if (self.selected < total - 1) {
self.selected += 1;
}
}
/// Selects the previous entry.
pub fn selectPrev(self: *FilePicker) void {
if (self.selected > 0) {
self.selected -= 1;
}
}
/// Toggles the selected entry (expand/collapse for directories).
pub fn toggleSelected(self: *FilePicker) !void {
var index: usize = 0;
_ = try self.toggleAtIndex(&self.root, &index);
}
fn toggleAtIndex(self: *FilePicker, entry: *FileEntry, index: *usize) !bool {
if (index.* == self.selected) {
try entry.toggle();
return true;
}
index.* += 1;
if (entry.expanded) {
for (entry.children.items) |*child| {
if (!self.show_hidden and child.name.len > 0 and child.name[0] == '.') {
continue;
}
if (try self.toggleAtIndex(child, index)) {
return true;
}
}
}
return false;
}
/// Gets the selected entry.
pub fn getSelectedEntry(self: *FilePicker) ?*FileEntry {
var index: usize = 0;
return self.getEntryAtIndex(&self.root, &index);
}
fn getEntryAtIndex(self: *FilePicker, entry: *FileEntry, index: *usize) ?*FileEntry {
if (index.* == self.selected) {
return entry;
}
index.* += 1;
if (entry.expanded) {
for (entry.children.items) |*child| {
if (!self.show_hidden and child.name.len > 0 and child.name[0] == '.') {
continue;
}
if (self.getEntryAtIndex(child, index)) |found| {
return found;
}
}
}
return null;
}
/// Gets the path of the selected entry.
pub fn getSelectedPath(self: *FilePicker) ?[]const u8 {
if (self.getSelectedEntry()) |entry| {
return entry.path;
}
return null;
}
/// Renders the file picker.
pub fn render(self: *FilePicker, area: Rect, buf: *Buffer) void {
// Clear area
buf.setStyle(area, self.style);
// Render block
var picker_area = area;
if (self.block) |blk| {
blk.render(area, buf);
picker_area = blk.inner(area);
}
if (picker_area.width < 4 or picker_area.height == 0) return;
// Adjust scroll
const visible = self.countVisible();
if (self.selected >= self.offset + picker_area.height) {
self.offset = self.selected - picker_area.height + 1;
}
if (self.selected < self.offset) {
self.offset = self.selected;
}
// Render entries
var y: u16 = picker_area.y;
var index: usize = 0;
self.renderEntry(picker_area, buf, &self.root, 0, &y, &index);
_ = visible;
}
fn renderEntry(
self: *FilePicker,
area: Rect,
buf: *Buffer,
entry: *FileEntry,
depth: u16,
y: *u16,
index: *usize,
) void {
// Skip hidden files
if (!self.show_hidden and depth > 0 and entry.name.len > 0 and entry.name[0] == '.') {
return;
}
// Check if within visible range
if (index.* >= self.offset and y.* < area.y + area.height) {
var x = area.x;
// Indentation
const indent = depth * 2;
x += indent;
// Icon
const icon = switch (entry.file_type) {
.directory => if (entry.expanded) self.icons.directory_open else self.icons.directory,
.file => self.icons.file,
.symlink => self.icons.symlink,
.other => self.icons.other,
};
_ = buf.setString(x, y.*, icon, self.style);
x += @intCast(@min(icon.len, 4));
// Style
var entry_style = switch (entry.file_type) {
.directory => self.dir_style,
else => self.style,
};
// Highlight selected
if (index.* == self.selected) {
entry_style = self.highlight_style;
// Fill line
var hx = area.x;
while (hx < area.x + area.width) : (hx += 1) {
if (buf.getCell(hx, y.*)) |cell| {
cell.setStyle(self.highlight_style);
}
}
}
// Name
const max_width = (area.x + area.width) -| x;
const name_len: u16 = @intCast(@min(entry.name.len, max_width));
if (name_len > 0) {
_ = buf.setString(x, y.*, entry.name[0..name_len], entry_style);
}
y.* += 1;
}
index.* += 1;
// Render children
if (entry.expanded) {
for (entry.children.items) |*child| {
self.renderEntry(area, buf, child, depth + 1, y, index);
}
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "FileEntry basic" {
const allocator = std.testing.allocator;
var entry = try FileEntry.init(allocator, "test.txt", "/tmp/test.txt", .file);
defer entry.deinit();
try std.testing.expectEqualStrings("test.txt", entry.name);
try std.testing.expectEqualStrings("/tmp/test.txt", entry.path);
try std.testing.expectEqual(FileType.file, entry.file_type);
}
test "FilePicker init current dir" {
const allocator = std.testing.allocator;
// Try to init with current directory
if (FilePicker.init(allocator, ".")) |*picker| {
defer picker.deinit();
try std.testing.expect(picker.countVisible() >= 1);
try std.testing.expect(picker.getSelectedPath() != null);
} else |_| {
// Skip if directory cannot be opened
}
}

493
src/widgets/tree.zig Normal file
View file

@ -0,0 +1,493 @@
//! Tree widget for hierarchical data display.
//!
//! Provides an expandable/collapsible tree view similar to file explorers.
//!
//! ## Example
//!
//! ```zig
//! const tree = Tree.init()
//! .items(&.{
//! TreeItem.init("Root")
//! .children(&.{
//! TreeItem.init("Child 1"),
//! TreeItem.init("Child 2")
//! .children(&.{
//! TreeItem.init("Grandchild"),
//! }),
//! }),
//! })
//! .block(Block.init().title("File Browser").setBorders(Borders.all));
//!
//! tree.render(area, buf);
//! ```
const std = @import("std");
const buffer_mod = @import("../buffer.zig");
const Buffer = buffer_mod.Buffer;
const Rect = buffer_mod.Rect;
const style_mod = @import("../style.zig");
const Style = style_mod.Style;
const Color = style_mod.Color;
const block_mod = @import("block.zig");
const Block = block_mod.Block;
const Borders = block_mod.Borders;
const symbols = @import("../symbols/symbols.zig");
// ============================================================================
// TreeItem
// ============================================================================
/// A single item in the tree.
pub const TreeItem = struct {
/// Display text for this item.
label: []const u8,
/// Child items.
children: []const TreeItem = &.{},
/// Whether this item is expanded (shows children).
expanded: bool = true,
/// Custom style for this item.
style: ?Style = null,
/// Icon/prefix character.
icon: ?[]const u8 = null,
/// User data (opaque pointer for application use).
data: ?*anyopaque = null,
/// Creates a new tree item.
pub fn init(label: []const u8) TreeItem {
return .{
.label = label,
};
}
/// Sets child items.
pub fn setChildren(self: TreeItem, children: []const TreeItem) TreeItem {
var item = self;
item.children = children;
return item;
}
/// Sets expanded state.
pub fn setExpanded(self: TreeItem, expanded: bool) TreeItem {
var item = self;
item.expanded = expanded;
return item;
}
/// Sets custom style.
pub fn setStyle(self: TreeItem, style: Style) TreeItem {
var item = self;
item.style = style;
return item;
}
/// Sets icon/prefix.
pub fn setIcon(self: TreeItem, icon: []const u8) TreeItem {
var item = self;
item.icon = icon;
return item;
}
/// Returns whether this item has children.
pub fn hasChildren(self: TreeItem) bool {
return self.children.len > 0;
}
/// Counts total visible items (including expanded children).
pub fn countVisible(self: TreeItem) usize {
var count: usize = 1;
if (self.expanded) {
for (self.children) |child| {
count += child.countVisible();
}
}
return count;
}
};
// ============================================================================
// TreeState
// ============================================================================
/// State for tree selection and scrolling.
pub const TreeState = struct {
/// Currently selected item index (flattened).
selected: usize = 0,
/// Scroll offset.
offset: usize = 0,
/// Creates a new tree state.
pub fn init() TreeState {
return .{};
}
/// Selects the next item.
pub fn selectNext(self: *TreeState, total: usize) void {
if (total > 0 and self.selected < total - 1) {
self.selected += 1;
}
}
/// Selects the previous item.
pub fn selectPrev(self: *TreeState) void {
if (self.selected > 0) {
self.selected -= 1;
}
}
/// Selects the first item.
pub fn selectFirst(self: *TreeState) void {
self.selected = 0;
}
/// Selects the last item.
pub fn selectLast(self: *TreeState, total: usize) void {
if (total > 0) {
self.selected = total - 1;
}
}
};
// ============================================================================
// Tree
// ============================================================================
/// Tree symbols for rendering.
pub const TreeSymbols = struct {
/// Branch for non-last items.
branch: []const u8 = "├── ",
/// Branch for last item.
branch_last: []const u8 = "└── ",
/// Vertical line for continuation.
vertical: []const u8 = "",
/// Empty space (for alignment).
space: []const u8 = " ",
/// Expanded indicator.
expanded: []const u8 = "",
/// Collapsed indicator.
collapsed: []const u8 = "",
/// Default ASCII symbols.
pub const ascii = TreeSymbols{
.branch = "+-- ",
.branch_last = "`-- ",
.vertical = "| ",
.space = " ",
.expanded = "v ",
.collapsed = "> ",
};
};
/// A tree widget for hierarchical data.
pub const Tree = struct {
/// Root items.
items: []const TreeItem = &.{},
/// Optional block wrapper.
block: ?Block = null,
/// Base style.
style: Style = Style.default,
/// Style for highlighted/selected item.
highlight_style: Style = Style.default.bg(Color.blue).fg(Color.white),
/// Tree symbols.
tree_symbols: TreeSymbols = .{},
/// Whether to show root items.
show_root: bool = true,
/// Indentation per level.
indent: u16 = 4,
/// Creates a new tree widget.
pub fn init() Tree {
return .{};
}
/// Sets the items.
pub fn setItems(self: Tree, items_slice: []const TreeItem) Tree {
var t = self;
t.items = items_slice;
return t;
}
/// Sets the block wrapper.
pub fn setBlock(self: Tree, blk: Block) Tree {
var t = self;
t.block = blk;
return t;
}
/// Sets the base style.
pub fn setStyle(self: Tree, style: Style) Tree {
var t = self;
t.style = style;
return t;
}
/// Sets the highlight style.
pub fn setHighlightStyle(self: Tree, style: Style) Tree {
var t = self;
t.highlight_style = style;
return t;
}
/// Sets tree symbols.
pub fn setSymbols(self: Tree, syms: TreeSymbols) Tree {
var t = self;
t.tree_symbols = syms;
return t;
}
/// Uses ASCII symbols.
pub fn asciiSymbols(self: Tree) Tree {
var t = self;
t.tree_symbols = TreeSymbols.ascii;
return t;
}
/// Counts total visible items.
pub fn countVisible(self: Tree) usize {
var count: usize = 0;
for (self.items) |item| {
count += item.countVisible();
}
return count;
}
/// Renders the tree widget.
pub fn render(self: Tree, area: Rect, buf: *Buffer) void {
self.renderStateful(area, buf, null);
}
/// Renders the tree widget with state.
pub fn renderStateful(self: Tree, area: Rect, buf: *Buffer, state: ?*TreeState) void {
// Clear area with base style
buf.setStyle(area, self.style);
// Render block if present
var tree_area = area;
if (self.block) |blk| {
blk.render(area, buf);
tree_area = blk.inner(area);
}
if (tree_area.width < 4 or tree_area.height == 0) return;
// Get state values
const selected = if (state) |s| s.selected else 0;
const offset = if (state) |s| s.offset else 0;
// Render items
var y: u16 = tree_area.y;
var index: usize = 0;
for (self.items, 0..) |item, i| {
const is_last = i == self.items.len - 1;
self.renderItem(
tree_area,
buf,
item,
0,
&.{},
is_last,
&y,
&index,
selected,
offset,
);
}
}
fn renderItem(
self: Tree,
area: Rect,
buf: *Buffer,
item: TreeItem,
depth: u16,
ancestors_last: []const bool,
is_last: bool,
y: *u16,
index: *usize,
selected: usize,
offset: usize,
) void {
// Check if within visible range
if (index.* >= offset and y.* < area.y + area.height) {
var x = area.x;
// Draw tree lines
for (ancestors_last) |ancestor_is_last| {
if (x + 4 > area.x + area.width) break;
const sym = if (ancestor_is_last) self.tree_symbols.space else self.tree_symbols.vertical;
_ = buf.setString(x, y.*, sym, self.style);
x += @intCast(@min(sym.len, 4));
}
// Draw branch
if (depth > 0) {
const branch_sym = if (is_last) self.tree_symbols.branch_last else self.tree_symbols.branch;
_ = buf.setString(x, y.*, branch_sym, self.style);
x += @intCast(@min(branch_sym.len, 4));
}
// Draw expand/collapse indicator
if (item.hasChildren()) {
const indicator = if (item.expanded) self.tree_symbols.expanded else self.tree_symbols.collapsed;
_ = buf.setString(x, y.*, indicator, self.style);
x += @intCast(@min(indicator.len, 2));
}
// Draw icon if present
if (item.icon) |icon| {
_ = buf.setString(x, y.*, icon, self.style);
x += @intCast(@min(icon.len, 4));
x += 1; // space after icon
}
// Determine style
var item_style = item.style orelse self.style;
if (index.* == selected) {
item_style = self.highlight_style;
}
// Draw label
const max_label_width = (area.x + area.width) -| x;
if (max_label_width > 0) {
const label_len: u16 = @intCast(@min(item.label.len, max_label_width));
// Highlight entire line if selected
if (index.* == selected) {
var hx = x;
while (hx < area.x + area.width) : (hx += 1) {
if (buf.getCell(hx, y.*)) |cell| {
cell.setStyle(self.highlight_style);
}
}
}
_ = buf.setString(x, y.*, item.label[0..label_len], item_style);
}
y.* += 1;
}
index.* += 1;
// Render children if expanded
if (item.expanded) {
// Build new ancestors array (stack allocated for reasonable depth)
var new_ancestors: [32]bool = undefined;
const ancestors_len = @min(ancestors_last.len, 31);
for (ancestors_last, 0..) |v, i| {
if (i < ancestors_len) new_ancestors[i] = v;
}
new_ancestors[ancestors_len] = is_last;
for (item.children, 0..) |child, ci| {
const child_is_last = ci == item.children.len - 1;
self.renderItem(
area,
buf,
child,
depth + 1,
new_ancestors[0 .. ancestors_len + 1],
child_is_last,
y,
index,
selected,
offset,
);
}
}
}
};
// ============================================================================
// Tests
// ============================================================================
test "TreeItem basic" {
const item = TreeItem.init("Root");
try std.testing.expectEqualStrings("Root", item.label);
try std.testing.expect(!item.hasChildren());
try std.testing.expectEqual(@as(usize, 1), item.countVisible());
}
test "TreeItem with children" {
const item = TreeItem.init("Root").setChildren(&.{
TreeItem.init("Child 1"),
TreeItem.init("Child 2"),
});
try std.testing.expect(item.hasChildren());
try std.testing.expectEqual(@as(usize, 3), item.countVisible());
}
test "TreeItem collapsed" {
const item = TreeItem.init("Root")
.setChildren(&.{
TreeItem.init("Child 1"),
TreeItem.init("Child 2"),
})
.setExpanded(false);
try std.testing.expectEqual(@as(usize, 1), item.countVisible());
}
test "Tree countVisible" {
const tree = Tree.init().setItems(&.{
TreeItem.init("Root 1").setChildren(&.{
TreeItem.init("Child 1"),
}),
TreeItem.init("Root 2"),
});
try std.testing.expectEqual(@as(usize, 4), tree.countVisible());
}
test "TreeState navigation" {
var state = TreeState.init();
const total: usize = 5;
try std.testing.expectEqual(@as(usize, 0), state.selected);
state.selectNext(total);
try std.testing.expectEqual(@as(usize, 1), state.selected);
state.selectLast(total);
try std.testing.expectEqual(@as(usize, 4), state.selected);
state.selectNext(total); // Should not go past last
try std.testing.expectEqual(@as(usize, 4), state.selected);
state.selectFirst();
try std.testing.expectEqual(@as(usize, 0), state.selected);
}
test "Tree render basic" {
const tree = Tree.init().setItems(&.{
TreeItem.init("Root"),
});
var cells: [80 * 24]buffer_mod.Cell = undefined;
for (&cells) |*cell| {
cell.* = buffer_mod.Cell.init();
}
var buf = Buffer.initWithCells(Rect.init(0, 0, 80, 24), &cells);
tree.render(Rect.init(0, 0, 40, 10), &buf);
// Check that something was rendered
if (buf.getCell(0, 0)) |cell| {
// Either tree symbol or text should be there
try std.testing.expect(cell.symbol.codepoint != ' ' or cell.symbol.codepoint == 'R');
}
}