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