Skip to content

Commit 33c1e2c

Browse files
authored
feat: command history (#110)
* fix: use child window for command input * fix: use full buffer for command execution * refactor: centralize command trimming * refactor: split command execution logic into separate functions * feat: add history parameter and key bindings * feat: add history service * feat: add history navigation * fix: deinit order * feat: store command history globally * docs: explain history options * feat: XDG config location * fix: remove now-unused error union type * feat: XDG history location * docs: describe config and history locations * docs: mention config locations
1 parent dc24805 commit 33c1e2c

File tree

6 files changed

+302
-78
lines changed

6 files changed

+302
-78
lines changed

README.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,15 @@ fancy-cat <path-to-pdf> <optional-page-number>
1919

2020
### Commands
2121

22-
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)
22+
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).
2323

24-
Documentation on the available commands can be found [here](./docs/commands.md)
24+
Documentation on the available commands can be found [here](./docs/commands.md).
2525

2626
### Configuration
2727

28-
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.
28+
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.
2929

30-
The default `config.json` and documentation can be found [here](./docs/config.md)
30+
An example `config.json` and documentation can be found [here](./docs/config.md).
3131

3232
## Installation
3333

docs/config.md

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,30 @@
11
# Configuration
22

3-
On startup, fancy-cat looks for a configuration file at:
3+
On startup, fancy-cat looks for a configuration file in the following locations:
44

5+
**Primary**
6+
7+
```
8+
$XDG_CONFIG_HOME/fancy-cat/config.json
9+
```
10+
11+
**Fallback**
12+
13+
```
14+
$HOME/.config/fancy-cat/config.json
515
```
6-
~/.config/fancy-cat/config.json
16+
17+
**Legacy**
18+
19+
```
20+
$HOME/.fancy-cat
721
```
822

9-
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.
23+
If no configuration file is found in any of these locations, fancy-cat creates an empty configuration file in the primary or fallback location.
1024

1125
## Defaults
1226

13-
Below is an example configuration file that replicates the default settings. You can use it as a starting point for your customizations:
27+
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:
1428

