From 5ac74ebff5a8d49ec8f146afcce88704aeea1593 Mon Sep 17 00:00:00 2001 From: reugenio Date: Mon, 8 Dec 2025 17:34:33 +0100 Subject: [PATCH] feat: Add terminal extensions and new widgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/hyperlink.zig | 269 ++++++++++++++++++++ src/image.zig | 394 +++++++++++++++++++++++++++++ src/notification.zig | 217 ++++++++++++++++ src/root.zig | 27 ++ src/widgets/filepicker.zig | 481 ++++++++++++++++++++++++++++++++++++ src/widgets/tree.zig | 493 +++++++++++++++++++++++++++++++++++++ 6 files changed, 1881 insertions(+) create mode 100644 src/hyperlink.zig create mode 100644 src/image.zig create mode 100644 src/notification.zig create mode 100644 src/widgets/filepicker.zig create mode 100644 src/widgets/tree.zig diff --git a/src/hyperlink.zig b/src/hyperlink.zig new file mode 100644 index 0000000..796f252 --- /dev/null +++ b/src/hyperlink.zig @@ -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); +} diff --git a/src/image.zig b/src/image.zig new file mode 100644 index 0000000..c3f8057 --- /dev/null +++ b/src/image.zig @@ -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); +} diff --git a/src/notification.zig b/src/notification.zig new file mode 100644 index 0000000..418864f --- /dev/null +++ b/src/notification.zig @@ -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); +} diff --git a/src/root.zig b/src/root.zig index cd88a54..5dc32f5 100644 --- a/src/root.zig +++ b/src/root.zig @@ -154,6 +154,18 @@ pub const widgets = struct { pub const Tooltip = tooltip_mod.Tooltip; pub const TooltipPosition = tooltip_mod.TooltipPosition; 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 @@ -193,6 +205,21 @@ pub const clipboard = @import("clipboard.zig"); pub const Clipboard = clipboard; 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 // ============================================================================ diff --git a/src/widgets/filepicker.zig b/src/widgets/filepicker.zig new file mode 100644 index 0000000..0d507fb --- /dev/null +++ b/src/widgets/filepicker.zig @@ -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 + } +} diff --git a/src/widgets/tree.zig b/src/widgets/tree.zig new file mode 100644 index 0000000..ecb539b --- /dev/null +++ b/src/widgets/tree.zig @@ -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'); + } +}