地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

at v1.3.0 670 lines 25 kB view raw
1const std = @import("std"); 2const App = @import("./app.zig"); 3const FileLogger = @import("./file_logger.zig"); 4const Notification = @import("./notification.zig"); 5const Directories = @import("./directories.zig"); 6const config = &@import("./config.zig").config; 7const vaxis = @import("vaxis"); 8const Git = @import("./git.zig"); 9const List = @import("./list.zig").List; 10const zeit = @import("zeit"); 11 12const Drawer = @This(); 13 14const top_div: u16 = 1; 15const info_div: u16 = 1; 16 17// Used to detect whether to re-render an image. 18current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 19current_item_path: []u8 = "", 20last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, 21last_item_path: []u8 = "", 22file_info_buf: [std.fs.max_path_bytes]u8 = undefined, 23file_name_buf: [std.fs.max_path_bytes + 2]u8 = undefined, // +2 to accomodate for [<file_name>] 24git_branch: [1024]u8 = undefined, 25verbose: bool = false, 26 27pub fn draw(self: *Drawer, app: *App) error{ OutOfMemory, NoSpaceLeft }!void { 28 const win = app.vx.window(); 29 win.clear(); 30 31 if (app.state == .help_menu) { 32 win.hideCursor(); 33 const offset: usize = app.help_menu.selected; 34 for (app.help_menu.all()[offset..], 0..) |item, i| { 35 if (i > win.height) continue; 36 37 const w = win.child(.{ .y_off = @intCast(i), .height = 1 }); 38 w.fill(vaxis.Cell{ 39 .style = config.styles.list_item, 40 }); 41 42 _ = w.print(&.{.{ 43 .text = item, 44 .style = config.styles.list_item, 45 }}, .{}); 46 } 47 48 return; 49 } 50 51 const abs_file_path_bar = try self.drawAbsFilePath(app, win); 52 const file_info_bar = try self.drawFileInfo(app.alloc, &app.directories, win); 53 app.last_known_height = drawDirList( 54 win, 55 app.directories.entries, 56 abs_file_path_bar, 57 file_info_bar, 58 ); 59 60 if (config.preview_file) { 61 const file_name_bar = try self.drawFileName(&app.directories, win); 62 try self.drawFilePreview(app, win, file_name_bar); 63 } 64 65 const input = app.inputToSlice(); 66 drawUserInput(app.state, &app.text_input, input, win); 67 68 // Notification should be drawn last. 69 drawNotification(&app.notification, &app.file_logger, win); 70} 71 72fn drawFileName( 73 self: *Drawer, 74 directories: *Directories, 75 win: vaxis.Window, 76) error{NoSpaceLeft}!vaxis.Window { 77 const file_name_bar = win.child(.{ 78 .x_off = win.width / 2, 79 .y_off = 0, 80 .width = win.width, 81 .height = top_div, 82 }); 83 84 const entry = lbl: { 85 const entry = directories.getSelected() catch return file_name_bar; 86 if (entry) |e| break :lbl e else return file_name_bar; 87 }; 88 89 const file_name = try std.fmt.bufPrint(&self.file_name_buf, "[{s}]", .{entry.name}); 90 _ = file_name_bar.printSegment(.{ .text = file_name, .style = config.styles.file_name }, .{}); 91 92 return file_name_bar; 93} 94 95fn drawFilePreview( 96 self: *Drawer, 97 app: *App, 98 win: vaxis.Window, 99 file_name_win: vaxis.Window, 100) error{ OutOfMemory, NoSpaceLeft }!void { 101 const bottom_div: u16 = 1; 102 103 const preview_win = win.child(.{ 104 .x_off = win.width / 2, 105 .y_off = top_div + 1, 106 .width = win.width / 2, 107 .height = win.height - (file_name_win.height + top_div + bottom_div), 108 }); 109 110 if (app.directories.entries.len() == 0 or !config.preview_file) return; 111 112 const entry = lbl: { 113 const entry = app.directories.getSelected() catch return; 114 if (entry) |e| break :lbl e else return; 115 }; 116 117 @memcpy(&self.last_item_path_buf, &self.current_item_path_buf); 118 self.last_item_path = self.last_item_path_buf[0..self.current_item_path.len]; 119 self.current_item_path = try std.fmt.bufPrint( 120 &self.current_item_path_buf, 121 "{s}/{s}", 122 .{ app.directories.fullPath(".") catch { 123 const message = try std.fmt.allocPrint(app.alloc, "Can not display file - unable to retrieve directory path.", .{}); 124 defer app.alloc.free(message); 125 app.notification.write(message, .err) catch {}; 126 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 127 128 _ = preview_win.print(&.{ 129 .{ .text = "Can not display file - unable to retrieve directory path. No preview available." }, 130 }, .{}); 131 return; 132 }, entry.name }, 133 ); 134 135 switch (entry.kind) { 136 .directory => { 137 app.directories.clearChildEntries(); 138 app.directories.populateChildEntries(entry.name) catch |err| { 139 const message = try std.fmt.allocPrint(app.alloc, "Failed to populate child directory entries - {}.", .{err}); 140 defer app.alloc.free(message); 141 app.notification.write(message, .err) catch {}; 142 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 143 144 _ = preview_win.print(&.{ 145 .{ .text = "Failed to populate child directory entries. No preview available." }, 146 }, .{}); 147 148 return; 149 }; 150 151 for (app.directories.child_entries.all(), 0..) |item, i| { 152 if (std.mem.startsWith(u8, item, ".") and config.show_hidden == false) { 153 continue; 154 } 155 if (i > preview_win.height) continue; 156 const w = preview_win.child(.{ .y_off = @intCast(i), .height = 1 }); 157 w.fill(vaxis.Cell{ .style = config.styles.list_item }); 158 _ = w.print(&.{.{ .text = item, .style = config.styles.list_item }}, .{}); 159 } 160 }, 161 .file => file: { 162 var file = app.directories.dir.openFile( 163 entry.name, 164 .{ .mode = .read_only }, 165 ) catch |err| { 166 const message = try std.fmt.allocPrint(app.alloc, "Failed to open file - {}.", .{err}); 167 defer app.alloc.free(message); 168 app.notification.write(message, .err) catch {}; 169 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 170 171 _ = preview_win.print(&.{ 172 .{ .text = "Failed to open file. No preview available." }, 173 }, .{}); 174 175 break :file; 176 }; 177 defer file.close(); 178 const bytes = file.readAll(&app.directories.file_contents) catch |err| { 179 const message = try std.fmt.allocPrint(app.alloc, "Failed to read file contents - {}.", .{err}); 180 defer app.alloc.free(message); 181 app.notification.write(message, .err) catch {}; 182 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 183 184 _ = preview_win.print(&.{ 185 .{ .text = "Failed to read file contents. No preview available." }, 186 }, .{}); 187 188 break :file; 189 }; 190 191 // Handle image. 192 if (config.show_images == true) unsupported: { 193 var match = false; 194 inline for (@typeInfo(vaxis.zigimg.Image.Format).@"enum".fields) |field| { 195 const entry_ext = std.mem.trimLeft(u8, std.fs.path.extension(entry.name), "."); 196 if (std.mem.eql(u8, entry_ext, field.name)) match = true; 197 } 198 if (!match) break :unsupported; 199 200 app.images.mutex.lock(); 201 defer app.images.mutex.unlock(); 202 203 if (app.images.cache.getPtr(self.current_item_path)) |cache_entry| { 204 if (cache_entry.status == .processing) { 205 _ = preview_win.print(&.{ 206 .{ .text = "Image still processing." }, 207 }, .{}); 208 break :file; 209 } 210 211 if (cache_entry.image) |img| { 212 img.draw(preview_win, .{ .scale = .contain }) catch |err| { 213 const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 214 defer app.alloc.free(message); 215 app.notification.write(message, .err) catch {}; 216 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 217 218 _ = preview_win.print(&.{ 219 .{ .text = "Failed to draw image to screen. No preview available." }, 220 }, .{}); 221 cache_entry.image = null; 222 break :file; 223 }; 224 } else { 225 if (cache_entry.data == null) { 226 const path = try app.alloc.dupe(u8, self.current_item_path); 227 processImage(app, path) catch break :unsupported; 228 } 229 230 if (app.vx.transmitImage(app.alloc, app.tty.anyWriter(), &cache_entry.data.?, .rgba)) |img| { 231 img.draw(preview_win, .{ .scale = .contain }) catch |err| { 232 const message = try std.fmt.allocPrint(app.alloc, "Failed to draw image to screen - {}.", .{err}); 233 defer app.alloc.free(message); 234 app.notification.write(message, .err) catch {}; 235 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 236 237 _ = preview_win.print(&.{ 238 .{ .text = "Failed to draw image to screen. No preview available." }, 239 }, .{}); 240 break :file; 241 }; 242 cache_entry.image = img; 243 cache_entry.data.?.deinit(); 244 cache_entry.data = null; 245 } else |_| { 246 break :unsupported; 247 } 248 } 249 250 break :file; 251 } else { 252 const path = try app.alloc.dupe(u8, self.current_item_path); 253 processImage(app, path) catch break :unsupported; 254 } 255 256 break :file; 257 } 258 259 // Handle pdf. 260 if (std.mem.eql(u8, std.fs.path.extension(entry.name), ".pdf")) { 261 const output = std.process.Child.run(.{ 262 .allocator = app.alloc, 263 .argv = &[_][]const u8{ 264 "pdftotext", 265 "-f", 266 "0", 267 "-l", 268 "5", 269 self.current_item_path, 270 "-", 271 }, 272 .cwd_dir = app.directories.dir, 273 }) catch { 274 _ = preview_win.print(&.{.{ 275 .text = "No preview available. Install pdftotext to get PDF previews.", 276 }}, .{}); 277 break :file; 278 }; 279 defer app.alloc.free(output.stderr); 280 defer app.alloc.free(output.stdout); 281 282 if (output.term.Exited != 0) { 283 _ = preview_win.print(&.{.{ 284 .text = "No preview available. Install pdftotext to get PDF previews.", 285 }}, .{}); 286 break :file; 287 } 288 289 if (app.directories.pdf_contents) |contents| app.alloc.free(contents); 290 app.directories.pdf_contents = try app.alloc.dupe(u8, output.stdout); 291 292 _ = preview_win.print(&.{ 293 .{ .text = app.directories.pdf_contents.? }, 294 }, .{}); 295 break :file; 296 } 297 298 // Handle utf-8. 299 if (std.unicode.utf8ValidateSlice(app.directories.file_contents[0..bytes])) { 300 _ = preview_win.print(&.{ 301 .{ .text = app.directories.file_contents[0..bytes] }, 302 }, .{}); 303 break :file; 304 } 305 306 // Fallback to no preview. 307 _ = preview_win.print(&.{.{ .text = "No preview available." }}, .{}); 308 }, 309 else => { 310 _ = preview_win.print(&.{ 311 vaxis.Segment{ .text = self.current_item_path }, 312 }, .{}); 313 }, 314 } 315} 316 317fn drawFileInfo( 318 self: *Drawer, 319 alloc: std.mem.Allocator, 320 directories: *Directories, 321 win: vaxis.Window, 322) error{NoSpaceLeft}!vaxis.Window { 323 const bottom_div: u16 = if (self.verbose) 6 else 1; 324 325 const file_info_win = win.child(.{ 326 .x_off = 0, 327 .y_off = win.height - bottom_div, 328 .width = if (config.preview_file) win.width / 2 else win.width, 329 .height = bottom_div, 330 }); 331 file_info_win.fill(.{ .style = config.styles.file_information }); 332 333 const entry = lbl: { 334 const entry = directories.getSelected() catch return file_info_win; 335 if (entry) |e| break :lbl e else return file_info_win; 336 }; 337 338 var fbs = std.io.fixedBufferStream(&self.file_info_buf); 339 340 // Selected entry. 341 try fbs.writer().print( 342 "{s}{d}/{d}{s}", 343 .{ 344 if (self.verbose) "Entry: " else "", 345 directories.entries.selected + 1, 346 directories.entries.len(), 347 if (self.verbose) "\n" else " ", 348 }, 349 ); 350 351 // Time created / last modified 352 if (self.verbose) lbl: { 353 var maybe_meta: ?std.fs.File.Metadata = null; 354 if (entry.kind == .directory) { 355 maybe_meta = directories.dir.metadata() catch break :lbl; 356 } else if (entry.kind == .file) { 357 var file = directories.dir.openFile(entry.name, .{}) catch break :lbl; 358 maybe_meta = file.metadata() catch break :lbl; 359 } 360 361 const meta = maybe_meta orelse break :lbl; 362 var env = std.process.getEnvMap(alloc) catch break :lbl; 363 defer env.deinit(); 364 const local = zeit.local(alloc, &env) catch break :lbl; 365 defer local.deinit(); 366 367 const ctime_instant = zeit.instant(.{ 368 .source = .{ .unix_nano = meta.created().? }, 369 .timezone = &local, 370 }) catch break :lbl; 371 const ctime = ctime_instant.time(); 372 ctime.strftime(fbs.writer().any(), "Created: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 373 374 const mtime_instant = zeit.instant(.{ 375 .source = .{ .unix_nano = meta.modified() }, 376 .timezone = &local, 377 }) catch break :lbl; 378 const mtime = mtime_instant.time(); 379 mtime.strftime(fbs.writer().any(), "Last modified: %Y-%m-%d %H:%M:%S\n") catch break :lbl; 380 } 381 382 // File permissions. 383 var file_perm_buf: [11]u8 = undefined; 384 const file_perms: usize = lbl: { 385 if (self.verbose) try fbs.writer().writeAll("Permissions: "); 386 var file_perm_fbs = std.io.fixedBufferStream(&file_perm_buf); 387 388 if (entry.kind == .directory) { 389 _ = try file_perm_fbs.write("d"); 390 } 391 392 const perm_strings = [_][]const u8{ 393 "---", "--x", "-w-", "-wx", 394 "r--", "r-x", "rw-", "rwx", 395 }; 396 397 const stat = directories.dir.statFile(entry.name) catch { 398 _ = try file_perm_fbs.write("---------\n"); 399 break :lbl 10; 400 }; 401 // Ignore upper bytes as they represent file type. 402 const perms = @as(u9, @truncate(stat.mode)); 403 404 for (0..3) |group| { 405 const shift: u4 = @truncate((2 - group) * 3); // Extract from left to right 406 const perm = @as(u3, @truncate((perms >> shift) & 0b111)); 407 _ = try file_perm_fbs.write(perm_strings[perm]); 408 } 409 410 if (self.verbose) { 411 _ = try file_perm_fbs.write("\n"); 412 } else { 413 _ = try file_perm_fbs.write(" "); 414 } 415 416 if (entry.kind == .directory) { 417 break :lbl 11; 418 } else { 419 break :lbl 10; 420 } 421 }; 422 try fbs.writer().writeAll(file_perm_buf[0..file_perms]); 423 424 // Size. 425 const size: ?usize = lbl: { 426 const stat = directories.dir.statFile(entry.name) catch break :lbl null; 427 if (entry.kind == .file) { 428 break :lbl stat.size; 429 } else if (entry.kind == .directory) { 430 if (config.true_dir_size) { 431 var dir = directories.dir.openDir( 432 entry.name, 433 .{ .iterate = true }, 434 ) catch break :lbl null; 435 defer dir.close(); 436 break :lbl directories.getDirSize(dir) catch break :lbl null; 437 } else { 438 break :lbl stat.size; 439 } 440 } 441 442 break :lbl 0; 443 }; 444 if (size) |s| try fbs.writer().print("{s}{:.2}\n", .{ 445 if (self.verbose) "Size: " else "", 446 std.fmt.fmtIntSizeDec(s), 447 }); 448 449 // Extension. 450 const extension = std.fs.path.extension(entry.name); 451 if (self.verbose) { 452 try fbs.writer().print( 453 "Extension: {s}\n", 454 .{if (entry.kind == .directory) "Dir" else extension}, 455 ); 456 } else { 457 try fbs.writer().print( 458 "{s} ", 459 .{if (entry.kind == .directory) "dir" else extension}, 460 ); 461 } 462 463 _ = file_info_win.printSegment(.{ 464 .text = fbs.getWritten(), 465 .style = config.styles.file_information, 466 }, .{}); 467 468 return file_info_win; 469} 470 471fn drawDirList( 472 win: vaxis.Window, 473 list: List(std.fs.Dir.Entry), 474 abs_file_path: vaxis.Window, 475 file_information: vaxis.Window, 476) u16 { 477 const bottom_div: u16 = 1; 478 479 const current_dir_list_win = win.child(.{ 480 .x_off = 0, 481 .y_off = top_div + 1, 482 .width = if (config.preview_file) win.width / 2 else win.width, 483 .height = win.height - (abs_file_path.height + file_information.height + top_div + bottom_div), 484 }); 485 486 const win_height = current_dir_list_win.height; 487 var offset: usize = 0; 488 489 while (list.all()[offset..].len > win_height and 490 list.selected >= offset + (win_height / 2)) 491 { 492 offset += 1; 493 } 494 495 for (list.all()[offset..], 0..) |item, i| { 496 const selected = list.selected - offset; 497 const is_selected = selected == i; 498 499 if (i > win_height) continue; 500 501 const w = current_dir_list_win.child(.{ .y_off = @intCast(i), .height = 1 }); 502 w.fill(vaxis.Cell{ 503 .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 504 }); 505 506 _ = w.print(&.{ 507 .{ 508 .text = item.name, 509 .style = if (is_selected) config.styles.selected_list_item else config.styles.list_item, 510 }, 511 }, .{}); 512 } 513 514 return win_height; 515} 516 517fn drawAbsFilePath( 518 self: *Drawer, 519 app: *App, 520 win: vaxis.Window, 521) error{ OutOfMemory, NoSpaceLeft }!vaxis.Window { 522 const abs_file_path_bar = win.child(.{ 523 .x_off = 0, 524 .y_off = 0, 525 .width = win.width, 526 .height = top_div, 527 }); 528 529 const branch_alloc = Git.getGitBranch(app.alloc, app.directories.dir) catch null; 530 defer if (branch_alloc) |b| app.alloc.free(b); 531 const branch = if (branch_alloc) |b| 532 try std.fmt.bufPrint( 533 &self.git_branch, 534 "{s}", 535 .{std.mem.trim(u8, b, " \n\r")}, 536 ) 537 else 538 ""; 539 540 _ = abs_file_path_bar.print(&.{ 541 vaxis.Segment{ .text = app.directories.fullPath(".") catch { 542 const message = try std.fmt.allocPrint(app.alloc, "Can not display absolute file path - unable to retrieve full path.", .{}); 543 defer app.alloc.free(message); 544 app.notification.write(message, .err) catch {}; 545 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 546 return abs_file_path_bar; 547 } }, 548 vaxis.Segment{ .text = if (branch_alloc != null) " on " else "" }, 549 vaxis.Segment{ .text = branch, .style = config.styles.git_branch }, 550 }, .{}); 551 552 return abs_file_path_bar; 553} 554 555fn drawUserInput( 556 current_state: App.State, 557 text_input: *vaxis.widgets.TextInput, 558 input: []const u8, 559 win: vaxis.Window, 560) void { 561 const user_input_win = win.child(.{ 562 .x_off = 0, 563 .y_off = top_div, 564 .width = win.width / 2, 565 .height = info_div, 566 }); 567 user_input_win.fill(.{ .style = config.styles.text_input }); 568 569 switch (current_state) { 570 .fuzzy, .new_file, .new_dir, .rename, .change_dir, .command => { 571 text_input.drawWithStyle(user_input_win, config.styles.text_input); 572 }, 573 .normal => { 574 if (text_input.buf.realLength() > 0) { 575 text_input.drawWithStyle( 576 user_input_win, 577 if (std.mem.eql(u8, input, ":UnsupportedCommand")) 578 config.styles.text_input_err 579 else 580 config.styles.text_input, 581 ); 582 } 583 584 win.hideCursor(); 585 }, 586 .help_menu => { 587 win.hideCursor(); 588 }, 589 } 590} 591 592fn drawNotification( 593 notification: *Notification, 594 file_logger: *?FileLogger, 595 win: vaxis.Window, 596) void { 597 if (notification.len() == 0) return; 598 if (notification.clearIfEnded()) return; 599 600 const width_padding = 4; 601 const height_padding = 3; 602 const screen_pos_padding = 10; 603 604 const max_width = win.width / 4; 605 const width = notification.len() + width_padding; 606 const calculated_width = if (width > max_width) max_width else width; 607 const height = (std.math.divCeil(usize, notification.len(), calculated_width) catch { 608 if (file_logger.*) |fl| fl.write("Unable to display notification - failed to calculate notification height.", .err) catch {}; 609 return; 610 }) + height_padding; 611 612 const notification_win = win.child(.{ 613 .x_off = @intCast(win.width - (calculated_width + screen_pos_padding)), 614 .y_off = top_div, 615 .width = @intCast(calculated_width), 616 .height = @intCast(height), 617 .border = .{ .where = .all, .style = switch (notification.style) { 618 .info => config.styles.notification.info, 619 .err => config.styles.notification.err, 620 .warn => config.styles.notification.warn, 621 } }, 622 }); 623 624 notification_win.fill(.{ .style = config.styles.notification.box }); 625 _ = notification_win.printSegment(.{ 626 .text = notification.slice(), 627 .style = config.styles.notification.box, 628 }, .{ .wrap = .word }); 629} 630 631fn processImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 632 app.images.cache.put(path, .{ .path = path, .status = .processing }) catch { 633 const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 634 defer app.alloc.free(message); 635 app.notification.write(message, .err) catch {}; 636 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 637 return error.Unsupported; 638 }; 639 640 const load_img_thread = std.Thread.spawn(.{}, loadImage, .{ 641 app, 642 path, 643 }) catch return error.Unsupported; 644 load_img_thread.detach(); 645} 646 647fn loadImage(app: *App, path: []const u8) error{ Unsupported, OutOfMemory }!void { 648 const data = vaxis.zigimg.Image.fromFilePath(app.alloc, path) catch { 649 const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to read image from path.", .{path}); 650 defer app.alloc.free(message); 651 app.notification.write(message, .err) catch {}; 652 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 653 return error.Unsupported; 654 }; 655 656 app.images.mutex.lock(); 657 if (app.images.cache.getPtr(path)) |entry| { 658 entry.status = .ready; 659 entry.data = data; 660 } else { 661 const message = try std.fmt.allocPrint(app.alloc, "Failed to load image '{s}' - error occurred while attempting to add image to cache.", .{path}); 662 defer app.alloc.free(message); 663 app.notification.write(message, .err) catch {}; 664 if (app.file_logger) |file_logger| file_logger.write(message, .err) catch {}; 665 return error.Unsupported; 666 } 667 app.images.mutex.unlock(); 668 669 app.loop.postEvent(.image_ready); 670}