地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
at v0.2.0 473 lines 19 kB view raw
1const std = @import("std"); 2const builtin = @import("builtin"); 3 4const log = &@import("./log.zig").log; 5const environment = @import("./environment.zig"); 6const config = &@import("./config.zig").config; 7const List = @import("./list.zig").List; 8const View = @import("./view.zig"); 9 10const vaxis = @import("vaxis"); 11const TextInput = @import("vaxis").widgets.TextInput; 12const Cell = vaxis.Cell; 13const Key = vaxis.Key; 14 15const State = enum { 16 normal, 17 input, 18}; 19 20const Event = union(enum) { 21 key_press: vaxis.Key, 22 winsize: vaxis.Winsize, 23}; 24 25var vx: vaxis.Vaxis = undefined; 26 27pub fn main() !void { 28 var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 29 defer _ = gpa.deinit(); 30 const alloc = gpa.allocator(); 31 32 var view = try View.init(alloc); 33 defer view.deinit(); 34 35 var file_metadata = try view.dir.metadata(); 36 37 log.init(); 38 39 config.parse(alloc) catch |err| switch (err) { 40 error.ConfigNotFound => {}, 41 error.MissingConfigHomeEnvironmentVariable => { 42 log.err("Could not read config due to $HOME or $XDG_CONFIG_HOME not being set.", .{}); 43 return; 44 }, 45 error.SyntaxError => { 46 log.err("Could not read config due to a syntax error.", .{}); 47 return; 48 }, 49 else => { 50 log.err("Could not read config due to an unknown error.", .{}); 51 return; 52 }, 53 }; 54 55 // TODO: Figure out size. 56 var file_buf: [4096]u8 = undefined; 57 58 var current_item_path: []u8 = ""; 59 var last_item_path: []u8 = ""; 60 var image: ?vaxis.Image = null; 61 var path: [std.fs.max_path_bytes]u8 = undefined; 62 var last_path: [std.fs.max_path_bytes]u8 = undefined; 63 64 try view.populate_entries(""); 65 66 vx = try vaxis.init(alloc, .{ .kitty_keyboard_flags = .{ 67 .report_text = false, 68 .disambiguate = false, 69 .report_events = false, 70 .report_alternate_keys = false, 71 .report_all_as_ctl_seqs = false, 72 } }); 73 defer vx.deinit(alloc); 74 75 var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; 76 try loop.run(); 77 defer loop.stop(); 78 79 try vx.enterAltScreen(); 80 try vx.queryTerminal(); 81 vx.caps.kitty_keyboard = false; 82 83 var text_input = TextInput.init(alloc, &vx.unicode); 84 defer text_input.deinit(); 85 86 var err_len: usize = 0; 87 var err_buf: [1024]u8 = undefined; 88 var fbs = std.io.fixedBufferStream(&err_buf); 89 90 var state = State.normal; 91 var last_pressed: ?vaxis.Key = null; 92 var last_known_height: usize = vx.window().height; 93 while (true) { 94 const event = loop.nextEvent(); 95 96 switch (state) { 97 .normal => { 98 switch (event) { 99 .key_press => |key| { 100 if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { 101 break; 102 } 103 104 switch (key.codepoint) { 105 '-', 'h', Key.left => { 106 err_len = 0; 107 text_input.clearAndFree(); 108 109 if (view.dir.openDir("../", .{ .iterate = true })) |dir| { 110 view.dir = dir; 111 112 var fuzzy_buf: [std.fs.max_path_bytes]u8 = undefined; 113 const fuzzy = text_input.sliceToCursor(&fuzzy_buf); 114 115 view.cleanup(); 116 view.populate_entries(fuzzy) catch |err| { 117 err_len = switch (err) { 118 error.AccessDenied => try fbs.write("Permission denied."), 119 else => try fbs.write("An unknown error occurred."), 120 }; 121 }; 122 } else |err| { 123 err_len = switch (err) { 124 error.AccessDenied => try fbs.write("Permission denied."), 125 else => try fbs.write("An unknown error occurred."), 126 }; 127 } 128 last_pressed = null; 129 }, 130 Key.enter, 'l', Key.right => { 131 const entry = view.entries.get(view.entries.selected) catch continue; 132 133 switch (entry.kind) { 134 .directory => { 135 err_len = 0; 136 text_input.clearAndFree(); 137 138 if (view.dir.openDir(entry.name, .{ .iterate = true })) |dir| { 139 view.dir = dir; 140 141 var fuzzy_buf: [std.fs.max_path_bytes]u8 = undefined; 142 const fuzzy = text_input.sliceToCursor(&fuzzy_buf); 143 144 view.cleanup(); 145 view.populate_entries(fuzzy) catch |err| { 146 err_len = switch (err) { 147 error.AccessDenied => try fbs.write("Permission denied."), 148 else => try fbs.write("An unknown error occurred."), 149 }; 150 }; 151 } else |err| { 152 err_len = switch (err) { 153 error.AccessDenied => try fbs.write("Permission denied."), 154 else => try fbs.write("An unknown error occurred."), 155 }; 156 } 157 last_pressed = null; 158 }, 159 .file => { 160 if (environment.get_editor()) |editor| { 161 try vx.exitAltScreen(); 162 loop.stop(); 163 164 environment.open_file(alloc, view.dir, entry.name, editor) catch { 165 err_len = try fbs.write("Unable to open file."); 166 }; 167 168 try loop.run(); 169 try vx.enterAltScreen(); 170 vx.queueRefresh(); 171 } else { 172 err_len = try fbs.write("$EDITOR is not set."); 173 } 174 }, 175 else => {}, 176 } 177 }, 178 'j', Key.down => { 179 view.entries.next(last_known_height); 180 last_pressed = null; 181 }, 182 'k', Key.up => { 183 view.entries.previous(last_known_height); 184 last_pressed = null; 185 }, 186 'G' => { 187 view.entries.select_last(last_known_height); 188 last_pressed = null; 189 }, 190 'g' => { 191 if (last_pressed) |k| { 192 if (k.codepoint == 103) { 193 view.entries.select_first(); 194 last_pressed = null; 195 } 196 } else { 197 last_pressed = key; 198 } 199 }, 200 '/' => state = State.input, 201 else => { 202 // log.debug("codepoint: {d}\n", .{key.codepoint}); 203 }, 204 } 205 }, 206 .winsize => |ws| { 207 try vx.resize(alloc, ws); 208 }, 209 } 210 }, 211 .input => { 212 switch (event) { 213 .key_press => |key| { 214 if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { 215 break; 216 } 217 218 switch (key.codepoint) { 219 Key.escape => { 220 text_input.clearAndFree(); 221 state = State.normal; 222 223 view.cleanup(); 224 view.populate_entries("") catch |err| { 225 err_len = switch (err) { 226 error.AccessDenied => try fbs.write("Permission denied."), 227 else => try fbs.write("An unknown error occurred."), 228 }; 229 }; 230 }, 231 Key.enter => { 232 state = State.normal; 233 }, 234 else => { 235 try text_input.update(.{ .key_press = key }); 236 237 var fuzzy_buf: [std.fs.max_path_bytes]u8 = undefined; 238 const fuzzy = text_input.sliceToCursor(&fuzzy_buf); 239 view.cleanup(); 240 view.populate_entries(fuzzy) catch |err| { 241 err_len = switch (err) { 242 error.AccessDenied => try fbs.write("Permission denied."), 243 else => try fbs.write("An unknown error occurred."), 244 }; 245 }; 246 }, 247 } 248 }, 249 .winsize => |ws| { 250 try vx.resize(alloc, ws); 251 }, 252 } 253 }, 254 } 255 256 const win = vx.window(); 257 win.clear(); 258 259 const top_div = 1; 260 const info_div = 1; 261 const bottom_div = 1; 262 263 const info_bar = win.child(.{ 264 .x_off = 0, 265 .y_off = top_div, 266 .width = .{ .limit = win.width }, 267 .height = .{ .limit = info_div }, 268 }); 269 270 const top_left_bar = win.child(.{ 271 .x_off = 0, 272 .y_off = 0, 273 .width = .{ .limit = win.width }, 274 .height = .{ .limit = top_div }, 275 }); 276 277 const bottom_left_bar = win.child(.{ 278 .x_off = 0, 279 .y_off = win.height - bottom_div, 280 .width = if (config.preview_file) .{ .limit = win.width / 2 } else .{ .limit = win.width }, 281 .height = .{ .limit = bottom_div }, 282 }); 283 bottom_left_bar.fill(vaxis.Cell{ .style = config.styles.file_information }); 284 285 const bottom_right_bar = win.child(.{ 286 .x_off = (win.width / 2) + 5, 287 .y_off = win.height - bottom_div, 288 .width = .{ .limit = win.width / 2 }, 289 .height = .{ .limit = bottom_div }, 290 }); 291 292 const top_right_bar = win.child(.{ 293 .x_off = win.width / 2, 294 .y_off = 0, 295 .width = .{ .limit = win.width }, 296 .height = .{ .limit = top_div }, 297 }); 298 299 const left_bar = win.child(.{ 300 .x_off = 0, 301 .y_off = top_div + 1, 302 .width = if (config.preview_file) .{ .limit = win.width / 2 } else .{ .limit = win.width }, 303 .height = .{ .limit = win.height - (top_left_bar.height + bottom_left_bar.height + top_div + bottom_div) }, 304 }); 305 306 const right_bar = win.child(.{ 307 .x_off = win.width / 2, 308 .y_off = top_div + 1, 309 .width = .{ .limit = win.width / 2 }, 310 .height = .{ .limit = win.height - (top_right_bar.height + bottom_right_bar.height + top_div + bottom_div) }, 311 }); 312 313 if (view.entries.all().len > 0 and config.preview_file == true) { 314 const entry = try view.entries.get(view.entries.selected); 315 316 @memcpy(&last_path, &path); 317 last_item_path = last_path[0..current_item_path.len]; 318 current_item_path = try std.fmt.bufPrint(&path, "{s}/{s}", .{ try view.full_path("."), entry.name }); 319 320 switch (entry.kind) { 321 .directory => { 322 view.cleanup_sub(); 323 if (view.populate_sub_entries(entry.name)) { 324 try view.write_sub_entries(right_bar, config.styles.list_item); 325 } else |err| { 326 err_len = switch (err) { 327 error.AccessDenied => try fbs.write("Permission denied."), 328 else => try fbs.write("An unknown error occurred."), 329 }; 330 } 331 }, 332 .file => file: { 333 var file = view.dir.openFile(entry.name, .{ .mode = .read_only }) catch |err| { 334 err_len = switch (err) { 335 error.AccessDenied => try fbs.write("Permission denied."), 336 else => try fbs.write("An unknown error occurred."), 337 }; 338 339 _ = try right_bar.print(&.{ 340 .{ 341 .text = "No preview available.", 342 }, 343 }, .{}); 344 345 break :file; 346 }; 347 defer file.close(); 348 const bytes = try file.readAll(&file_buf); 349 350 // Handle image. 351 if (config.show_images == true) unsupported_terminal: { 352 const supported: [1][]const u8 = .{".png"}; 353 354 for (supported) |ext| { 355 if (std.mem.eql(u8, get_extension(entry.name), ext)) { 356 // Don't re-render preview if we haven't changed selection. 357 if (std.mem.eql(u8, last_item_path, current_item_path)) break :file; 358 359 if (vx.loadImage(alloc, .{ .path = current_item_path })) |img| { 360 image = img; 361 } else |_| { 362 image = null; 363 break :unsupported_terminal; 364 } 365 366 break :file; 367 } else { 368 // Free any image we might have already. 369 if (image) |img| { 370 vx.freeImage(img.id); 371 } 372 } 373 } 374 } 375 376 // Handle utf-8. 377 if (std.unicode.utf8ValidateSlice(file_buf[0..bytes])) { 378 _ = try right_bar.print(&.{ 379 .{ 380 .text = file_buf[0..bytes], 381 }, 382 }, .{}); 383 break :file; 384 } 385 386 // Fallback to no preview. 387 _ = try right_bar.print(&.{ 388 .{ 389 .text = "No preview available.", 390 }, 391 }, .{}); 392 }, 393 else => { 394 _ = try right_bar.print(&.{vaxis.Segment{ .text = current_item_path }}, .{}); 395 }, 396 } 397 } 398 399 if (image) |img| { 400 try img.draw(right_bar, .{ .scale = .fit }); 401 } 402 403 _ = try top_left_bar.print(&.{vaxis.Segment{ .text = try view.full_path(".") }}, .{}); 404 405 var file_information_buf: [1024]u8 = undefined; 406 const file_information = try std.fmt.bufPrint(&file_information_buf, "{d}/{d} {s} {s}", .{ 407 view.entries.selected + 1, 408 view.entries.items.items.len, 409 get_extension( 410 if (view.entries.get(view.entries.selected)) |entry| entry.name else |_| "", 411 ), 412 std.fmt.fmtIntSizeDec(file_metadata.size()), 413 }); 414 _ = try bottom_left_bar.print(&.{vaxis.Segment{ .text = file_information, .style = config.styles.file_information }}, .{}); 415 _ = try bottom_right_bar.print(&.{vaxis.Segment{ 416 .text = if (last_pressed) |key| key.text.? else "", 417 .style = if (config.preview_file) .{} else config.styles.file_information, 418 }}, .{}); 419 420 if (config.preview_file == true) { 421 if (view.entries.get(view.entries.selected)) |entry| { 422 var file_name_buf: [std.fs.MAX_NAME_BYTES + 2]u8 = undefined; 423 const file_name = try std.fmt.bufPrint(&file_name_buf, "[{s}]", .{entry.name}); 424 _ = try top_right_bar.print(&.{vaxis.Segment{ 425 .text = file_name, 426 .style = config.styles.file_name, 427 }}, .{}); 428 } else |_| {} 429 } 430 431 try view.write_entries(left_bar, config.styles.selected_list_item, config.styles.list_item, null); 432 433 if (state == State.input or text_input.grapheme_count > 0) { 434 err_len = 0; 435 text_input.draw(info_bar); 436 } 437 438 if (err_len > 0) { 439 if (text_input.grapheme_count > 0) { 440 text_input.clearAndFree(); 441 } 442 443 _ = try info_bar.print(&.{ 444 .{ 445 .text = err_buf[0..err_len], 446 .style = config.styles.error_bar, 447 }, 448 }, .{}); 449 } 450 451 if (state == State.normal) { 452 win.hideCursor(); 453 } 454 455 try vx.render(); 456 457 last_known_height = left_bar.height; 458 fbs.reset(); 459 } 460} 461 462fn get_extension(file: []const u8) []const u8 { 463 const index = std.mem.indexOf(u8, file, ".") orelse 0; 464 if (index == 0) { 465 return ""; 466 } 467 return file[std.mem.indexOf(u8, file, ".") orelse 0 ..]; 468} 469 470pub fn panic(msg: []const u8, trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn { 471 vx.deinit(null); 472 std.builtin.default_panic(msg, trace, ret_addr); 473}