From 760bacdeb20b5d140bf43cc9f88cfed595494fcc Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:24:26 -0400 Subject: [PATCH 01/15] fix: use child window for command input --- src/modes/CommandMode.zig | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/modes/CommandMode.zig b/src/modes/CommandMode.zig index c98a9a0..67a8605 100644 --- a/src/modes/CommandMode.zig +++ b/src/modes/CommandMode.zig @@ -48,14 +48,7 @@ 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 { From 0fa9c05ffcc7493e6323a1b91395b1cdc07c789a Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:25:18 -0400 Subject: [PATCH 02/15] fix: use full buffer for command execution --- src/modes/CommandMode.zig | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modes/CommandMode.zig b/src/modes/CommandMode.zig index 67a8605..ff51589 100644 --- a/src/modes/CommandMode.zig +++ b/src/modes/CommandMode.zig @@ -31,7 +31,9 @@ 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 cmd = try self.text_input.buf.toOwnedSlice(); + defer self.context.allocator.free(cmd); + self.executeCommand(cmd); self.context.changeMode(.view); return; } From 3caf81ff33677a4cb19f21d52b51c7fc84a71b23 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:26:29 -0400 Subject: [PATCH 03/15] refactor: centralize command trimming --- src/modes/CommandMode.zig | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/modes/CommandMode.zig b/src/modes/CommandMode.zig index ff51589..6e85ada 100644 --- a/src/modes/CommandMode.zig +++ b/src/modes/CommandMode.zig @@ -31,8 +31,9 @@ pub fn handleKeyStroke(self: *Self, key: vaxis.Key, km: Config.KeyMap) !void { } if (key.matches(km.execute_command.codepoint, km.execute_command.mods)) { - const cmd = try self.text_input.buf.toOwnedSlice(); - defer self.context.allocator.free(cmd); + 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); self.executeCommand(cmd); self.context.changeMode(.view); return; @@ -54,17 +55,16 @@ pub fn drawCommandBar(self: *Self, win: vaxis.Window) void { } 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")) { + if (std.mem.eql(u8, cmd, "q")) { self.context.should_quit = true; return; } - if (cmd_str.len >= 3) { - const axis = cmd_str[0]; - const sign = cmd_str[1]; + if (cmd.len >= 3) { + const axis = cmd[0]; + const sign = cmd[1]; if ((axis == 'x' or axis == 'y') and (sign == '+' or sign == '-')) { - const number_str = cmd_str[2..]; + 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; @@ -76,8 +76,8 @@ pub fn executeCommand(self: *Self, cmd: []const u8) void { } } - if (std.mem.endsWith(u8, cmd_str, "%")) { - const number_str = cmd_str[0 .. cmd_str.len - 1]; + if (std.mem.endsWith(u8, cmd, "%")) { + 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; @@ -88,7 +88,7 @@ pub fn executeCommand(self: *Self, cmd: []const u8) void { return; } - if (std.fmt.parseInt(u16, cmd_str, 10)) |page_num| { + if (std.fmt.parseInt(u16, cmd, 10)) |page_num| { const success = self.context.document_handler.goToPage(page_num); if (success) { self.context.resetCurrentPage(); From b1dd7e7ed0a630fbf15e7a5421ebaad74d5736b2 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:27:42 -0400 Subject: [PATCH 04/15] refactor: split command execution logic into separate functions --- src/modes/CommandMode.zig | 85 +++++++++++++++++++++++---------------- 1 file changed, 50 insertions(+), 35 deletions(-) diff --git a/src/modes/CommandMode.zig b/src/modes/CommandMode.zig index 6e85ada..d5782dc 100644 --- a/src/modes/CommandMode.zig +++ b/src/modes/CommandMode.zig @@ -55,43 +55,58 @@ pub fn drawCommandBar(self: *Self, win: vaxis.Window) void { } pub fn executeCommand(self: *Self, cmd: []const u8) void { - if (std.mem.eql(u8, cmd, "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.len >= 3) { - const axis = cmd[0]; - const sign = cmd[1]; - if ((axis == 'x' or axis == 'y') and (sign == '+' or sign == '-')) { - 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(); - } 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, "%")) { - 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(); - } 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, 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; + } } From 868838fbb4c4844793a24129f2f7c9acef17ac27 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:28:33 -0400 Subject: [PATCH 05/15] feat: add history parameter and key bindings --- src/config/Config.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/config/Config.zig b/src/config/Config.zig index 72eac97..096f7a0 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; } From 765078ed4817277d05f5393f0d6de16feb5dbf1e Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:29:38 -0400 Subject: [PATCH 06/15] feat: add history service --- src/services/History.zig | 99 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 99 insertions(+) create mode 100644 src/services/History.zig diff --git a/src/services/History.zig b/src/services/History.zig new file mode 100644 index 0000000..5310856 --- /dev/null +++ b/src/services/History.zig @@ -0,0 +1,99 @@ +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, + +pub fn init(allocator: std.mem.Allocator, config: *Config) Self { + var self = Self{ + .allocator = allocator, + .config = config, + .items = .{}, + .index = -1, + }; + + if (config.general.history <= 0) return self; + + const home = std.process.getEnvVarOwned(allocator, "HOME") catch return self; + defer allocator.free(home); + + var history_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const history_path = std.fmt.bufPrint(&history_path_buf, "{s}/.local/state/fancy-cat/history.txt", .{home}) catch return self; + + const file = std.fs.openFileAbsolute(history_path, .{ .mode = .read_only }) catch return self; + defer file.close(); + + const content = file.readToEndAlloc(allocator, 1024 * 1024) catch 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 { + defer { + for (self.items.items) |entry| { + self.allocator.free(entry); + } + self.items.deinit(self.allocator); + } + + if (self.config.general.history <= 0) return; + + const home = std.process.getEnvVarOwned(self.allocator, "HOME") catch return; + defer self.allocator.free(home); + + var history_dir_buf: [std.fs.max_path_bytes]u8 = undefined; + const history_dir = std.fmt.bufPrint(&history_dir_buf, "{s}/.local/state/fancy-cat", .{home}) catch return; + + std.fs.makeDirAbsolute(history_dir) catch |err| { + if (err != error.PathAlreadyExists) return; + }; + + var history_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const history_path = std.fmt.bufPrint(&history_path_buf, "{s}/history.txt", .{history_dir}) catch return; + + const file = std.fs.createFileAbsolute(history_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; +} From a9d0c1e2f7a9589cca90d67ee3cee111605a8297 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:30:52 -0400 Subject: [PATCH 07/15] feat: add history navigation --- src/modes/CommandMode.zig | 62 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/src/modes/CommandMode.zig b/src/modes/CommandMode.zig index d5782dc..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 { @@ -34,11 +36,69 @@ pub fn handleKeyStroke(self: *Self, key: vaxis.Key, km: Config.KeyMap) !void { 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); - self.executeCommand(cmd); + + 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 }); } From 93ee0efc416029841fb136ccfdd6b559110e8eb0 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:31:39 -0400 Subject: [PATCH 08/15] fix: deinit order --- src/Context.zig | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Context.zig b/src/Context.zig index ef48265..8515312 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -107,14 +107,14 @@ 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.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); } From 78cbc16a5416cae9a1e70302c32fb63e6ba6d15b Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:32:25 -0400 Subject: [PATCH 09/15] feat: store command history globally --- src/Context.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/Context.zig b/src/Context.zig index 8515312..e948532 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, @@ -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, @@ -108,6 +112,7 @@ pub const Context = struct { if (self.page_info_text.len > 0) self.allocator.free(self.page_info_text); self.reload_indicator_timer.deinit(); + self.history.deinit(); self.cache.deinit(); self.document_handler.deinit(); self.vx.deinit(self.allocator, self.tty.writer()); From 696a5918286f2b27b2aed5abad42366530cd9df8 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Sat, 25 Oct 2025 10:33:43 -0400 Subject: [PATCH 10/15] docs: explain history options --- docs/config.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/config.md b/docs/config.md index d178b98..d9992e1 100644 --- a/docs/config.md +++ b/docs/config.md @@ -29,7 +29,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 +48,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, @@ -110,6 +113,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 +196,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. From e39016c2277227708ad79bc7eaab3d5ad3131345 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:18:07 -0400 Subject: [PATCH 11/15] feat: XDG config location --- src/config/Config.zig | 46 +++++++++++++++++++++++++------------------ 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 096f7a0..fc512ad 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -216,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); From 14ce3480883f872a75863318dcc0ce9f36547b38 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:19:14 -0400 Subject: [PATCH 12/15] fix: remove now-unused error union type --- src/Context.zig | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Context.zig b/src/Context.zig index e948532..9595756 100644 --- a/src/Context.zig +++ b/src/Context.zig @@ -56,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); From c169efed75c0ecc33e2f62983dffec414110268f Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:21:21 -0400 Subject: [PATCH 13/15] feat: XDG history location --- src/services/History.zig | 46 +++++++++++++++++----------------------- 1 file changed, 19 insertions(+), 27 deletions(-) diff --git a/src/services/History.zig b/src/services/History.zig index 5310856..4251b41 100644 --- a/src/services/History.zig +++ b/src/services/History.zig @@ -6,6 +6,7 @@ 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{ @@ -13,6 +14,7 @@ pub fn init(allocator: std.mem.Allocator, config: *Config) Self { .config = config, .items = .{}, .index = -1, + .path = "", }; if (config.general.history <= 0) return self; @@ -20,16 +22,19 @@ pub fn init(allocator: std.mem.Allocator, config: *Config) Self { const home = std.process.getEnvVarOwned(allocator, "HOME") catch return self; defer allocator.free(home); - var history_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const history_path = std.fmt.bufPrint(&history_path_buf, "{s}/.local/state/fancy-cat/history.txt", .{home}) catch return self; + 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 file = std.fs.openFileAbsolute(history_path, .{ .mode = .read_only }) catch return self; - defer file.close(); - - const content = file.readToEndAlloc(allocator, 1024 * 1024) catch return self; - defer allocator.free(content); + 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'); + 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 { @@ -42,29 +47,16 @@ pub fn init(allocator: std.mem.Allocator, config: *Config) Self { } pub fn deinit(self: *Self) void { + if (self.config.general.history <= 0) return; + defer { - for (self.items.items) |entry| { - self.allocator.free(entry); - } + 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 (self.config.general.history <= 0) return; - - const home = std.process.getEnvVarOwned(self.allocator, "HOME") catch return; - defer self.allocator.free(home); - - var history_dir_buf: [std.fs.max_path_bytes]u8 = undefined; - const history_dir = std.fmt.bufPrint(&history_dir_buf, "{s}/.local/state/fancy-cat", .{home}) catch return; - - std.fs.makeDirAbsolute(history_dir) catch |err| { - if (err != error.PathAlreadyExists) return; - }; - - var history_path_buf: [std.fs.max_path_bytes]u8 = undefined; - const history_path = std.fmt.bufPrint(&history_path_buf, "{s}/history.txt", .{history_dir}) catch return; - - const file = std.fs.createFileAbsolute(history_path, .{}) catch return; + 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| { From 3b19e8bf8d893bc5cddb62dc8c96a2e0967ba9bf Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:22:53 -0400 Subject: [PATCH 14/15] docs: describe config and history locations --- docs/config.md | 47 +++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/docs/config.md b/docs/config.md index d9992e1..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 { @@ -80,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) @@ -210,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 From f6b791f1403d5205948c0d536904a659f97505d2 Mon Sep 17 00:00:00 2001 From: fdcote <48247604+fdcote@users.noreply.github.com> Date: Wed, 29 Oct 2025 09:24:16 -0400 Subject: [PATCH 15/15] docs: mention config locations --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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