diff --git a/build.zig b/build.zig index 36fa3e8..9991c1d 100644 --- a/build.zig +++ b/build.zig @@ -2,7 +2,7 @@ const std = @import("std"); const builtin = @import("builtin"); const min_zig_string = "0.12.0"; -const version = std.SemanticVersion{ .major = 0, .minor = 2, .patch = 0 }; +const version = std.SemanticVersion{ .major = 0, .minor = 3, .patch = 0 }; const targets: []const std.Target.Query = &.{ .{ .cpu_arch = .aarch64, .os_tag = .macos }, diff --git a/build.zig.zon b/build.zig.zon index 154a9b2..3a68746 100644 --- a/build.zig.zon +++ b/build.zig.zon @@ -1,6 +1,6 @@ .{ .name = "zfe", - .version = "0.2.0", + .version = "0.3.0", .minimum_zig_version = "0.12.0", diff --git a/src/app.zig b/src/app.zig new file mode 100644 index 0000000..f3fecea --- /dev/null +++ b/src/app.zig @@ -0,0 +1,708 @@ +const std = @import("std"); +const builtin = @import("builtin"); + +const Logger = @import("./log.zig").Logger; +const environment = @import("./environment.zig"); +const Notification = @import("./notification.zig"); +const config = &@import("./config.zig").config; +const List = @import("./list.zig").List; +const Directories = @import("./directories.zig"); + +const zuid = @import("zuid"); + +const vaxis = @import("vaxis"); +const TextInput = @import("vaxis").widgets.TextInput; +const Cell = vaxis.Cell; +const Key = vaxis.Key; + +pub const State = enum { + normal, + fuzzy, + new_dir, + new_file, + rename, +}; + +const Effect = enum { + exit, + default, +}; + +pub const Action = union(enum) { + delete: struct { + /// Allocated. + old_path: []const u8, + /// Allocated. + tmp_path: []const u8, + }, + rename: struct { + /// Allocated. + old_path: []const u8, + /// Allocated. + new_path: []const u8, + }, +}; + +const Event = union(enum) { + key_press: vaxis.Key, + winsize: vaxis.Winsize, +}; + +const top_div = 1; +const info_div = 1; +const bottom_div = 1; + +const App = @This(); + +alloc: std.mem.Allocator, +vx: vaxis.Vaxis = undefined, +logger: Logger, +state: State = State.normal, +actions: std.ArrayList(Action), + +// Used to detect whether to re-render an image. +current_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, +current_item_path: []u8 = "", +last_item_path_buf: [std.fs.max_path_bytes]u8 = undefined, +last_item_path: []u8 = "", + +directories: Directories, +notification: Notification, + +text_input: TextInput, +text_input_buf: [std.fs.max_path_bytes]u8 = undefined, + +image: ?vaxis.Image = null, +last_known_height: usize, + +pub fn init(alloc: std.mem.Allocator) !App { + var vx = try vaxis.init(alloc, .{ + .kitty_keyboard_flags = .{ + .report_text = false, + .disambiguate = false, + .report_events = false, + .report_alternate_keys = false, + .report_all_as_ctl_seqs = false, + }, + }); + + return App{ + .alloc = alloc, + .vx = vx, + .directories = try Directories.init(alloc), + .logger = Logger{}, + .text_input = TextInput.init(alloc, &vx.unicode), + .notification = Notification{}, + .actions = std.ArrayList(Action).init(alloc), + .last_known_height = vx.window().height, + }; +} + +pub fn deinit(self: *App) void { + for (self.actions.items) |action| { + switch (action) { + .delete => |a| { + self.alloc.free(a.tmp_path); + self.alloc.free(a.old_path); + }, + .rename => |a| { + self.alloc.free(a.new_path); + self.alloc.free(a.old_path); + }, + } + } + + self.directories.deinit(); + self.text_input.deinit(); + self.actions.deinit(); + self.vx.deinit(self.alloc); +} + +pub fn run(self: *App) !void { + self.logger.init(); + self.notification.init(); + + try self.directories.populate_entries(""); + + var loop: vaxis.Loop(Event) = .{ .vaxis = &self.vx }; + try loop.run(); + defer loop.stop(); + + try self.vx.enterAltScreen(); + try self.vx.queryTerminal(); + + while (true) { + self.notification.reset(); + const event = loop.nextEvent(); + + switch (self.state) { + .normal => { + switch (try self.handle_normal_event(event, &loop)) { + .exit => return, + .default => {}, + } + }, + .fuzzy, .new_file, .new_dir, .rename => { + switch (try self.handle_input_event(event)) { + .exit => return, + .default => {}, + } + }, + } + + try self.draw(); + } +} + +pub fn inputToSlice(self: *App) []const u8 { + self.text_input.cursor_idx = self.text_input.grapheme_count; + return self.text_input.sliceToCursor(&self.text_input_buf); +} + +pub fn handle_normal_event(self: *App, event: Event, loop: *vaxis.Loop(Event)) !Effect { + switch (event) { + .key_press => |key| { + if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { + return .exit; + } + + switch (key.codepoint) { + '-', 'h', Key.left => { + self.text_input.clearAndFree(); + + if (self.directories.dir.openDir("../", .{ .iterate = true })) |dir| { + self.directories.dir = dir; + + self.directories.cleanup(); + const fuzzy = self.inputToSlice(); + self.directories.populate_entries(fuzzy) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + } else |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + } + }, + Key.enter, 'l', Key.right => { + const entry = try self.directories.get_selected(); + + switch (entry.kind) { + .directory => { + self.text_input.clearAndFree(); + + if (self.directories.dir.openDir(entry.name, .{ .iterate = true })) |dir| { + self.directories.dir = dir; + + self.directories.cleanup(); + const fuzzy = self.inputToSlice(); + self.directories.populate_entries(fuzzy) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + } else |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + } + }, + .file => { + if (environment.get_editor()) |editor| { + try self.vx.exitAltScreen(); + loop.stop(); + + environment.open_file(self.alloc, self.directories.dir, entry.name, editor) catch { + try self.notification.write_err(.UnableToOpenFile); + }; + + try loop.run(); + try self.vx.enterAltScreen(); + self.vx.queueRefresh(); + } else { + try self.notification.write_err(.EditorNotSet); + } + }, + else => {}, + } + }, + 'j', Key.down => { + self.directories.entries.next(self.last_known_height); + }, + 'k', Key.up => { + self.directories.entries.previous(self.last_known_height); + }, + 'G' => { + self.directories.entries.select_last(self.last_known_height); + }, + 'g' => { + self.directories.entries.select_first(); + }, + 'D' => { + const entry = try self.directories.get_selected(); + + var old_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const old_path = try self.alloc.dupe(u8, try self.directories.dir.realpath(entry.name, &old_path_buf)); + var tmp_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const tmp_path = try self.alloc.dupe(u8, try std.fmt.bufPrint(&tmp_path_buf, "/tmp/{s}-{s}", .{ entry.name, zuid.new.v4().toString() })); + + try self.notification.write("Deleting item...", .info); + if (self.directories.dir.rename(entry.name, tmp_path)) { + try self.actions.append(.{ .delete = .{ .old_path = old_path, .tmp_path = tmp_path } }); + try self.notification.write("Deleted item.", .info); + + self.directories.cleanup(); + const fuzzy = self.inputToSlice(); + self.directories.populate_entries(fuzzy) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + } else |_| { + try self.notification.write_err(.UnableToDeleteItem); + } + }, + 'd' => { + self.state = .new_dir; + }, + '%' => { + self.state = .new_file; + }, + 'u' => { + if (self.actions.items.len > 0) { + const action = self.actions.pop(); + switch (action) { + .delete => |a| { + // TODO: Will overwrite an item if it has the same name. + if (self.directories.dir.rename(a.tmp_path, a.old_path)) { + defer self.alloc.free(a.tmp_path); + defer self.alloc.free(a.old_path); + + self.directories.cleanup(); + const fuzzy = self.inputToSlice(); + self.directories.populate_entries(fuzzy) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + try self.notification.write("Restored deleted item.", .info); + } else |_| { + try self.notification.write_err(.UnableToUndo); + } + }, + .rename => |a| { + // TODO: Will overwrite an item if it has the same name. + if (self.directories.dir.rename(a.new_path, a.old_path)) { + defer self.alloc.free(a.new_path); + defer self.alloc.free(a.old_path); + + self.directories.cleanup(); + const fuzzy = self.inputToSlice(); + self.directories.populate_entries(fuzzy) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + try self.notification.write("Restored previous item name.", .info); + } else |_| { + try self.notification.write_err(.UnableToUndo); + } + }, + } + } else { + try self.notification.write("Nothing to undo.", .info); + } + }, + '/' => { + self.state = State.fuzzy; + }, + 'R' => { + self.state = State.rename; + + const entry = try self.directories.get_selected(); + self.text_input.insertSliceAtCursor(entry.name) catch { + self.state = State.normal; + try self.notification.write_err(.UnableToRename); + }; + }, + else => { + // log.debug("codepoint: {d}\n", .{key.codepoint}); + }, + } + }, + .winsize => |ws| { + try self.vx.resize(self.alloc, ws); + }, + } + + return .default; +} + +pub fn handle_input_event(self: *App, event: Event) !Effect { + switch (event) { + .key_press => |key| { + if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { + return .exit; + } + + switch (key.codepoint) { + Key.escape => { + switch (self.state) { + .new_dir => {}, + .new_file => {}, + .rename => {}, + .fuzzy => { + self.directories.cleanup(); + self.directories.populate_entries("") catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + }, + else => {}, + } + + self.text_input.clearAndFree(); + self.state = State.normal; + }, + Key.enter => { + switch (self.state) { + .new_dir => { + const dir = self.inputToSlice(); + if (self.directories.dir.makeDir(dir)) { + self.directories.cleanup(); + self.directories.populate_entries("") catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + } else |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + error.PathAlreadyExists => try self.notification.write_err(.ItemAlreadyExists), + else => try self.notification.write_err(.UnknownError), + } + } + self.text_input.clearAndFree(); + }, + .new_file => { + const file = self.inputToSlice(); + if (environment.file_exists(self.directories.dir, file)) { + try self.notification.write_err(.ItemAlreadyExists); + } else { + if (self.directories.dir.createFile(file, .{})) |f| { + f.close(); + self.directories.cleanup(); + self.directories.populate_entries("") catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + } else |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + } + } + self.text_input.clearAndFree(); + }, + .rename => { + var dir_prefix_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_prefix = try self.directories.dir.realpath(".", &dir_prefix_buf); + + const old = try self.directories.get_selected(); + const new = self.inputToSlice(); + + if (environment.file_exists(self.directories.dir, new)) { + try self.notification.write_err(.ItemAlreadyExists); + } else { + self.directories.dir.rename(old.name, new) catch |err| switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + error.PathAlreadyExists => try self.notification.write_err(.ItemAlreadyExists), + else => try self.notification.write_err(.UnknownError), + }; + try self.actions.append(.{ .rename = .{ + .old_path = try std.fs.path.join(self.alloc, &.{ dir_prefix, old.name }), + .new_path = try std.fs.path.join(self.alloc, &.{ dir_prefix, new }), + } }); + + self.directories.cleanup(); + self.directories.populate_entries("") catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + } + self.text_input.clearAndFree(); + }, + .fuzzy => {}, + else => {}, + } + self.state = State.normal; + }, + else => { + try self.text_input.update(.{ .key_press = key }); + + switch (self.state) { + .new_dir => {}, + .new_file => {}, + .rename => {}, + .fuzzy => { + self.directories.cleanup(); + const fuzzy = self.inputToSlice(); + self.directories.populate_entries(fuzzy) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + }; + }, + else => {}, + } + }, + } + }, + .winsize => |ws| { + try self.vx.resize(self.alloc, ws); + }, + } + + return .default; +} + +pub fn draw(self: *App) !void { + const win = self.vx.window(); + win.clear(); + + const abs_file_path_bar = try self.draw_abs_file_path(win); + var file_info_buf: [1024]u8 = undefined; + const file_info_bar = try self.draw_file_info(win, &file_info_buf); + try self.draw_current_dir_list(win, abs_file_path_bar, file_info_bar); + + if (config.preview_file == true) { + var file_name_buf: [std.fs.MAX_NAME_BYTES + 2]u8 = undefined; + const file_name_bar = try self.draw_file_name(win, &file_name_buf); + try self.draw_preview(win, file_name_bar); + } + + try self.draw_info(win); + + try self.vx.render(); +} + +fn draw_file_name(self: *App, win: vaxis.Window, buf: []u8) !vaxis.Window { + const file_name_bar = win.child(.{ + .x_off = win.width / 2, + .y_off = 0, + .width = .{ .limit = win.width }, + .height = .{ .limit = top_div }, + }); + + if (self.directories.get_selected()) |entry| { + const file_name = try std.fmt.bufPrint(buf, "[{s}]", .{entry.name}); + _ = try file_name_bar.print(&.{vaxis.Segment{ + .text = file_name, + .style = config.styles.file_name, + }}, .{}); + } else |_| {} + + return file_name_bar; +} + +fn draw_preview(self: *App, win: vaxis.Window, file_name_win: vaxis.Window) !void { + const preview_win = win.child(.{ + .x_off = win.width / 2, + .y_off = top_div + 1, + .width = .{ .limit = win.width / 2 }, + .height = .{ .limit = win.height - (file_name_win.height + top_div + bottom_div) }, + }); + + // Populate preview bar + if (self.directories.entries.all().len > 0 and config.preview_file == true) { + const entry = try self.directories.get_selected(); + + @memcpy(&self.last_item_path_buf, &self.current_item_path_buf); + self.last_item_path = self.last_item_path_buf[0..self.current_item_path.len]; + self.current_item_path = try std.fmt.bufPrint(&self.current_item_path_buf, "{s}/{s}", .{ try self.directories.full_path("."), entry.name }); + + switch (entry.kind) { + .directory => { + self.directories.cleanup_sub(); + if (self.directories.populate_sub_entries(entry.name)) { + try self.directories.write_sub_entries(preview_win, config.styles.list_item); + } else |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + } + }, + .file => file: { + var file = self.directories.dir.openFile(entry.name, .{ .mode = .read_only }) catch |err| { + switch (err) { + error.AccessDenied => try self.notification.write_err(.PermissionDenied), + else => try self.notification.write_err(.UnknownError), + } + + _ = try preview_win.print(&.{ + .{ + .text = "No preview available.", + }, + }, .{}); + + break :file; + }; + defer file.close(); + const bytes = try file.readAll(&self.directories.file_contents); + + // Handle image. + if (config.show_images == true) unsupported_terminal: { + const supported: [1][]const u8 = .{".png"}; + + for (supported) |ext| { + if (std.mem.eql(u8, std.fs.path.extension(entry.name), ext)) { + if (!std.mem.eql(u8, self.last_item_path, self.current_item_path)) { + if (self.vx.loadImage(self.alloc, .{ .path = self.current_item_path })) |img| { + self.image = img; + } else |_| { + self.image = null; + break :unsupported_terminal; + } + } + + if (self.image) |img| { + try img.draw(preview_win, .{ .scale = .fit }); + } + + break :file; + } else { + // Free any image we might have already. + if (self.image) |img| { + self.vx.freeImage(img.id); + } + } + } + } + + // Handle utf-8. + if (std.unicode.utf8ValidateSlice(self.directories.file_contents[0..bytes])) { + _ = try preview_win.print(&.{ + .{ + .text = self.directories.file_contents[0..bytes], + }, + }, .{}); + break :file; + } + + // Fallback to no preview. + _ = try preview_win.print(&.{ + .{ + .text = "No preview available.", + }, + }, .{}); + }, + else => { + _ = try preview_win.print(&.{vaxis.Segment{ .text = self.current_item_path }}, .{}); + }, + } + } +} + +fn draw_file_info(self: *App, win: vaxis.Window, file_info_buf: []u8) !vaxis.Window { + const file_info = try std.fmt.bufPrint(file_info_buf, "{d}/{d} {s} {s}", .{ + self.directories.entries.selected + 1, + self.directories.entries.items.items.len, + std.fs.path.extension(if (self.directories.get_selected()) |entry| entry.name else |_| ""), + std.fmt.fmtIntSizeDec((try self.directories.dir.metadata()).size()), + }); + + const file_info_win = win.child(.{ + .x_off = 0, + .y_off = win.height - bottom_div, + .width = if (config.preview_file) .{ .limit = win.width / 2 } else .{ .limit = win.width }, + .height = .{ .limit = bottom_div }, + }); + file_info_win.fill(vaxis.Cell{ .style = config.styles.file_information }); + _ = try file_info_win.print(&.{vaxis.Segment{ .text = file_info, .style = config.styles.file_information }}, .{}); + + return file_info_win; +} + +fn draw_current_dir_list(self: *App, win: vaxis.Window, abs_file_path: vaxis.Window, file_information: vaxis.Window) !void { + const current_dir_list_win = win.child(.{ + .x_off = 0, + .y_off = top_div + 1, + .width = if (config.preview_file) .{ .limit = win.width / 2 } else .{ .limit = win.width }, + .height = .{ .limit = win.height - (abs_file_path.height + file_information.height + top_div + bottom_div) }, + }); + try self.directories.write_entries(current_dir_list_win, config.styles.selected_list_item, config.styles.list_item, null); + + self.last_known_height = current_dir_list_win.height; +} + +fn draw_abs_file_path(self: *App, win: vaxis.Window) !vaxis.Window { + const abs_file_path_bar = win.child(.{ + .x_off = 0, + .y_off = 0, + .width = .{ .limit = win.width }, + .height = .{ .limit = top_div }, + }); + _ = try abs_file_path_bar.print(&.{vaxis.Segment{ .text = try self.directories.full_path(".") }}, .{}); + + return abs_file_path_bar; +} + +fn draw_info(self: *App, win: vaxis.Window) !void { + const info_win = win.child(.{ + .x_off = 0, + .y_off = top_div, + .width = .{ .limit = win.width }, + .height = .{ .limit = info_div }, + }); + + // Display info box. + if (self.notification.len > 0) { + if (self.text_input.grapheme_count > 0) { + self.text_input.clearAndFree(); + } + + _ = try info_win.print(&.{ + .{ + .text = self.notification.slice(), + .style = switch (self.notification.style) { + .info => config.styles.info_bar, + .err => config.styles.error_bar, + }, + }, + }, .{}); + } + + // Display user input box. + switch (self.state) { + .fuzzy, .new_file, .new_dir, .rename => { + self.notification.reset(); + self.text_input.draw(info_win); + }, + .normal => { + if (self.text_input.grapheme_count > 0) { + self.text_input.draw(info_win); + } + + win.hideCursor(); + }, + } +} diff --git a/src/view.zig b/src/directories.zig similarity index 97% rename from src/view.zig rename to src/directories.zig index ca7953a..6c0f446 100644 --- a/src/view.zig +++ b/src/directories.zig @@ -2,7 +2,6 @@ const std = @import("std"); const List = @import("./list.zig").List; const config = &@import("./config.zig").config; const vaxis = @import("vaxis"); - const fuzzig = @import("fuzzig"); const Self = @This(); @@ -19,7 +18,6 @@ pub fn init(alloc: std.mem.Allocator) !Self { return Self{ .alloc = alloc, .dir = try std.fs.cwd().openDir(".", .{ .iterate = true }), - .file_contents = undefined, .entries = List(std.fs.Dir.Entry).init(alloc), .sub_entries = List([]const u8).init(alloc), .searcher = try fuzzig.Ascii.init( @@ -42,6 +40,10 @@ pub fn deinit(self: *Self) void { self.searcher.deinit(); } +pub fn get_selected(self: *Self) !std.fs.Dir.Entry { + return self.entries.get_selected(); +} + pub fn full_path(self: *Self, relative_path: []const u8) ![]const u8 { return try self.dir.realpath(relative_path, &self.path_buf); } diff --git a/src/list.zig b/src/list.zig index fc0866c..e514871 100644 --- a/src/list.zig +++ b/src/list.zig @@ -41,6 +41,14 @@ pub fn List(comptime T: type) type { return self.items.items[index]; } + pub fn get_selected(self: *Self) !T { + if (self.items.items.len > 0) { + return self.items.items[self.selected]; + } + + return error.EmptyList; + } + pub fn all(self: *Self) []T { return self.items.items; } diff --git a/src/log.zig b/src/log.zig index 0712d4c..8cc927c 100644 --- a/src/log.zig +++ b/src/log.zig @@ -1,6 +1,6 @@ const std = @import("std"); -const Logger = struct { +pub const Logger = struct { const Self = @This(); const BufferedFileWriter = std.io.BufferedWriter(4096, std.fs.File.Writer); diff --git a/src/main.zig b/src/main.zig index 3ccc5fa..d925215 100644 --- a/src/main.zig +++ b/src/main.zig @@ -1,62 +1,19 @@ const std = @import("std"); const builtin = @import("builtin"); +const App = @import("app.zig"); + const log = &@import("./log.zig").log; -const environment = @import("./environment.zig"); -const InfoBar = @import("./info_bar.zig"); const config = &@import("./config.zig").config; -const List = @import("./list.zig").List; -const View = @import("./view.zig"); - -const zuid = @import("zuid"); - const vaxis = @import("vaxis"); -const TextInput = @import("vaxis").widgets.TextInput; -const Cell = vaxis.Cell; -const Key = vaxis.Key; - -const State = enum { - normal, - fuzzy, - new_dir, - new_file, - rename, -}; - -const Action = union(enum) { - delete: struct { - /// Allocated. - old_path: []const u8, - /// Allocated. - tmp_path: []const u8, - }, - rename: struct { - /// Allocated. - old_path: []const u8, - /// Allocated. - new_path: []const u8, - }, -}; -const Event = union(enum) { - key_press: vaxis.Key, - winsize: vaxis.Winsize, -}; - -var vx: vaxis.Vaxis = undefined; +var app: App = undefined; pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const alloc = gpa.allocator(); - var view = try View.init(alloc); - defer view.deinit(); - - var file_metadata = try view.dir.metadata(); - - log.init(); - config.parse(alloc) catch |err| switch (err) { error.ConfigNotFound => {}, error.MissingConfigHomeEnvironmentVariable => { @@ -73,630 +30,13 @@ pub fn main() !void { }, }; - // TODO: Figure out size. - var file_buf: [4096]u8 = undefined; - - var current_item_path: []u8 = ""; - var last_item_path: []u8 = ""; - var image: ?vaxis.Image = null; - var path: [std.fs.max_path_bytes]u8 = undefined; - var last_path: [std.fs.max_path_bytes]u8 = undefined; - - try view.populate_entries(""); - - vx = try vaxis.init(alloc, .{ .kitty_keyboard_flags = .{ - .report_text = false, - .disambiguate = false, - .report_events = false, - .report_alternate_keys = false, - .report_all_as_ctl_seqs = false, - } }); - defer vx.deinit(alloc); - - var loop: vaxis.Loop(Event) = .{ .vaxis = &vx }; - try loop.run(); - defer loop.stop(); - - try vx.enterAltScreen(); - try vx.queryTerminal(); - - var text_input = TextInput.init(alloc, &vx.unicode); - defer text_input.deinit(); - - var info = InfoBar{}; - info.init(); - - var actions = std.ArrayList(Action).init(alloc); - defer actions.deinit(); - - var state = State.normal; - var last_pressed: ?vaxis.Key = null; - var last_known_height: usize = vx.window().height; - var input_buf: [std.fs.max_path_bytes]u8 = undefined; - while (true) { - info.reset(); - const event = loop.nextEvent(); - - switch (event) { - .winsize => |ws| { - try vx.resize(alloc, ws); - }, - else => {}, - } - const win = vx.window(); - win.clear(); - - const top_div = 1; - const info_div = 1; - const bottom_div = 1; - - const info_bar = win.child(.{ - .x_off = 0, - .y_off = top_div, - .width = .{ .limit = win.width }, - .height = .{ .limit = info_div }, - }); - - switch (state) { - .normal => { - switch (event) { - .key_press => |key| { - if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { - break; - } - - switch (key.codepoint) { - '-', 'h', Key.left => { - text_input.clearAndFree(); - - if (view.dir.openDir("../", .{ .iterate = true })) |dir| { - view.dir = dir; - - view.cleanup(); - const fuzzy = text_input.sliceToCursor(&input_buf); - view.populate_entries(fuzzy) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - } else |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - } - last_pressed = null; - }, - Key.enter, 'l', Key.right => { - const entry = view.entries.get(view.entries.selected) catch continue; - - switch (entry.kind) { - .directory => { - text_input.clearAndFree(); - - if (view.dir.openDir(entry.name, .{ .iterate = true })) |dir| { - view.dir = dir; - - view.cleanup(); - const fuzzy = text_input.sliceToCursor(&input_buf); - view.populate_entries(fuzzy) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - } else |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - } - last_pressed = null; - }, - .file => { - if (environment.get_editor()) |editor| { - try vx.exitAltScreen(); - loop.stop(); - - environment.open_file(alloc, view.dir, entry.name, editor) catch { - try info.write_err(.UnableToOpenFile); - }; - - try loop.run(); - try vx.enterAltScreen(); - vx.queueRefresh(); - } else { - try info.write_err(.EditorNotSet); - } - }, - else => {}, - } - }, - 'j', Key.down => { - view.entries.next(last_known_height); - last_pressed = null; - }, - 'k', Key.up => { - view.entries.previous(last_known_height); - last_pressed = null; - }, - 'G' => { - view.entries.select_last(last_known_height); - last_pressed = null; - }, - 'g' => { - if (last_pressed) |k| { - if (k.codepoint == 'g') { - view.entries.select_first(); - last_pressed = null; - } - last_pressed = null; - } else { - last_pressed = key; - } - }, - 'D' => { - const entry = view.entries.get(view.entries.selected) catch continue; - - var old_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const old_path = try alloc.dupe(u8, try view.dir.realpath(entry.name, &old_path_buf)); - var tmp_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const tmp_path = try alloc.dupe(u8, try std.fmt.bufPrint(&tmp_path_buf, "/tmp/{s}-{s}", .{ entry.name, zuid.new.v4().toString() })); - - try info.write("Deleting item...", .info); - if (view.dir.rename(entry.name, tmp_path)) { - try actions.append(.{ .delete = .{ .old_path = old_path, .tmp_path = tmp_path } }); - try info.write("Deleted item.", .info); - - view.cleanup(); - const fuzzy = text_input.sliceToCursor(&input_buf); - view.populate_entries(fuzzy) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - } else |_| { - try info.write_err(.UnableToDeleteItem); - } - - last_pressed = null; - }, - 'd' => { - state = .new_dir; - last_pressed = null; - }, - '%' => { - state = .new_file; - last_pressed = null; - }, - 'u' => { - if (actions.items.len > 0) { - const action = actions.pop(); - switch (action) { - .delete => |a| { - // TODO: Will overwrite an item if it has the same name. - if (view.dir.rename(a.tmp_path, a.old_path)) { - defer alloc.free(a.tmp_path); - defer alloc.free(a.old_path); - - view.cleanup(); - const fuzzy = text_input.sliceToCursor(&input_buf); - view.populate_entries(fuzzy) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - try info.write("Restored deleted item.", .info); - } else |_| { - try info.write_err(.UnableToUndo); - } - }, - .rename => |a| { - // TODO: Will overwrite an item if it has the same name. - if (view.dir.rename(a.new_path, a.old_path)) { - defer alloc.free(a.new_path); - defer alloc.free(a.old_path); - - view.cleanup(); - const fuzzy = text_input.sliceToCursor(&input_buf); - view.populate_entries(fuzzy) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - try info.write("Restored previous item name.", .info); - } else |_| { - try info.write_err(.UnableToUndo); - } - }, - } - } else { - try info.write("Nothing to undo.", .info); - } - last_pressed = null; - }, - '/' => { - state = State.fuzzy; - last_pressed = null; - }, - 'R' => { - state = State.rename; - - const entry = view.entries.get(view.entries.selected) catch continue; - text_input.insertSliceAtCursor(entry.name) catch { - state = State.normal; - try info.write_err(.UnableToRename); - }; - - last_pressed = null; - }, - else => { - // log.debug("codepoint: {d}\n", .{key.codepoint}); - }, - } - }, - else => {}, - } - }, - .fuzzy, .new_file, .new_dir, .rename => { - switch (event) { - .key_press => |key| { - if ((key.codepoint == 'c' and key.mods.ctrl) or key.codepoint == 'q') { - break; - } - - switch (key.codepoint) { - Key.escape => { - text_input.clearAndFree(); - state = State.normal; - - switch (state) { - .new_dir => {}, - .new_file => {}, - .rename => {}, - .fuzzy => { - view.cleanup(); - view.populate_entries("") catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - }, - else => {}, - } - }, - Key.enter => { - switch (state) { - .new_dir => { - const dir = text_input.sliceToCursor(&input_buf); - if (view.dir.makeDir(dir)) { - view.cleanup(); - view.populate_entries("") catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - } else |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - error.PathAlreadyExists => try info.write_err(.ItemAlreadyExists), - else => try info.write_err(.UnknownError), - } - } - text_input.clearAndFree(); - }, - .new_file => { - const file = text_input.sliceToCursor(&input_buf); - if (environment.file_exists(view.dir, file)) { - try info.write_err(.ItemAlreadyExists); - } else { - if (view.dir.createFile(file, .{})) |f| { - f.close(); - view.cleanup(); - view.populate_entries("") catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - } else |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - } - } - text_input.clearAndFree(); - }, - .rename => { - const original = view.entries.get(view.entries.selected) catch continue; - text_input.cursor_idx = text_input.grapheme_count; - const new = text_input.sliceToCursor(&input_buf); - - if (environment.file_exists(view.dir, new)) { - try info.write_err(.ItemAlreadyExists); - } else { - view.dir.rename(original.name, new) catch |err| switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - error.PathAlreadyExists => try info.write_err(.ItemAlreadyExists), - else => try info.write_err(.UnknownError), - }; - - try actions.append(.{ .rename = .{ - .old_path = try alloc.dupe(u8, original.name), - .new_path = try alloc.dupe(u8, new), - } }); - - view.cleanup(); - view.populate_entries("") catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - } - text_input.clearAndFree(); - }, - .fuzzy => {}, - else => {}, - } - state = State.normal; - }, - else => { - try text_input.update(.{ .key_press = key }); - - switch (state) { - .new_dir => {}, - .new_file => {}, - .rename => {}, - .fuzzy => { - view.cleanup(); - const fuzzy = text_input.sliceToCursor(&input_buf); - view.populate_entries(fuzzy) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - }; - }, - else => {}, - } - }, - } - }, - else => {}, - } - }, - } - - // -- Absolute file path bar - const abs_file_path_bar = win.child(.{ - .x_off = 0, - .y_off = 0, - .width = .{ .limit = win.width }, - .height = .{ .limit = top_div }, - }); - _ = try abs_file_path_bar.print(&.{vaxis.Segment{ .text = try view.full_path(".") }}, .{}); - // -- - - // -- File information bar - var file_information_buf: [1024]u8 = undefined; - const file_information = try std.fmt.bufPrint(&file_information_buf, "{d}/{d} {s} {s}", .{ - view.entries.selected + 1, - view.entries.items.items.len, - std.fs.path.extension(if (view.entries.get(view.entries.selected)) |entry| entry.name else |_| ""), - std.fmt.fmtIntSizeDec(file_metadata.size()), - }); - - const file_information_bar = win.child(.{ - .x_off = 0, - .y_off = win.height - bottom_div, - .width = if (config.preview_file) .{ .limit = win.width / 2 } else .{ .limit = win.width }, - .height = .{ .limit = bottom_div }, - }); - file_information_bar.fill(vaxis.Cell{ .style = config.styles.file_information }); - _ = try file_information_bar.print(&.{vaxis.Segment{ .text = file_information, .style = config.styles.file_information }}, .{}); - // -- - - // -- Key binding bar - const keybind_bar = win.child(.{ - .x_off = (win.width / 2) + 5, - .y_off = win.height - bottom_div, - .width = .{ .limit = win.width / 2 }, - .height = .{ .limit = bottom_div }, - }); - _ = try keybind_bar.print(&.{vaxis.Segment{ - .text = if (last_pressed) |key| key.text.? else "", - .style = if (config.preview_file) .{} else config.styles.file_information, - }}, .{}); - // -- - - // -- File preview bar - if (config.preview_file == true) { - const file_name_bar = win.child(.{ - .x_off = win.width / 2, - .y_off = 0, - .width = .{ .limit = win.width }, - .height = .{ .limit = top_div }, - }); - if (view.entries.get(view.entries.selected)) |entry| { - var file_name_buf: [std.fs.MAX_NAME_BYTES + 2]u8 = undefined; - const file_name = try std.fmt.bufPrint(&file_name_buf, "[{s}]", .{entry.name}); - _ = try file_name_bar.print(&.{vaxis.Segment{ - .text = file_name, - .style = config.styles.file_name, - }}, .{}); - } else |_| {} - - const preview_bar = win.child(.{ - .x_off = win.width / 2, - .y_off = top_div + 1, - .width = .{ .limit = win.width / 2 }, - .height = .{ .limit = win.height - (file_name_bar.height + keybind_bar.height + top_div + bottom_div) }, - }); - - // Populate preview bar - if (view.entries.all().len > 0 and config.preview_file == true) { - const entry = try view.entries.get(view.entries.selected); - - @memcpy(&last_path, &path); - last_item_path = last_path[0..current_item_path.len]; - current_item_path = try std.fmt.bufPrint(&path, "{s}/{s}", .{ try view.full_path("."), entry.name }); - - switch (entry.kind) { - .directory => { - view.cleanup_sub(); - if (view.populate_sub_entries(entry.name)) { - try view.write_sub_entries(preview_bar, config.styles.list_item); - } else |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - } - }, - .file => file: { - var file = view.dir.openFile(entry.name, .{ .mode = .read_only }) catch |err| { - switch (err) { - error.AccessDenied => try info.write_err(.PermissionDenied), - else => try info.write_err(.UnknownError), - } - - _ = try preview_bar.print(&.{ - .{ - .text = "No preview available.", - }, - }, .{}); - - break :file; - }; - defer file.close(); - const bytes = try file.readAll(&file_buf); - - // Handle image. - if (config.show_images == true) unsupported_terminal: { - const supported: [1][]const u8 = .{".png"}; - - for (supported) |ext| { - if (std.mem.eql(u8, std.fs.path.extension(entry.name), ext)) { - // Don't re-render preview if we haven't changed selection. - if (std.mem.eql(u8, last_item_path, current_item_path)) break :file; - - if (vx.loadImage(alloc, .{ .path = current_item_path })) |img| { - image = img; - } else |_| { - image = null; - break :unsupported_terminal; - } - - if (image) |img| { - try img.draw(preview_bar, .{ .scale = .fit }); - } - - break :file; - } else { - // Free any image we might have already. - if (image) |img| { - vx.freeImage(img.id); - } - } - } - } - - // Handle utf-8. - if (std.unicode.utf8ValidateSlice(file_buf[0..bytes])) { - _ = try preview_bar.print(&.{ - .{ - .text = file_buf[0..bytes], - }, - }, .{}); - break :file; - } - - // Fallback to no preview. - _ = try preview_bar.print(&.{ - .{ - .text = "No preview available.", - }, - }, .{}); - }, - else => { - _ = try preview_bar.print(&.{vaxis.Segment{ .text = current_item_path }}, .{}); - }, - } - } - } - // -- - - // -- Current directory bar - const current_dir_bar = win.child(.{ - .x_off = 0, - .y_off = top_div + 1, - .width = if (config.preview_file) .{ .limit = win.width / 2 } else .{ .limit = win.width }, - .height = .{ .limit = win.height - (abs_file_path_bar.height + file_information_bar.height + top_div + bottom_div) }, - }); - try view.write_entries(current_dir_bar, config.styles.selected_list_item, config.styles.list_item, null); - // -- - - // -- Info bar - if (info.len > 0) { - if (text_input.grapheme_count > 0) { - text_input.clearAndFree(); - } - - _ = try info_bar.print(&.{ - .{ - .text = info.slice(), - .style = switch (info.style) { - .info => config.styles.info_bar, - .err => config.styles.error_bar, - }, - }, - }, .{}); - } - - // Display search box. - switch (state) { - .fuzzy, .new_file, .new_dir, .rename => { - info.reset(); - text_input.draw(info_bar); - }, - .normal => { - if (text_input.grapheme_count > 0) { - text_input.draw(info_bar); - } - - win.hideCursor(); - }, - } - // -- - - try vx.render(); - - last_known_height = current_dir_bar.height; - } + app = try App.init(alloc); + defer app.deinit(); - for (actions.items) |action| { - switch (action) { - .delete => |a| { - alloc.free(a.tmp_path); - alloc.free(a.old_path); - }, - .rename => |a| { - alloc.free(a.new_path); - alloc.free(a.old_path); - }, - } - } + try app.run(); } pub fn panic(msg: []const u8, trace: ?*std.builtin.StackTrace, ret_addr: ?usize) noreturn { - vx.deinit(null); + app.vx.deinit(app.alloc); std.builtin.default_panic(msg, trace, ret_addr); } diff --git a/src/info_bar.zig b/src/notification.zig similarity index 76% rename from src/info_bar.zig rename to src/notification.zig index 40d37dd..bc902e0 100644 --- a/src/info_bar.zig +++ b/src/notification.zig @@ -1,8 +1,8 @@ const std = @import("std"); -const InfoBar = @This(); +const Self = @This(); -const InfoStyle = enum { +const Style = enum { err, info, }; @@ -20,21 +20,21 @@ const Error = enum { len: usize = 0, buf: [1024]u8 = undefined, -style: InfoStyle = InfoStyle.info, +style: Style = Style.info, fbs: std.io.FixedBufferStream([]u8) = undefined, -pub fn init(self: *InfoBar) void { +pub fn init(self: *Self) void { self.fbs = std.io.fixedBufferStream(&self.buf); } -pub fn write(self: *InfoBar, text: []const u8, style: InfoStyle) !void { +pub fn write(self: *Self, text: []const u8, style: Style) !void { self.fbs.reset(); self.len = try self.fbs.write(text); self.style = style; } -pub fn write_err(self: *InfoBar, err: Error) !void { +pub fn write_err(self: *Self, err: Error) !void { try switch (err) { .PermissionDenied => self.write("Permission denied.", .err), .UnknownError => self.write("An unknown error occurred.", .err), @@ -47,12 +47,12 @@ pub fn write_err(self: *InfoBar, err: Error) !void { }; } -pub fn reset(self: *InfoBar) void { +pub fn reset(self: *Self) void { self.fbs.reset(); self.len = 0; - self.style = InfoStyle.info; + self.style = Style.info; } -pub fn slice(self: *InfoBar) []const u8 { +pub fn slice(self: *Self) []const u8 { return self.buf[0..self.len]; }