Something went wrong. Try again.
A charm-like tui library
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475//! Browse the local filesystem. Renders one entry per line with//! permissions, size, and name; cursor selects an entry; Open enters//! a directory or marks a file as selected; Back pops up a level.//!//! Directory reads are synchronous via `setCurrentDirectory` — caller//! supplies `Allocator` + `std.Io`. Entries are owned by the picker;//! `deinit` frees them.//!//! Skipped from bubbles for v1: symlink target resolution (kind shown//! but not the `→ /target` suffix), AutoHeight wiring (call `setHeight`//! from your WindowSize handler), hidden-detection by platform attribute//! (only `.`-prefix is treated as hidden), humanize unit prefix beyond//! KiB/MiB/GiB/TiB.const std = @import("std");const matcha = @import("matcha");const blush = @import("blush");const key = @import("key.zig");const util = @import("util.zig");
const Allocator = std.mem.Allocator;const Writer = std.Io.Writer;const Cmd = matcha.Cmd;const Msg = matcha.Msg;const Io = std.Io;const ArrayList = std.ArrayList;
const FilePicker = @This();
pub const KeyMap = struct { goto_top: key.Binding = .{ .keys = &.{key.rune('g')}, .help = .{ .key = "g", .desc = "first" } }, goto_last: key.Binding = .{ .keys = &.{key.shift('G')}, .help = .{ .key = "G", .desc = "last" } }, down: key.Binding = .{ .keys = &.{ key.rune('j'), key.special(.down), key.ctrl('n') }, .help = .{ .key = "j/↓", .desc = "down" }, }, up: key.Binding = .{ .keys = &.{ key.rune('k'), key.special(.up), key.ctrl('p') }, .help = .{ .key = "k/↑", .desc = "up" }, }, page_up: key.Binding = .{ .keys = &.{ key.shift('K'), key.special(.page_up) }, .help = .{ .key = "pgup", .desc = "page up" }, }, page_down: key.Binding = .{ .keys = &.{ key.shift('J'), key.special(.page_down) }, .help = .{ .key = "pgdn", .desc = "page down" }, }, back: key.Binding = .{ .keys = &.{ key.rune('h'), key.special(.backspace), key.special(.left), key.special(.escape) }, .help = .{ .key = "h/←", .desc = "back" }, }, open: key.Binding = .{ .keys = &.{ key.rune('l'), key.special(.right), key.special(.enter) }, .help = .{ .key = "l/→", .desc = "open" }, }, select: key.Binding = .{ .keys = &.{key.special(.enter)}, .help = .{ .key = "enter", .desc = "select" }, },};
pub const Styles = struct { cursor: ?blush.Style = null, disabled_cursor: ?blush.Style = null, symlink: ?blush.Style = null, directory: ?blush.Style = null, file: ?blush.Style = null, disabled_file: ?blush.Style = null, permission: ?blush.Style = null, selected: ?blush.Style = null, disabled_selected: ?blush.Style = null, file_size: ?blush.Style = null, empty_directory: ?blush.Style = null,};
pub const default_styles: Styles = .{ .cursor = .{ .foreground = .{ .ansi256 = 212 } }, .disabled_cursor = .{ .foreground = .{ .ansi256 = 247 } }, .symlink = .{ .foreground = .{ .ansi256 = 36 } }, .directory = .{ .foreground = .{ .ansi256 = 99 } }, .file = null, .disabled_file = .{ .foreground = .{ .ansi256 = 243 } }, .disabled_selected = .{ .foreground = .{ .ansi256 = 247 } }, .permission = .{ .foreground = .{ .ansi256 = 244 } }, .selected = .{ .foreground = .{ .ansi256 = 212 }, .bold = true }, .file_size = .{ .foreground = .{ .ansi256 = 240 } }, .empty_directory = .{ .foreground = .{ .ansi256 = 240 } },};
pub const Kind = enum { file, directory, sym_link, other };
pub const Entry = struct { name: []u8, kind: Kind, size: u64, mode: u32,};
const StackFrame = struct { selected: u16, min: u16, max: u16 };
const file_size_width: u16 = 7;
model: matcha.Model = .{ .vtable = &vtable },
/// Path the user has selected via `Select`. Empty until they pick a file.path: []u8 = "",/// Directory currently being browsed.current_directory: []u8 = "",
key_map: KeyMap = .{},styles: Styles = default_styles,cursor_text: []const u8 = ">",allowed_types: []const []const u8 = &.{},
show_permissions: bool = true,show_size: bool = true,show_hidden: bool = false,dir_allowed: bool = false,file_allowed: bool = true,height: u16 = 10,
selected_index: u16 = 0,min_idx: u16 = 0,max_idx: u16 = 0,
entries: ArrayList(Entry) = .empty,stack: ArrayList(StackFrame) = .empty,
const vtable: matcha.Model.VTable = .{ .init = vtableInit, .update = vtableUpdate, .view = vtableView,};
fn vtableInit(_: *matcha.Model) ?Cmd { return null;}
fn vtableUpdate(m: *matcha.Model, msg: Msg) ?Cmd { const self: *FilePicker = @fieldParentPtr("model", m); return self.update(m.gpa, m.io, msg);}
fn vtableView(m: *matcha.Model, w: *Writer) Writer.Error!matcha.View { const self: *FilePicker = @fieldParentPtr("model", m); try self.view(w); return .{};}
pub fn deinit(self: *FilePicker, allocator: Allocator) void { self.freeEntries(allocator); self.entries.deinit(allocator); self.stack.deinit(allocator); if (self.current_directory.len > 0) allocator.free(self.current_directory); if (self.path.len > 0) allocator.free(self.path);}
fn freeEntries(self: *FilePicker, allocator: Allocator) void { for (self.entries.items) |e| allocator.free(e.name); self.entries.clearRetainingCapacity();}
// ---- public API ----
pub fn setCurrentDirectory( self: *FilePicker, allocator: Allocator, io: Io, new_path: []const u8,) !void { const dup = try allocator.dupe(u8, new_path); if (self.current_directory.len > 0) allocator.free(self.current_directory); self.current_directory = dup; try self.readDir(allocator, io); self.selected_index = 0; self.min_idx = 0; self.max_idx = if (self.height > 0) self.height - 1 else 0;}
pub fn setHeight(self: *FilePicker, h: u16) void { self.height = h; if (self.max_idx > h -| 1) self.max_idx = self.min_idx + (h -| 1);}
/// Current cursor's path joined onto `current_directory`. Caller owns/// the returned buffer; free with `allocator.free`.pub fn highlightedPathAlloc(self: *const FilePicker, allocator: Allocator) Allocator.Error!?[]u8 { if (self.entries.items.len == 0) return null; const entry = self.entries.items[self.selected_index]; return try std.fs.path.join(allocator, &.{ self.current_directory, entry.name });}
// ---- update ----
pub fn update(self: *FilePicker, allocator: Allocator, io: Io, msg: Msg) ?Cmd { switch (msg) { .key_press => |k| { const total: u16 = @intCast(@min(self.entries.items.len, std.math.maxInt(u16))); if (self.key_map.goto_top.matches(k)) { self.selected_index = 0; self.min_idx = 0; self.max_idx = self.height -| 1; } else if (self.key_map.goto_last.matches(k)) { if (total == 0) return null; self.selected_index = total - 1; self.max_idx = total - 1; self.min_idx = if (total > self.height) total - self.height else 0; } else if (self.key_map.down.matches(k)) { if (total == 0) return null; if (self.selected_index + 1 >= total) return null; self.selected_index += 1; if (self.selected_index > self.max_idx) { self.min_idx += 1; self.max_idx += 1; } } else if (self.key_map.up.matches(k)) { if (self.selected_index == 0) return null; self.selected_index -= 1; if (self.selected_index < self.min_idx) { self.min_idx -= 1; self.max_idx -= 1; } } else if (self.key_map.page_down.matches(k)) { if (total == 0) return null; self.selected_index +|= self.height; if (self.selected_index >= total) self.selected_index = total - 1; self.min_idx +|= self.height; self.max_idx +|= self.height; if (self.max_idx >= total) { self.max_idx = total - 1; self.min_idx = if (total > self.height) total - self.height else 0; } } else if (self.key_map.page_up.matches(k)) { self.selected_index = if (self.selected_index > self.height) self.selected_index - self.height else 0; self.min_idx = if (self.min_idx > self.height) self.min_idx - self.height else 0; self.max_idx = self.min_idx + self.height -| 1; } else if (self.key_map.back.matches(k)) { self.goBack(allocator, io) catch {}; return null; } else if (self.key_map.open.matches(k)) { self.openSelected(allocator, io) catch {}; return null; } }, else => {}, } return null;}
fn goBack(self: *FilePicker, allocator: Allocator, io: Io) !void { const parent = std.fs.path.dirname(self.current_directory) orelse "/"; const dup = try allocator.dupe(u8, parent); allocator.free(self.current_directory); self.current_directory = dup; try self.readDir(allocator, io); if (self.stack.items.len > 0) { const frame = self.stack.pop().?; self.selected_index = frame.selected; self.min_idx = frame.min; self.max_idx = frame.max; } else { self.selected_index = 0; self.min_idx = 0; self.max_idx = self.height -| 1; }}
fn openSelected(self: *FilePicker, allocator: Allocator, io: Io) !void { if (self.entries.items.len == 0) return; const entry = self.entries.items[self.selected_index]; const is_dir = entry.kind == .directory; const can_select = (!is_dir and self.file_allowed and self.canSelect(entry.name)) or (is_dir and self.dir_allowed); if (can_select) { if (self.path.len > 0) allocator.free(self.path); self.path = try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); } if (!is_dir) return; try self.stack.append(allocator, .{ .selected = self.selected_index, .min = self.min_idx, .max = self.max_idx, }); const new_dir = try std.fs.path.join(allocator, &.{ self.current_directory, entry.name }); defer allocator.free(new_dir); try self.setCurrentDirectory(allocator, io, new_dir);}
// ---- directory read ----
fn readDir(self: *FilePicker, allocator: Allocator, io: Io) !void { self.freeEntries(allocator);
var dir = std.Io.Dir.cwd().openDir(io, self.current_directory, .{ .iterate = true }) catch return; defer dir.close(io);
var iter = dir.iterate(); while (true) { const maybe = iter.next(io) catch break; const e = maybe orelse break; if (!self.show_hidden and e.name.len > 0 and e.name[0] == '.') continue; const name_owned = try allocator.dupe(u8, e.name); const kind = switch (e.kind) { .file => Kind.file, .directory => Kind.directory, .sym_link => Kind.sym_link, else => Kind.other, }; var size: u64 = 0; var mode: u32 = 0; if (dir.statFile(io, e.name, .{ .follow_symlinks = false })) |s| { size = s.size; mode = @intCast(@intFromEnum(s.permissions)); } else |_| {} try self.entries.append(allocator, .{ .name = name_owned, .kind = kind, .size = size, .mode = mode, }); } std.mem.sort(Entry, self.entries.items, {}, entryLessThan);}
fn entryLessThan(_: void, a: Entry, b: Entry) bool { const ad = a.kind == .directory; const bd = b.kind == .directory; if (ad != bd) return ad; return std.mem.lessThan(u8, a.name, b.name);}
pub fn canSelect(self: *const FilePicker, name: []const u8) bool { if (self.allowed_types.len == 0) return true; for (self.allowed_types) |ext| { if (std.mem.endsWith(u8, name, ext)) return true; } return false;}
// ---- view ----
pub fn view(self: *FilePicker, w: *Writer) Writer.Error!void { if (self.entries.items.len == 0) { try util.writeInline(w, self.styles.empty_directory, " Bummer. No Files Found."); return; } var rendered: u16 = 0; var i: u16 = 0; while (i < self.entries.items.len) : (i += 1) { if (i < self.min_idx or i > self.max_idx) continue; if (rendered > 0) try w.writeByte('\n'); try self.renderEntry(w, i); rendered += 1; } while (rendered <= self.height) : (rendered += 1) { try w.writeByte('\n'); }}
fn renderEntry(self: *FilePicker, w: *Writer, idx: u16) Writer.Error!void { const e = self.entries.items[idx]; const is_selected = idx == self.selected_index; const disabled = !self.canSelect(e.name) and e.kind != .directory;
var size_buf: [16]u8 = undefined; const size_text = formatSize(e.size, &size_buf); var perm_buf: [16]u8 = undefined; const perm_text = formatMode(e.mode, e.kind, &perm_buf);
if (is_selected) { // Cursor + selected row in one styled blob. try util.writeInline(w, if (disabled) self.styles.disabled_cursor else self.styles.cursor, self.cursor_text); const sel_style = if (disabled) self.styles.disabled_selected else self.styles.selected; if (self.show_permissions) { try util.writeInline(w, sel_style, " "); try util.writeInline(w, sel_style, perm_text); } if (self.show_size) { try util.writeInline(w, sel_style, " "); try writePaddedSize(w, sel_style, size_text); } try util.writeInline(w, sel_style, " "); try util.writeInline(w, sel_style, e.name); return; }
// Unselected: leading space for the cursor column, then perms/size/name. try w.writeByte(' '); if (self.show_permissions) { try w.writeByte(' '); try util.writeInline(w, self.styles.permission, perm_text); } if (self.show_size) { try w.writeByte(' '); try writePaddedSize(w, self.styles.file_size, size_text); } try w.writeByte(' '); const name_style: ?blush.Style = switch (e.kind) { .directory => self.styles.directory, .sym_link => self.styles.symlink, else => if (disabled) self.styles.disabled_file else self.styles.file, }; try util.writeInline(w, name_style, e.name);}
fn writePaddedSize(w: *Writer, style: ?blush.Style, size_text: []const u8) Writer.Error!void { const w_cells: u16 = file_size_width; const cells = blush.measure.cellWidth(size_text); var pad: u16 = if (w_cells > cells) w_cells - cells else 0; while (pad > 0) : (pad -= 1) try w.writeByte(' '); try util.writeInline(w, style, size_text);}
fn formatSize(size: u64, buf: []u8) []const u8 { const units = [_][]const u8{ "B", "K", "M", "G", "T" }; var value: f64 = @floatFromInt(size); var unit: usize = 0; while (value >= 1024.0 and unit + 1 < units.len) { value /= 1024.0; unit += 1; } if (unit == 0) return std.fmt.bufPrint(buf, "{d}B", .{size}) catch buf[0..0]; return std.fmt.bufPrint(buf, "{d:.1}{s}", .{ value, units[unit] }) catch buf[0..0];}
fn formatMode(mode: u32, kind: Kind, buf: []u8) []const u8 { if (buf.len < 10) return buf[0..0]; buf[0] = switch (kind) { .directory => 'd', .sym_link => 'l', else => '-', }; const bits = [_]struct { mask: u32, ch: u8 }{ .{ .mask = 0o400, .ch = 'r' }, .{ .mask = 0o200, .ch = 'w' }, .{ .mask = 0o100, .ch = 'x' }, .{ .mask = 0o040, .ch = 'r' }, .{ .mask = 0o020, .ch = 'w' }, .{ .mask = 0o010, .ch = 'x' }, .{ .mask = 0o004, .ch = 'r' }, .{ .mask = 0o002, .ch = 'w' }, .{ .mask = 0o001, .ch = 'x' }, }; for (bits, 0..) |b, i| { buf[i + 1] = if (mode & b.mask != 0) b.ch else '-'; } return buf[0..10];}
// ---- tests ----
const testing = std.testing;
test "formatSize humanizes bytes" { var buf: [16]u8 = undefined; try testing.expectEqualStrings("512B", formatSize(512, &buf)); try testing.expectEqualStrings("1.0K", formatSize(1024, &buf)); try testing.expectEqualStrings("1.5K", formatSize(1536, &buf)); try testing.expectEqualStrings("2.0M", formatSize(2 * 1024 * 1024, &buf));}
test "formatMode renders rwx" { var buf: [16]u8 = undefined; try testing.expectEqualStrings("drwxr-xr-x", formatMode(0o755, .directory, &buf)); try testing.expectEqualStrings("-rw-r--r--", formatMode(0o644, .file, &buf)); try testing.expectEqualStrings("lrwxrwxrwx", formatMode(0o777, .sym_link, &buf));}
test "entryLessThan: dirs first then name" { const a: Entry = .{ .name = @constCast("zeta"), .kind = .directory, .size = 0, .mode = 0 }; const b: Entry = .{ .name = @constCast("alpha"), .kind = .file, .size = 0, .mode = 0 }; try testing.expect(entryLessThan({}, a, b)); try testing.expect(!entryLessThan({}, b, a));}