地圖 (Jido) is a lightweight Unix TUI file explorer designed for speed and simplicity.
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}