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