1529
```json
1630
{
@@ -29,7 +43,9 @@ Below is an example configuration file that replicates the default settings. You
2943
"full_screen": { "key": "f"},
3044
"enter_command_mode": { "key": ":" },
3145
"exit_command_mode": { "key": "escape" },
32-
"execute_command": { "key": "enter" }
46+
"execute_command": { "key": "enter" },
47+
"history_back": { "key": "up" },
48+
"history_forward": { "key": "down" }
3349
},
3450
"FileMonitor": {
3551
"enabled": true,
@@ -46,7 +62,8 @@ Below is an example configuration file that replicates the default settings. You
4662
"scroll_step": 100.0,
4763
"retry_delay": 0.2,
4864
"timeout": 5.0,
49-
"dpi": 96.0
65+
"dpi": 96.0,
66+
"history": 1000
5067
},
5168
"StatusBar": {
5269
"enabled": true,
@@ -77,6 +94,7 @@ The rest of this reference provides detailed explanations for each configuration
7794
- [File Monitor](#file-monitor)
7895
- [General](#general)
7996
- [Color](#color)
97+
- [History](#history)
8098
- [Status Bar](#status-bar)
8199
- [Style](#style)
82100
- [Underline](#underline)
@@ -110,6 +128,8 @@ The `KeyMap` section defines keybindings for various actions.
110128
| `enter_command_mode` | Enter command mode |
111129
| `exit_command_mode` | Exit command mode |
112130
| `execute_command` | Execute the entered command |
131+
| `history_back` | Go back one command in history |
132+
| `history_forward` | Go forward one command in history |
113133

114134
### Keybindings
115135

@@ -191,6 +211,7 @@ The `General` section includes various display and timing settings.
191211
| `dpi` | Float | Resolution used for 100% zoom calculation |
192212
| `retry_delay` | Float (seconds) | Delay before retrying to load a document or render a page |
193213
| `timeout` | Float (seconds) | Maximum time to keep retrying before giving up on loading a document or rendering a page |
214+
| `history` | Integer | Maximum number of entries in command history |
194215

195216
>[!TIP]
196217
>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:
204225
| `"#RRGGBB"` or `"0xRRGGBB"` | `RR`, `GG`, and `BB` are two-digit hexadecimal values |
205226
| `{ "rgb": [R, G, B] }` | `R`, `G`, and `B` are integers between 0 and 255 |
206227

228+
### History
229+
230+
To ensure persistence across sessions, fancy-cat saves its command history in one of the following locations:
231+
232+
**Primary**
233+
234+
```
235+
$XDG_STATE_HOME/fancy-cat/history
236+
```
237+
238+
**Fallback**
239+
240+
```
241+
$HOME/.local/state/fancy-cat/history
242+
```
243+
244+
**Legacy**
245+
246+
```
247+
$HOME/.fancy-cat_history
248+
```
249+
>[!NOTE]
250+
>The legacy location is only used if the [configuration file](#configuration) itself is located at `$HOME/.fancy-cat`.
251+
207252
---
208253

209254
## Status Bar

src/Context.zig

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ const Config = @import("config/Config.zig");
77
const DocumentHandler = @import("handlers/DocumentHandler.zig");
88
const Cache = @import("./Cache.zig");
99
const ReloadIndicatorTimer = @import("services/ReloadIndicatorTimer.zig");
10+
const History = @import("services/History.zig");
1011

1112
pub const panic = vaxis.panic_handler;
1213

@@ -38,6 +39,7 @@ pub const Context = struct {
3839
watcher_thread: ?std.Thread,
3940
config: *Config,
4041
current_mode: Mode,
42+
history: History,
4143
reload_page: bool,
4244
cache: Cache,
4345
should_check_cache: bool,
@@ -54,7 +56,7 @@ pub const Context = struct {
5456

5557
const config = try allocator.create(Config);
5658
errdefer allocator.destroy(config);
57-
config.* = try Config.init(allocator);
59+
config.* = Config.init(allocator);
5860
errdefer config.deinit();
5961

6062
var document_handler = try DocumentHandler.init(allocator, path, initial_page, config);
@@ -70,6 +72,7 @@ pub const Context = struct {
7072
const buf = try allocator.alloc(u8, 4096);
7173
const tty = try vaxis.Tty.init(buf);
7274
const reload_indicator_timer = ReloadIndicatorTimer.init(config);
75+
const history = History.init(allocator, config);
7376

7477
return .{
7578
.allocator = allocator,
@@ -85,6 +88,7 @@ pub const Context = struct {
8588
.watcher_thread = null,
8689
.config = config,
8790
.current_mode = undefined,
91+
.history = history,
8892
.reload_page = true,
8993
.cache = Cache.init(allocator, config, vx, &tty),
9094
.should_check_cache = config.cache.enabled,
@@ -107,14 +111,15 @@ pub const Context = struct {
107111

108112
if (self.page_info_text.len > 0) self.allocator.free(self.page_info_text);
109113

110-
self.config.deinit();
111-
self.allocator.destroy(self.config);
112-
self.arena.deinit();
113114
self.reload_indicator_timer.deinit();
115+
self.history.deinit();
114116
self.cache.deinit();
115117
self.document_handler.deinit();
116118
self.vx.deinit(self.allocator, self.tty.writer());
117119
self.tty.deinit();
120+
self.config.deinit();
121+
self.allocator.destroy(self.config);
122+
self.arena.deinit();
118123
self.allocator.free(self.buf);
119124
}
120125

src/config/Config.zig

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ pub const KeyMap = struct {
1818
enter_command_mode: vaxis.Key = .{ .codepoint = ':' },
1919
exit_command_mode: vaxis.Key = .{ .codepoint = vaxis.Key.escape },
2020
execute_command: vaxis.Key = .{ .codepoint = vaxis.Key.enter },
21+
history_back: vaxis.Key = .{ .codepoint = vaxis.Key.up },
22+
history_forward: vaxis.Key = .{ .codepoint = vaxis.Key.down },
2123

2224
pub fn parse(val: std.json.Value, allocator: std.mem.Allocator) KeyMap {
2325
var keymap = KeyMap{};
@@ -87,6 +89,8 @@ pub const General = struct {
8789
timeout: f32 = 5.0,
8890
// resolution
8991
dpi: f32 = 96.0,
92+
// whole number (possibly 0)
93+
history: u32 = 1000,
9094

9195
pub fn parse(val: std.json.Value, allocator: std.mem.Allocator) General {
9296
var general = General{};
@@ -116,6 +120,7 @@ pub const General = struct {
116120
general.retry_delay = parseType(f32, val.object, "retry_delay", allocator, general.retry_delay);
117121
general.timeout = parseType(f32, val.object, "timeout", allocator, general.timeout);
118122
general.dpi = parseType(f32, val.object, "dpi", allocator, general.dpi);
123+
general.history = parseType(u32, val.object, "history", allocator, general.history);
119124

120125
return general;
121126
}
@@ -211,34 +216,42 @@ general: General = .{},
211216
status_bar: StatusBar = .{},
212217
cache: Cache = .{},
213218

214-
pub fn init(allocator: std.mem.Allocator) !Self {
219+
legacy_path: bool = false,
220+
221+
pub fn init(allocator: std.mem.Allocator) Self {
215222
var self = Self{ .arena = std.heap.ArenaAllocator.init(allocator) };
216223
const arena_allocator = self.arena.allocator();
217224

218225
const home = std.process.getEnvVarOwned(allocator, "HOME") catch return self;
219226
defer allocator.free(home);
220227

221-
var config_dir_buf: [std.fs.max_path_bytes]u8 = undefined;
222-
const config_dir = std.fmt.bufPrint(&config_dir_buf, "{s}/.config/fancy-cat", .{home}) catch return self;
223-
224-
std.fs.makeDirAbsolute(config_dir) catch {};
225-
226-
var config_path_buf: [std.fs.max_path_bytes]u8 = undefined;
227-
const config_path = std.fmt.bufPrint(&config_path_buf, "{s}/config.json", .{config_dir}) catch return self;
228-
229-
const file = std.fs.openFileAbsolute(config_path, .{ .mode = .read_only }) catch |err| {
230-
if (err == error.FileNotFound) {
231-
const newf = std.fs.createFileAbsolute(config_path, .{}) catch return self;
232-
newf.close();
228+
var path: []u8 = "";
229+
const xdg_config_home = std.process.getEnvVarOwned(allocator, "XDG_CONFIG_HOME") catch null;
230+
if (xdg_config_home) |x| {
231+
path = std.fmt.allocPrint(allocator, "{s}/fancy-cat/config.json", .{x}) catch return self;
232+
allocator.free(x);
233+
} else path = std.fmt.allocPrint(allocator, "{s}/.config/fancy-cat/config.json", .{home}) catch return self;
234+
defer allocator.free(path);
235+
236+
var content = std.fs.cwd().readFileAlloc(allocator, path, 1024 * 1024) catch null;
237+
if (content == null) {
238+
const legacy_path = std.fmt.allocPrint(allocator, "{s}/.fancy-cat", .{home}) catch return self;
239+
defer allocator.free(legacy_path);
240+
241+
content = std.fs.cwd().readFileAlloc(allocator, legacy_path, 1024 * 1024) catch null;
242+
if (content == null) {
243+
if (std.fs.path.dirname(path)) |dir| std.fs.cwd().makePath(dir) catch {};
244+
const file = std.fs.createFileAbsolute(path, .{}) catch return self;
245+
file.close();
246+
return self;
233247
}
234-
return self;
235-
};
236-
defer file.close();
248+
self.legacy_path = true;
249+
}
250+
defer allocator.free(content.?);
237251

238-
const content = file.readToEndAlloc(arena_allocator, 1024 * 1024) catch return self;
239-
if (content.len == 0) return self;
252+
if (content.?.len == 0) return self;
240253

241-
var parsed = std.json.parseFromSlice(std.json.Value, arena_allocator, content, .{}) catch return self;
254+
var parsed = std.json.parseFromSlice(std.json.Value, arena_allocator, content.?, .{}) catch return self;
242255
defer parsed.deinit();
243256

244257
if (parsed.value.object.get("KeyMap")) |key_map| self.key_map = KeyMap.parse(key_map, arena_allocator);

0 commit comments

Comments
 (0)