diff --git a/README.md b/README.md index 5ad7a01..a85a18e 100644 --- a/README.md +++ b/README.md @@ -19,15 +19,15 @@ fancy-cat ### Commands -fancy-cat uses a modal interface similar to Neovim. There are two modes: view mode and command mode. To enter command mode you type `:` by default (this can be changed in the config file) +fancy-cat uses a modal interface similar to Neovim. There are two modes: view mode and command mode. To enter command mode you type `:` by default (this can be changed in the config file). -Documentation on the available commands can be found [here](./docs/commands.md) +Documentation on the available commands can be found [here](./docs/commands.md). ### Configuration -fancy-cat can be configured through a JSON config file located at `~/.config/fancy-cat/config.json`. The file is automatically created on the first run with default settings. +fancy-cat can be configured through a JSON configuration file located in one of several locations (primary `$XDG_CONFIG_HOME/fancy-cat/config.json`, fallback `$HOME/.config/fancy-cat/config.json`, legacy `$HOME/.fancy-cat`). An empty configuration file is automatically created in the primary or fallback location on the first run. -The default `config.json` and documentation can be found [here](./docs/config.md) +An example `config.json` and documentation can be found [here](./docs/config.md). ## Installation diff --git a/docs/config.md b/docs/config.md index d178b98..75082f9 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,16 +1,30 @@ # Configuration -On startup, fancy-cat looks for a configuration file at: +On startup, fancy-cat looks for a configuration file in the following locations: +**Primary** + +``` +$XDG_CONFIG_HOME/fancy-cat/config.json +``` + +**Fallback** + +``` +$HOME/.config/fancy-cat/config.json ``` -~/.config/fancy-cat/config.json + +**Legacy** + +``` +$HOME/.fancy-cat ``` -If no configuration file is found, fancy-cat creates an empty one. Since fancy-cat comes with sensible defaults, you only need to add the options you want to change. +If no configuration file is found in any of these locations, fancy-cat creates an empty configuration file in the primary or fallback location. ## Defaults -Below is an example configuration file that replicates the default settings. You can use it as a starting point for your customizations: +Because fancy-cat provides sensible defaults, you only need to specify the options you wish to override. Below is an example configuration file that replicates the default settings. You can use this example as a starting point for your customizations: ```json { @@ -29,7 +43,9 @@ Below is an example configuration file that replicates the default settings. You "full_screen": { "key": "f"}, "enter_command_mode": { "key": ":" }, "exit_command_mode": { "key": "escape" }, - "execute_command": { "key": "enter" } + "execute_command": { "key": "enter" }, + "history_back": { "key": "up" }, + "history_forward": { "key": "down" } }, "FileMonitor": { "enabled": true, @@ -46,7 +62,8 @@ Below is an example configuration file that replicates the default settings. You "scroll_step": 100.0, "retry_delay": 0.2, "timeout": 5.0, - "dpi": 96.0 + "dpi": 96.0, + "history": 1000 }, "StatusBar": { "enabled": true, @@ -77,6 +94,7 @@ The rest of this reference provides detailed explanations for each configuration - [File Monitor](#file-monitor) - [General](#general) - [Color](#color) + - [History](#history) - [Status Bar](#status-bar) - [Style](#style) - [Underline](#underline) @@ -110,6 +128,8 @@ The `KeyMap` section defines keybindings for various actions. | `enter_command_mode` | Enter command mode | | `exit_command_mode` | Exit command mode | | `execute_command` | Execute the entered command | +| `history_back` | Go back one command in history | +| `history_forward` | Go forward one command in history | ### Keybindings @@ -191,6 +211,7 @@ The `General` section includes various display and timing settings. | `dpi` | Float | Resolution used for 100% zoom calculation | | `retry_delay` | Float (seconds) | Delay before retrying to load a document or render a page | | `timeout` | Float (seconds) | Maximum time to keep retrying before giving up on loading a document or rendering a page | +| `history` | Integer | Maximum number of entries in command history | >[!TIP] >The color replacement feature works by replacing white and black with custom colors, which also affects the full color range depending on contrast. By default, `white` is set to black (`#000000`) and `black` is set to white (`#ffffff`). For a seamless look, try setting `white` to match your terminal’s background color and `black` to match the foreground (text) color. @@ -204,6 +225,30 @@ The following color formats are supported: | `"#RRGGBB"` or `"0xRRGGBB"` | `RR`, `GG`, and `BB` are two-digit hexadecimal values | | `{ "rgb": [R, G, B] }` | `R`, `G`, and `B` are integers between 0 and 255 | +### History + +To ensure persistence across sessions, fancy-cat saves its command history in one of the following locations: + +**Primary** + +``` +$XDG_STATE_HOME/fancy-cat/history +``` + +**Fallback** + +``` +$HOME/.local/state/fancy-cat/history +``` + +**Legacy** + +``` +$HOME/.fancy-cat_history +``` +>[!NOTE] +>The legacy location is only used if the [configuration file](#configuration) itself is located at `$HOME/.fancy-cat`. + --- ## Status Bar diff --git a/src/Context.zig b/src/Context.zig index ef48265..9595756 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -7,6 +7,7 @@ const Config = @import("config/Config.zig"); const DocumentHandler = @import("handlers/DocumentHandler.zig"); const Cache = @import("./Cache.zig"); const ReloadIndicatorTimer = @import("services/ReloadIndicatorTimer.zig"); +const History = @import("services/History.zig"); pub const panic = vaxis.panic_handler; @@ -38,6 +39,7 @@ pub const Context = struct { watcher_thread: ?std.Thread, config: *Config, current_mode: Mode, + history: History, reload_page: bool, cache: Cache, should_check_cache: bool, @@ -54,7 +56,7 @@ pub const Context = struct { const config = try allocator.create(Config); errdefer allocator.destroy(config); - config.* = try Config.init(allocator); + config.* = Config.init(allocator); errdefer config.deinit(); var document_handler = try DocumentHandler.init(allocator, path, initial_page, config); @@ -70,6 +72,7 @@ pub const Context = struct { const buf = try allocator.alloc(u8, 4096); const tty = try vaxis.Tty.init(buf); const reload_indicator_timer = ReloadIndicatorTimer.init(config); + const history = History.init(allocator, config); return .{ .allocator = allocator, @@ -85,6 +88,7 @@ pub const Context = struct { .watcher_thread = null, .config = config, .current_mode = undefined, + .history = history, .reload_page = true, .cache = Cache.init(allocator, config, vx, &tty), .should_check_cache = config.cache.enabled, @@ -107,14 +111,15 @@ pub const Context = struct { if (self.page_info_text.len > 0) self.allocator.free(self.page_info_text); - self.config.deinit(); - self.allocator.destroy(self.config); - self.arena.deinit(); self.reload_indicator_timer.deinit(); + self.history.deinit(); self.cache.deinit(); self.document_handler.deinit(); self.vx.deinit(self.allocator, self.tty.writer()); self.tty.deinit(); + self.config.deinit(); + self.allocator.destroy(self.config); + self.arena.deinit(); self.allocator.free(self.buf); } diff --git a/src/config/Config.zig b/src/config/Config.zig index 72eac97..fc512ad 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -18,6 +18,8 @@ pub const KeyMap = struct { enter_command_mode: vaxis.Key = .{ .codepoint = ':' }, exit_command_mode: vaxis.Key = .{ .codepoint = vaxis.Key.escape }, execute_command: vaxis.Key = .{ .codepoint = vaxis.Key.enter }, + history_back: vaxis.Key = .{ .codepoint = vaxis.Key.up }, + history_forward: vaxis.Key = .{ .codepoint = vaxis.Key.down }, pub fn parse(val: std.json.Value, allocator: std.mem.Allocator) KeyMap { var keymap = KeyMap{}; @@ -87,6 +89,8 @@ pub const General = struct { timeout: f32 = 5.0, // resolution dpi: f32 = 96.0, + // whole number (possibly 0) + history: u32 = 1000, pub fn parse(val: std.json.Value, allocator: std.mem.Allocator) General { var general = General{}; @@ -116,6 +120,7 @@ pub const General = struct { general.retry_delay = parseType(f32, val.object, "retry_delay", allocator, general.retry_delay); general.timeout = parseType(f32, val.object, "timeout", allocator, general.timeout); general.dpi = parseType(f32, val.object, "dpi", allocator, general.dpi); + general.history = parseType(u32, val.object, "history", allocator, general.history); return general; } @@ -211,34 +216,42 @@ general: General = .{}, status_bar: StatusBar = .{}, cache: Cache = .{}, -pub fn init(allocator: std.mem.Allocator) !Self { +legacy_path: bool = false, + +pub fn init(allocator: std.mem.Allocator) Self { var self = Self{ .arena = std.heap.ArenaAllocator.init(allocator) }; const arena_allocator = self.arena.allocator(); const home = std.process.getEnvVarOwned(allocator, "HOME") catch return self; defer allocator.free(home); - var config_dir_buf: [std.fs.max_path_bytes]u8 = undefined; - const config_dir = std.fmt.bufPrint(&config_dir_buf, "{s}/.config/fancy-cat", .{home}) catch return self; - - std.fs.makeDirAbsolute(config_dir) catch {}; - - var config_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const config_path = std.fmt.bufPrint(&config_path_buf, "{s}/config.json", .{config_dir}) catch return self; - - const file = std.fs.openFileAbsolute(config_path, .{ .mode = .read_only }) catch |err| { - if (err == error.FileNotFound) { - const newf = std.fs.createFileAbsolute(config_path, .{}) catch return self; - newf.close(); + var path: []u8 = ""; + const xdg_config_home = std.process.getEnvVarOwned(allocator, "XDG_CONFIG_HOME") catch null; + if (xdg_config_home) |x| { + path = std.fmt.allocPrint(allocator, "{s}/fancy-cat/config.json", .{x}) catch return self; + allocator.free(x); + } else path = std.fmt.allocPrint(allocator, "{s}/.config/fancy-cat/config.json", .{home}) catch return self; + defer allocator.free(path); + + var content = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch null; + if (content == null) { + const legacy_path = std.fmt.allocPrint(allocator, "{s}/.fancy-cat", .{home}) catch return self; + defer allocator.free(legacy_path); + + content = std.fs.cwd().readFileAlloc(allocator, legacy_path, 1024 * 1024) catch null; + if (content == null) { + if (std.fs.path.dirname(path)) |dir| std.fs.cwd().makePath(dir) catch {}; + const file = std.fs.createFileAbsolute(path, .{}) catch return self; + file.close(); + return self; } - return self; - }; - defer file.close(); + self.legacy_path = true; + } + defer allocator.free(content.?); - const content = file.readToEndAlloc(arena_allocator, 1024 * 1024) catch return self; - if (content.len == 0) return self; + if (content.?.len == 0) return self; - var parsed = std.json.parseFromSlice(std.json.Value, arena_allocator, content, .{}) catch return self; + var parsed = std.json.parseFromSlice(std.json.Value, arena_allocator, content.?, .{}) catch return self; defer parsed.deinit(); if (parsed.value.object.get("KeyMap")) |key_map| self.key_map = KeyMap.parse(key_map, arena_allocator); diff --git a/src/modes/CommandMode.zig b/src/modes/CommandMode.zig index c98a9a0..71a4a58 100644 --- a/src/modes/CommandMode.zig +++ b/src/modes/CommandMode.zig @@ -8,6 +8,7 @@ const TextInput = vaxis.widgets.TextInput; context: *Context, text_input: TextInput, +history_prefix: ?[]const u8 = null, pub fn init(context: *Context) Self { return .{ @@ -20,6 +21,7 @@ pub fn deinit(self: *Self) void { const win = self.context.vx.window(); win.hideCursor(); self.text_input.deinit(); + if (self.history_prefix) |history_prefix| self.context.allocator.free(history_prefix); } pub fn handleKeyStroke(self: *Self, key: vaxis.Key, km: Config.KeyMap) !void { @@ -31,11 +33,72 @@ pub fn handleKeyStroke(self: *Self, key: vaxis.Key, km: Config.KeyMap) !void { } if (key.matches(km.execute_command.codepoint, km.execute_command.mods)) { - self.executeCommand(self.text_input.buf.firstHalf()); + const text_input = try self.text_input.buf.toOwnedSlice(); + defer self.context.allocator.free(text_input); + const cmd = std.mem.trim(u8, text_input, &std.ascii.whitespace); + + if (cmd.len > 0) { + self.executeCommand(cmd); + self.context.history.addToHistory(cmd); + } + self.context.changeMode(.view); return; } + // History + if (key.matches(km.history_back.codepoint, km.history_back.mods) or key.matches(km.history_forward.codepoint, km.history_forward.mods)) { + if (self.history_prefix == null) { + const text_input = try self.text_input.buf.toOwnedSlice(); + defer self.context.allocator.free(text_input); + self.history_prefix = try self.context.allocator.dupe(u8, text_input); + } + + const history_prefix = self.history_prefix.?; + var filtered = std.ArrayList([]const u8){}; + defer filtered.deinit(self.context.allocator); + + for (self.context.history.items.items) |cmd| { + if (std.mem.startsWith(u8, cmd, history_prefix)) { + try filtered.append(self.context.allocator, cmd); + } + } + + const count = @as(isize, @intCast(filtered.items.len)); + if (count > 0) { + if (key.matches(km.history_back.codepoint, km.history_back.mods)) { + if (self.context.history.index == -1) { + self.context.history.index = count - 1; + } else if (self.context.history.index > 0) { + self.context.history.index -= 1; + } + } else if (key.matches(km.history_forward.codepoint, km.history_forward.mods)) { + if (self.context.history.index >= 0 and self.context.history.index < count - 1) { + self.context.history.index += 1; + } else { + self.context.history.index = -1; + } + } + } + + const input_to_display = if (self.context.history.index == -1 or count == 0) + history_prefix + else + filtered.items[@as(usize, @intCast(self.context.history.index))]; + + self.text_input.buf.clearRetainingCapacity(); + self.text_input.reset(); + try self.text_input.insertSliceAtCursor(input_to_display); + + return; + } + + if (self.history_prefix) |history_prefix| { + self.context.allocator.free(history_prefix); + self.history_prefix = null; + } + self.context.history.index = -1; + try self.text_input.update(.{ .key_press = key }); } @@ -48,55 +111,62 @@ pub fn drawCommandBar(self: *Self, win: vaxis.Window) void { }); _ = command_bar.print(&.{.{ .text = ":" }}, .{ .col_offset = 0 }); - const child = win.child(.{ - .x_off = 1, - .y_off = win.height - 1, - .width = win.width, - .height = 1, - }); - - self.text_input.draw(child); + self.text_input.draw(command_bar.child(.{ .x_off = 1 })); } pub fn executeCommand(self: *Self, cmd: []const u8) void { - const cmd_str = std.mem.trim(u8, cmd, " "); - if (std.mem.eql(u8, cmd_str, "q")) { - self.context.should_quit = true; - return; - } + if (self.handleQuit(cmd)) return; + if (self.handleGoToPage(cmd)) return; + if (self.handleZoom(cmd)) return; + if (self.handleScroll(cmd)) return; +} - if (cmd_str.len >= 3) { - const axis = cmd_str[0]; - const sign = cmd_str[1]; - if ((axis == 'x' or axis == 'y') and (sign == '+' or sign == '-')) { - const number_str = cmd_str[2..]; - if (std.fmt.parseFloat(f32, number_str)) |amount| { - const delta = if (sign == '+') amount else -amount; - const dx: f32 = if (axis == 'x') delta else 0.0; - const dy: f32 = if (axis == 'y') delta else 0.0; - self.context.document_handler.offsetScroll(dx, dy); - self.context.resetCurrentPage(); - } else |_| {} - return; - } - } +fn handleQuit(self: *Self, cmd: []const u8) bool { + if (!std.mem.eql(u8, cmd, "q")) return false; + self.context.should_quit = true; + return true; +} - if (std.mem.endsWith(u8, cmd_str, "%")) { - const number_str = cmd_str[0 .. cmd_str.len - 1]; - if (std.fmt.parseFloat(f32, number_str)) |percent| { - // TODO detect DPI - const dpi = self.context.document_handler.pdf_handler.config.general.dpi; - const zoom_factor = (percent * dpi) / 7200.0; - self.context.document_handler.setZoom(zoom_factor); - self.context.resetCurrentPage(); - } else |_| {} - return; +fn handleGoToPage(self: *Self, cmd: []const u8) bool { + const page_num = (std.fmt.parseInt(u16, cmd, 10) catch return false); + if (!self.context.document_handler.goToPage(page_num)) return false; + + self.context.resetCurrentPage(); + return true; +} + +fn handleZoom(self: *Self, cmd: []const u8) bool { + if (!std.mem.endsWith(u8, cmd, "%")) return false; + + const number_str = cmd[0 .. cmd.len - 1]; + if (std.fmt.parseFloat(f32, number_str)) |percent| { + // TODO detect DPI + const dpi = self.context.document_handler.pdf_handler.config.general.dpi; + const zoom_factor = (percent * dpi) / 7200.0; + self.context.document_handler.setZoom(zoom_factor); + self.context.resetCurrentPage(); + return true; + } else |_| { + return false; } +} - if (std.fmt.parseInt(u16, cmd_str, 10)) |page_num| { - const success = self.context.document_handler.goToPage(page_num); - if (success) { - self.context.resetCurrentPage(); - } - } else |_| {} +fn handleScroll(self: *Self, cmd: []const u8) bool { + if (cmd.len < 3) return false; + const axis = cmd[0]; + const sign = cmd[1]; + if ((axis != 'x' and axis != 'y') or (sign != '+' and sign != '-')) return false; + + const number_str = cmd[2..]; + + if (std.fmt.parseFloat(f32, number_str)) |amount| { + const delta = if (sign == '+') amount else -amount; + const dx: f32 = if (axis == 'x') delta else 0.0; + const dy: f32 = if (axis == 'y') delta else 0.0; + self.context.document_handler.offsetScroll(dx, dy); + self.context.resetCurrentPage(); + return true; + } else |_| { + return false; + } } diff --git a/src/services/History.zig b/src/services/History.zig new file mode 100644 index 0000000..4251b41 --- /dev/null +++ b/src/services/History.zig @@ -0,0 +1,91 @@ +const Self = @This(); +const std = @import("std"); +const Config = @import("../config/Config.zig"); + +allocator: std.mem.Allocator, +config: *Config, +items: std.ArrayList([]const u8), +index: isize, +path: []u8, + +pub fn init(allocator: std.mem.Allocator, config: *Config) Self { + var self = Self{ + .allocator = allocator, + .config = config, + .items = .{}, + .index = -1, + .path = "", + }; + + if (config.general.history <= 0) return self; + + const home = std.process.getEnvVarOwned(allocator, "HOME") catch return self; + defer allocator.free(home); + + if (!config.legacy_path) { + const xdg_state_home = std.process.getEnvVarOwned(allocator, "XDG_STATE_HOME") catch null; + if (xdg_state_home) |x| { + self.path = std.fmt.allocPrint(allocator, "{s}/fancy-cat/history", .{x}) catch return self; + allocator.free(x); + } else self.path = std.fmt.allocPrint(allocator, "{s}/.local/state/fancy-cat/history", .{home}) catch return self; + } else self.path = std.fmt.allocPrint(allocator, "{s}/.fancy-cat_history", .{home}) catch return self; + + const content = std.fs.cwd().readFileAlloc(allocator, self.path, 1024 * 1024) catch null; + if (content == null) return self; + defer allocator.free(content.?); + + var line = std.mem.tokenizeScalar(u8, content.?, '\n'); + while (line.next()) |cmd| { + const cmd_copy = allocator.dupe(u8, cmd) catch continue; + self.items.append(allocator, cmd_copy) catch { + allocator.free(cmd_copy); + continue; + }; + } + + return self; +} + +pub fn deinit(self: *Self) void { + if (self.config.general.history <= 0) return; + + defer { + for (self.items.items) |entry| self.allocator.free(entry); + self.items.deinit(self.allocator); + if (self.path.len > 0) self.allocator.free(self.path); + } + + if (std.fs.path.dirname(self.path)) |dir| std.fs.cwd().makePath(dir) catch {}; + const file = std.fs.createFileAbsolute(self.path, .{}) catch return; + defer file.close(); + + for (self.items.items) |cmd| { + file.writeAll(cmd) catch continue; + file.writeAll("\n") catch continue; + } +} + +pub fn addToHistory(self: *Self, cmd: []const u8) void { + if (self.config.general.history <= 0) return; + + for (self.items.items, 0..) |existing_cmd, i| { + if (std.mem.eql(u8, existing_cmd, cmd)) { + self.allocator.free(self.items.orderedRemove(i)); + break; + } + } + + const cmd_copy = self.allocator.dupe(u8, cmd) catch return; + self.items.append(self.allocator, cmd_copy) catch { + self.allocator.free(cmd_copy); + return; + }; + + const max: usize = self.config.general.history; + while (self.items.items.len > max) { + const removed = self.items.orderedRemove(0); + self.allocator.free(removed); + } + + self.index = -1; +}