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 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
|
||||
// ============================================================================
|
||||
|
|
|
|||
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