地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
at main 519 lines 18 kB view raw
1const std = @import("std"); 2 3const vaxis = @import("vaxis"); 4const zeit = @import("zeit"); 5 6const App = @import("./app.zig"); 7const Archive = @import("./archive.zig"); 8const Directories = @import("./directories.zig"); 9const FileLogger = @import("./file_logger.zig"); 10const Git = @import("./git.zig"); 11const Image = @import("./image.zig"); 12const List = @import("./list.zig").List; 13const Notification = @import("./notification.zig"); 14const path_utils = @import("./path_utils.zig"); 15const Preview = @import("./preview.zig"); 16const sort = @import("./sort.zig"); 17 18const config = &@import("./config.zig").config; 19const Drawer = @This(); 20 21const top_div: u16 = 1; 22const info_div: u16 = 1; 23 24file_info_buf: [std.fs.max_path_bytes]u8 = undefined, 25file_name_buf: [std.fs.max_path_bytes + 2]u8 = undefined, // +2 to accomodate for [<file_name>] 26git_branch: [1024]u8 = undefined, 27verbose: bool = false, 28 29pub fn draw(self: *Drawer, app: *App) error{ OutOfMemory, NoSpaceLeft }!void { 30 const win = app.vx.window(); 31 win.clear(); 32 33 if (app.state == .help_menu) { 34 win.hideCursor(); 35 const offset: usize = app.help_menu.selected; 36 for (app.help_menu.all()[offset..], 0..) |item, i| { 37 if (i > win.height) continue; 38 39 const w = win.child(.{ .y_off = @intCast(i), .height = 1 }); 40 w.fill(vaxis.Cell{ 41 .style = config.styles.list_item, 42 }); 43 44 _ = w.print(&.{.{ 45 .text = item, 46 .style = config.styles.list_item, 47 }}, .{}); 48 } 49 50 return; 51 } 52 53 const abs_file_path_bar = try self.drawAbsFilePath(app, win); 54 const file_info_bar = try self.drawFileInfo(app.alloc, &app.directories, win); 55 app.last_known_height = drawDirList( 56 win, 57 app.directories.entries, 58 abs_file_path_bar, 59 file_info_bar, 60 ); 61 62 if (config.preview_file) { 63 const file_name_bar = try self.drawFileName(&app.directories, win); 64 try drawFilePreview(app, win, file_name_bar); 65 } 66 67 const input = app.readInput(); 68 drawUserInput(app.state, &app.text_input, input, win); 69 70 // Notification should be drawn last. 71 drawNotification(&app.notification, &app.file_logger, win); 72} 73 74fn drawFileName( 75 self: *Drawer, 76 directories: *Directories, 77 win: vaxis.Window, 78) error{NoSpaceLeft}!vaxis.Window { 79 const file_name_bar = win.child(.{ 80 .x_off = win.width / 2, 81 .y_off = 0, 82 .width = win.width, 83 .height = top_div, 84 }); 85 86 const entry = lbl: { 87 const entry = directories.getSelected() catch return file_name_bar; 88 if (entry) |e| break :lbl e else return file_name_bar; 89 }; 90 91 const file_name = try std.fmt.bufPrint(&self.file_name_buf, "[{s}]", .{entry.name}); 92 _ = file_name_bar.printSegment(.{ .text = file_name, .style = config.styles.file_name }, .{}); 93 94 return file_name_bar; 95} 96 97fn drawFilePreview( 98 app: *App, 99 win: vaxis.Window, 100 file_name_win: vaxis.Window, 101) error{ OutOfMemory, NoSpaceLeft }!void { 102 const bottom_div: u16 = 1; 103 104 const preview_win = win.child(.{ 105 .x_off = win.width / 2, 106 .y_off = top_div + 1, 107 .width = win.width / 2, 108 .height = win.height - (file_name_win.height + top_div + bottom_div), 109 }); 110 111 if (app.directories.entries.len() == 0 or !config.preview_file) return; 112 113 const entry = lbl: { 114 const entry = app.directories.getSelected() catch return; 115 if (entry) |e| break :lbl e else return; 116 }; 117 118 const clean_name = path_utils.getCleanName(entry); 119 const abs_path = app.directories.fullPath(clean_name) catch { 120 _ = preview_win.print(&.{.{ .text = "Unable to get file path." }}, .{}); 121 return; 122 }; 123 124 const preview_data = app.preview_cache.get(abs_path); 125 if (preview_data == null) { 126 _ = preview_win.print(&.{.{ .text = "Loading preview..." }}, .{}); 127 return; 128 } 129 130 switch (preview_data.?.*) { 131 .none => { 132 _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 133 }, 134 .text, .pdf => |text| { 135 _ = preview_win.print(&.{.{ .text = text }}, .{}); 136 }, 137 .directory => |entries| { 138 for (entries.items, 0..) |item, i| { 139 if (std.mem.startsWith(u8, item, ".") and config.show_hidden == false) { 140 continue; 141 } 142 if (i >= preview_win.height) break; 143 const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 144 w.fill(vaxis.Cell{ .style = config.styles.list_item }); 145 _ = w.print(&.{.{ .text = item, .style = config.styles.list_item }}, .{}); 146 } 147 }, 148 .archive => |entries| { 149 for (entries.items, 0..) |item, i| { 150 if (i >= preview_win.height) break; 151 const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 152 w.fill(vaxis.Cell{ .style = config.styles.list_item }); 153 _ = w.print(&.{.{ .text = item, .style = config.styles.list_item }}, .{}); 154 } 155 }, 156 .image => |img_info| { 157 if (!config.show_images) { 158 _ = preview_win.print(&.{.{ .text = "Image preview disabled." }}, .{}); 159 return; 160 } 161 162 app.images.mutex.lock(); 163 defer app.images.mutex.unlock(); 164 165 if (app.images.cache.getPtr(img_info.cache_path)) |cache_entry| { 166 switch (cache_entry.status) { 167 .processing => { 168 _ = preview_win.print(&.{.{ .text = "Image still processing..." }}, .{}); 169 }, 170 .failed => { 171 _ = preview_win.print(&.{.{ .text = "Failed to process image." }}, .{}); 172 }, 173 .ready => { 174 if (cache_entry.image) |image| { 175 image.draw(preview_win, .{ .scale = .contain }) catch { 176 _ = preview_win.print(&.{.{ .text = "Failed to draw image." }}, .{}); 177 return; 178 }; 179 } else if (cache_entry.data) |*data| { 180 if (app.vx.transmitImage(app.alloc, app.tty.writer(), data, .rgba)) |image| { 181 image.draw(preview_win, .{ .scale = .contain }) catch { 182 _ = preview_win.print(&.{.{ .text = "Failed to draw image." }}, .{}); 183 return; 184 }; 185 cache_entry.image = image; 186 var d = data.*; 187 d.deinit(app.alloc); 188 cache_entry.data = null; 189 } else |_| { 190 _ = preview_win.print(&.{.{ .text = "Failed to transmit image." }}, .{}); 191 } 192 } else { 193 _ = preview_win.print(&.{.{ .text = "Image processing..." }}, .{}); 194 } 195 }, 196 } 197 } else { 198 _ = preview_win.print(&.{.{ .text = "Image not found in cache." }}, .{}); 199 } 200 }, 201 } 202} 203 204fn drawFileInfo( 205 self: *Drawer, 206 alloc: std.mem.Allocator, 207 directories: *Directories, 208 win: vaxis.Window, 209) error{NoSpaceLeft}!vaxis.Window { 210 const bottom_div: u16 = if (self.verbose) 6 else 1; 211 212 const file_info_win = win.child(.{ 213 .x_off = 0, 214 .y_off = win.height - bottom_div, 215 .width = if (config.preview_file) win.width / 2 else win.width, 216 .height = bottom_div, 217 }); 218 file_info_win.fill(.{ .style = config.styles.file_information }); 219 220 const entry = lbl: { 221 const entry = directories.getSelected() catch return file_info_win; 222 if (entry) |e| break :lbl e else return file_info_win; 223 }; 224 225 var fbs = std.io.fixedBufferStream(&self.file_info_buf); 226 227 // Selected entry. 228 try fbs.writer().print( 229 "{s}{d}/{d}{s}", 230 .{ 231 if (self.verbose) "Entry: " else "", 232 directories.entries.selected + 1, 233 directories.entries.len(), 234 if (self.verbose) "\n" else " ", 235 }, 236 ); 237 238 // Time created / last modified 239 if (self.verbose) lbl: { 240 var maybe_meta: ?std.fs.File.Stat = null; 241 if (entry.kind == .directory) { 242 maybe_meta = directories.dir.stat() catch break :lbl; 243 } else if (entry.kind == .file) { 244 const clean_name = path_utils.getCleanName(entry); 245 var file = directories.dir.openFile(clean_name, .{}) catch break :lbl; 246 maybe_meta = file.stat() catch break :lbl; 247 } 248 249 const meta = maybe_meta orelse break :lbl; 250 var env = std.process.getEnvMap(alloc) catch break :lbl; 251 defer env.deinit(); 252 const local = zeit.local(alloc, &env) catch break :lbl; 253 defer local.deinit(); 254 255 const ctime_instant = zeit.instant(.{ 256 .source = .{ .unix_nano = meta.ctime }, 257 .timezone = &local, 258 }) catch break :lbl; 259 const ctime = ctime_instant.time(); 260 ctime.strftime(fbs.writer().any(), "Created: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 261 262 const mtime_instant = zeit.instant(.{ 263 .source = .{ .unix_nano = meta.mtime }, 264 .timezone = &local, 265 }) catch break :lbl; 266 const mtime = mtime_instant.time(); 267 mtime.strftime(fbs.writer().any(), "Last modified: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 268 } 269 270 // File permissions. 271 var file_perm_buf: [11]u8 = undefined; 272 const file_perms: usize = lbl: { 273 if (self.verbose) try fbs.writer().writeAll("Permissions: "); 274 var file_perm_fbs = std.io.fixedBufferStream(&file_perm_buf); 275 276 if (entry.kind == .directory) { 277 _ = try file_perm_fbs.write("d"); 278 } 279 280 const perm_strings = [_][]const u8{ 281 "---", "--x", "-w-", "-wx", 282 "r--", "r-x", "rw-", "rwx", 283 }; 284 285 const clean_name = path_utils.getCleanName(entry); 286 const stat = directories.dir.statFile(clean_name) catch { 287 _ = try file_perm_fbs.write("---------\n"); 288 break :lbl 10; 289 }; 290 // Ignore upper bytes as they represent file type. 291 const perms = @as(u9, @truncate(stat.mode)); 292 293 for (0..3) |group| { 294 const shift: u4 = @truncate((2 - group) * 3); // Extract from left to right 295 const perm = @as(u3, @truncate((perms >> shift) & 0b111)); 296 _ = try file_perm_fbs.write(perm_strings[perm]); 297 } 298 299 if (self.verbose) { 300 _ = try file_perm_fbs.write("\n"); 301 } else { 302 _ = try file_perm_fbs.write(" "); 303 } 304 305 if (entry.kind == .directory) { 306 break :lbl 11; 307 } else { 308 break :lbl 10; 309 } 310 }; 311 try fbs.writer().writeAll(file_perm_buf[0..file_perms]); 312 313 // Size. 314 const size: ?usize = lbl: { 315 const clean_name = path_utils.getCleanName(entry); 316 const stat = directories.dir.statFile(clean_name) catch break :lbl null; 317 if (entry.kind == .file) { 318 break :lbl stat.size; 319 } else if (entry.kind == .directory) { 320 if (config.true_dir_size) { 321 var dir = directories.dir.openDir( 322 clean_name, 323 .{ .iterate = true }, 324 ) catch break :lbl null; 325 defer dir.close(); 326 break :lbl directories.getDirSize(dir) catch break :lbl null; 327 } else { 328 break :lbl stat.size; 329 } 330 } 331 332 break :lbl 0; 333 }; 334 if (size) |s| try fbs.writer().print("{s}{B:.2}\n", .{ 335 if (self.verbose) "Size: " else "", 336 s, 337 }); 338 339 // Extension. 340 const extension = std.fs.path.extension(entry.name); 341 if (self.verbose) { 342 try fbs.writer().print( 343 "Extension: {s}\n", 344 .{if (entry.kind == .directory) "Dir" else extension}, 345 ); 346 } else { 347 try fbs.writer().print( 348 "{s} ", 349 .{if (entry.kind == .directory) "dir" else extension}, 350 ); 351 } 352 353 _ = file_info_win.printSegment(.{ 354 .text = fbs.getWritten(), 355 .style = config.styles.file_information, 356 }, .{}); 357 358 return file_info_win; 359} 360 361fn drawDirList( 362 win: vaxis.Window, 363 list: List(std.fs.Dir.Entry), 364 abs_file_path: vaxis.Window, 365 file_information: vaxis.Window, 366) u16 { 367 const bottom_div: u16 = 1; 368 369 const current_dir_list_win = win.child(.{ 370 .x_off = 0, 371 .y_off = top_div + 1, 372 .width = if (config.preview_file) win.width / 2 else win.width, 373 .height = win.height - (abs_file_path.height + file_information.height + top_div + bottom_div), 374 }); 375 376 const win_height = current_dir_list_win.height; 377 var offset: usize = 0; 378 379 while (list.all()[offset..].len > win_height and 380 list.selected >= offset + (win_height / 2)) 381 { 382 offset += 1; 383 } 384 385 for (list.all()[offset..], 0..) |item, i| { 386 const selected = list.selected - offset; 387 const is_selected = selected == i; 388 389 if (i > win_height) continue; 390 391 const w = current_dir_list_win.child(.{ .y_off = @intCast(i), .height = 1 }); 392 w.fill(vaxis.Cell{ 393 .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 394 }); 395 396 _ = w.print(&.{ 397 .{ 398 .text = item.name, 399 .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 400 }, 401 }, .{}); 402 } 403 404 return win_height; 405} 406 407fn drawAbsFilePath( 408 self: *Drawer, 409 app: *App, 410 win: vaxis.Window, 411) error{ OutOfMemory, NoSpaceLeft }!vaxis.Window { 412 const abs_file_path_bar = win.child(.{ 413 .x_off = 0, 414 .y_off = 0, 415 .width = win.width, 416 .height = top_div, 417 }); 418 419 const branch_alloc = Git.getGitBranch(app.alloc, app.directories.dir) catch null; 420 defer if (branch_alloc) |b| app.alloc.free(b); 421 const branch = if (branch_alloc) |b| 422 try std.fmt.bufPrint( 423 &self.git_branch, 424 "{s}", 425 .{std.mem.trim(u8, b, " \n\r")}, 426 ) 427 else 428 ""; 429 430 _ = abs_file_path_bar.print(&.{ 431 vaxis.Segment{ .text = app.directories.fullPath(".") catch { 432 const message = try std.fmt.allocPrint(app.alloc, "Can not display absolute file path - unable to retrieve full path.", .{}); 433 defer app.alloc.free(message); 434 app.notification.write(message, .err) catch {}; 435 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 436 return abs_file_path_bar; 437 } }, 438 vaxis.Segment{ .text = if (branch_alloc != null) " on " else "" }, 439 vaxis.Segment{ .text = branch, .style = config.styles.git_branch }, 440 }, .{}); 441 442 return abs_file_path_bar; 443} 444 445fn drawUserInput( 446 current_state: App.State, 447 text_input: *vaxis.widgets.TextInput, 448 input: []const u8, 449 win: vaxis.Window, 450) void { 451 const user_input_win = win.child(.{ 452 .x_off = 0, 453 .y_off = top_div, 454 .width = win.width / 2, 455 .height = info_div, 456 }); 457 user_input_win.fill(.{ .style = config.styles.text_input }); 458 459 switch (current_state) { 460 .fuzzy, .new_file, .new_dir, .rename, .change_dir, .command => { 461 text_input.drawWithStyle(user_input_win, config.styles.text_input); 462 }, 463 .normal => { 464 if (text_input.buf.realLength() > 0) { 465 text_input.drawWithStyle( 466 user_input_win, 467 if (std.mem.eql(u8, input, ":UnsupportedCommand")) 468 config.styles.text_input_err 469 else 470 config.styles.text_input, 471 ); 472 } 473 474 win.hideCursor(); 475 }, 476 .help_menu => { 477 win.hideCursor(); 478 }, 479 } 480} 481 482fn drawNotification( 483 notification: *Notification, 484 file_logger: *?FileLogger, 485 win: vaxis.Window, 486) void { 487 if (notification.len() == 0) return; 488 if (notification.clearIfEnded()) return; 489 490 const width_padding = 4; 491 const height_padding = 3; 492 const screen_pos_padding = 10; 493 494 const max_width = win.width / 4; 495 const width = notification.len() + width_padding; 496 const calculated_width = if (width > max_width) max_width else width; 497 const height = (std.math.divCeil(usize, notification.len(), calculated_width) catch { 498 if (file_logger.*) |fl| fl.write("Unable to display notification - failed to calculate notification height.", .err) catch {}; 499 return; 500 }) + height_padding; 501 502 const notification_win = win.child(.{ 503 .x_off = @intCast(win.width - (calculated_width + screen_pos_padding)), 504 .y_off = top_div, 505 .width = @intCast(calculated_width), 506 .height = @intCast(height), 507 .border = .{ .where = .all, .style = switch (notification.style) { 508 .info => config.styles.notification.info, 509 .err => config.styles.notification.err, 510 .warn => config.styles.notification.warn, 511 } }, 512 }); 513 514 notification_win.fill(.{ .style = config.styles.notification.box }); 515 _ = notification_win.printSegment(.{ 516 .text = notification.slice(), 517 .style = config.styles.notification.box, 518 }, .{ .wrap = .word }); 519}