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:
parent
8c218a3f0d
commit
5ac74ebff5
6 changed files with 1881 additions and 0 deletions
269
src/hyperlink.zig
Normal file
269
src/hyperlink.zig
Normal 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
394
src/image.zig
Normal 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
217
src/notification.zig
Normal 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);
|
||||||
|
}
|
||||||
27
src/root.zig
27
src/root.zig
|
|
@ -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
481
src/widgets/filepicker.zig
Normal 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
493
src/widgets/tree.zig
Normal 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');
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue