1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5pub fn config_dir() -> PathBuf {
6 if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
8 return PathBuf::from(xdg).join("binocular");
9 }
10
11 #[cfg(target_os = "macos")]
13 if let Ok(home) = std::env::var("HOME") {
14 return PathBuf::from(home).join(".config").join("binocular");
15 }
16
17 #[cfg(target_os = "windows")]
18 if let Ok(app_data) = std::env::var("APPDATA") {
19 return PathBuf::from(app_data).join("binocular");
20 }
21
22 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
23 if let Ok(home) = std::env::var("HOME") {
24 return PathBuf::from(home).join(".config").join("binocular");
25 }
26
27 PathBuf::from(".").join("binocular")
28}
29
30#[derive(Debug, Clone, PartialEq)]
31pub struct KeyBinding {
32 pub code: KeyCode,
33 pub modifiers: KeyModifiers,
34}
35
36impl KeyBinding {
37 fn matches(&self, key: &KeyEvent) -> bool {
38 self.code == key.code && self.modifiers == key.modifiers
39 }
40}
41
42pub fn format_keybinding(binding: &KeyBinding) -> String {
43 let mut parts = Vec::new();
44 if binding.modifiers.contains(KeyModifiers::CONTROL) {
45 parts.push("Ctrl".to_string());
46 }
47 if binding.modifiers.contains(KeyModifiers::ALT) {
48 parts.push("Alt".to_string());
49 }
50 if binding.modifiers.contains(KeyModifiers::SHIFT) {
51 parts.push("Shift".to_string());
52 }
53 parts.push(match binding.code {
54 KeyCode::Enter => "Enter".to_string(),
55 KeyCode::Tab => "Tab".to_string(),
56 KeyCode::BackTab => "Shift+Tab".to_string(),
57 KeyCode::Esc => "Esc".to_string(),
58 KeyCode::Backspace => "Backspace".to_string(),
59 KeyCode::Delete => "Delete".to_string(),
60 KeyCode::Insert => "Insert".to_string(),
61 KeyCode::Up => "Up".to_string(),
62 KeyCode::Down => "Down".to_string(),
63 KeyCode::Left => "Left".to_string(),
64 KeyCode::Right => "Right".to_string(),
65 KeyCode::PageUp => "PageUp".to_string(),
66 KeyCode::PageDown => "PageDown".to_string(),
67 KeyCode::Home => "Home".to_string(),
68 KeyCode::End => "End".to_string(),
69 KeyCode::Char(' ') => "Space".to_string(),
70 KeyCode::Char(ch) => ch.to_ascii_uppercase().to_string(),
71 KeyCode::F(n) => format!("F{n}"),
72 _ => format!("{:?}", binding.code),
73 });
74 parts.join("+")
75}
76
77pub fn format_keybindings(bindings: &[KeyBinding]) -> String {
78 bindings
79 .iter()
80 .map(format_keybinding)
81 .collect::<Vec<_>>()
82 .join(" / ")
83}
84
85pub fn kb_matches(bindings: &[KeyBinding], key: &KeyEvent) -> bool {
86 bindings.iter().any(|b| b.matches(key))
87}
88
89pub fn parse_key(s: &str) -> Result<KeyBinding, String> {
90 let parts: Vec<&str> = s.split('+').collect();
91 if parts.is_empty() {
92 return Err("empty key string".into());
93 }
94
95 let mut modifiers = KeyModifiers::empty();
96 for &part in &parts[..parts.len() - 1] {
97 match part.to_ascii_lowercase().as_str() {
98 "ctrl" | "control" => modifiers |= KeyModifiers::CONTROL,
99 "shift" => modifiers |= KeyModifiers::SHIFT,
100 "alt" | "meta" => modifiers |= KeyModifiers::ALT,
101 other => return Err(format!("unknown modifier '{other}'")),
102 }
103 }
104
105 let key_str = parts[parts.len() - 1].to_ascii_lowercase();
106 let code = match key_str.as_str() {
107 "enter" | "return" => KeyCode::Enter,
108 "tab" => KeyCode::Tab,
109 "esc" | "escape" => KeyCode::Esc,
110 "backspace" => KeyCode::Backspace,
111 "delete" | "del" => KeyCode::Delete,
112 "up" => KeyCode::Up,
113 "down" => KeyCode::Down,
114 "left" => KeyCode::Left,
115 "right" => KeyCode::Right,
116 "pageup" | "page_up" => KeyCode::PageUp,
117 "pagedown" | "page_down" => KeyCode::PageDown,
118 "home" => KeyCode::Home,
119 "end" => KeyCode::End,
120 "insert" | "ins" => KeyCode::Insert,
121 "space" => KeyCode::Char(' '),
122 "f1" => KeyCode::F(1),
123 "f2" => KeyCode::F(2),
124 "f3" => KeyCode::F(3),
125 "f4" => KeyCode::F(4),
126 "f5" => KeyCode::F(5),
127 "f6" => KeyCode::F(6),
128 "f7" => KeyCode::F(7),
129 "f8" => KeyCode::F(8),
130 "f9" => KeyCode::F(9),
131 "f10" => KeyCode::F(10),
132 "f11" => KeyCode::F(11),
133 "f12" => KeyCode::F(12),
134 c if c.chars().count() == 1 => KeyCode::Char(c.chars().next().unwrap()),
135 other => return Err(format!("unknown key '{other}'")),
136 };
137
138 Ok(KeyBinding { code, modifiers })
139}
140
141fn parse_key_list(strings: Vec<String>, action: &str) -> Vec<KeyBinding> {
142 strings
143 .into_iter()
144 .filter_map(|s| {
145 parse_key(&s)
146 .map_err(|e| eprintln!("binocular: keybinding '{action}': {e}"))
147 .ok()
148 })
149 .collect()
150}
151
152#[derive(Deserialize, Clone)]
153#[serde(untagged)]
154enum OneOrMany {
155 One(String),
156 Many(Vec<String>),
157}
158
159impl OneOrMany {
160 fn into_vec(self) -> Vec<String> {
161 match self {
162 Self::One(s) => vec![s],
163 Self::Many(v) => v,
164 }
165 }
166}
167
168#[derive(Deserialize, Default)]
171#[serde(default)]
172struct KeybindingsConfig {
173 quit: Option<OneOrMany>,
174 toggle_help: Option<OneOrMany>,
175 toggle_preview_focus: Option<OneOrMany>,
176 toggle_preview_fullscreen: Option<OneOrMany>,
177 swap_panes: Option<OneOrMany>,
178 preview_wider: Option<OneOrMany>,
179 preview_narrower: Option<OneOrMany>,
180 toggle_search_bar_position: Option<OneOrMany>,
181 toggle_preview_visibility: Option<OneOrMany>,
182 toggle_exact: Option<OneOrMany>,
183 mode_path: Option<OneOrMany>,
184 mode_files: Option<OneOrMany>,
185 mode_grep: Option<OneOrMany>,
186 mode_dirs: Option<OneOrMany>,
187 scroll_preview_up: Option<OneOrMany>,
188 scroll_preview_down: Option<OneOrMany>,
189 mark_result: Option<OneOrMany>,
190 mark_diff_result: Option<OneOrMany>,
191 select_from_preview: Option<OneOrMany>,
192}
193
194#[derive(Deserialize, Default)]
195#[serde(default)]
196struct RawAppConfig {
197 keybindings: KeybindingsConfig,
198 log: LogConfig,
199}
200
201#[derive(Clone, Deserialize)]
202#[serde(default)]
203pub struct LogConfig {
204 pub max_entries: usize,
206}
207
208impl Default for LogConfig {
209 fn default() -> Self {
210 Self {
211 max_entries: 100_000,
212 }
213 }
214}
215
216#[derive(Clone)]
217pub struct Keybindings {
218 pub quit: Vec<KeyBinding>,
219 pub toggle_help: Vec<KeyBinding>,
220 pub toggle_preview_focus: Vec<KeyBinding>,
221 pub toggle_preview_fullscreen: Vec<KeyBinding>,
222 pub swap_panes: Vec<KeyBinding>,
223 pub preview_wider: Vec<KeyBinding>,
224 pub preview_narrower: Vec<KeyBinding>,
225 pub toggle_search_bar_position: Vec<KeyBinding>,
226 pub toggle_preview_visibility: Vec<KeyBinding>,
227 pub toggle_exact: Vec<KeyBinding>,
228 pub mode_path: Vec<KeyBinding>,
229 pub mode_files: Vec<KeyBinding>,
230 pub mode_grep: Vec<KeyBinding>,
231 pub mode_dirs: Vec<KeyBinding>,
232 pub scroll_preview_up: Vec<KeyBinding>,
233 pub scroll_preview_down: Vec<KeyBinding>,
234 pub mark_result: Vec<KeyBinding>,
235 pub mark_diff_result: Vec<KeyBinding>,
236 pub select_from_preview: Vec<KeyBinding>,
237}
238
239fn single(code: KeyCode, modifiers: KeyModifiers) -> Vec<KeyBinding> {
240 vec![KeyBinding { code, modifiers }]
241}
242
243impl Default for Keybindings {
244 fn default() -> Self {
245 use KeyCode::*;
246 use KeyModifiers as M;
247 Self {
248 quit: single(Char('c'), M::CONTROL),
249 toggle_help: single(Char('h'), M::CONTROL),
250 toggle_preview_focus: single(Char('w'), M::CONTROL),
251 toggle_preview_fullscreen: single(Char('f'), M::CONTROL),
252 swap_panes: single(Char('e'), M::CONTROL),
253 preview_wider: single(Char('p'), M::CONTROL),
254 preview_narrower: single(Char('n'), M::CONTROL),
255 toggle_search_bar_position: single(Char('t'), M::CONTROL),
256 toggle_preview_visibility: single(Char('b'), M::CONTROL),
257 toggle_exact: single(Char('x'), M::CONTROL),
258 mode_path: single(F(1), M::NONE),
259 mode_files: single(F(2), M::NONE),
260 mode_grep: single(F(3), M::NONE),
261 mode_dirs: single(F(4), M::NONE),
262 scroll_preview_up: vec![
263 KeyBinding {
264 code: PageUp,
265 modifiers: M::NONE,
266 },
267 KeyBinding {
268 code: Char('u'),
269 modifiers: M::CONTROL,
270 },
271 ],
272 scroll_preview_down: vec![
273 KeyBinding {
274 code: PageDown,
275 modifiers: M::NONE,
276 },
277 KeyBinding {
278 code: Char('d'),
279 modifiers: M::CONTROL,
280 },
281 ],
282 mark_result: single(Tab, M::NONE),
283 mark_diff_result: single(F(5), M::NONE),
284 select_from_preview: single(Enter, M::NONE),
285 }
286 }
287}
288
289impl Keybindings {
290 fn from_config(cfg: KeybindingsConfig) -> Self {
291 let d = Self::default();
292
293 macro_rules! resolve {
294 ($field:ident) => {
295 match cfg.$field {
296 None => d.$field,
297 Some(raw) => {
298 let parsed = parse_key_list(raw.into_vec(), stringify!($field));
299 if parsed.is_empty() {
300 d.$field
301 } else {
302 parsed
303 }
304 }
305 }
306 };
307 }
308
309 Self {
310 quit: resolve!(quit),
311 toggle_help: resolve!(toggle_help),
312 toggle_preview_focus: resolve!(toggle_preview_focus),
313 toggle_preview_fullscreen: resolve!(toggle_preview_fullscreen),
314 swap_panes: resolve!(swap_panes),
315 preview_wider: resolve!(preview_wider),
316 preview_narrower: resolve!(preview_narrower),
317 toggle_search_bar_position: resolve!(toggle_search_bar_position),
318 toggle_preview_visibility: resolve!(toggle_preview_visibility),
319 toggle_exact: resolve!(toggle_exact),
320 mode_path: resolve!(mode_path),
321 mode_files: resolve!(mode_files),
322 mode_grep: resolve!(mode_grep),
323 mode_dirs: resolve!(mode_dirs),
324 scroll_preview_up: resolve!(scroll_preview_up),
325 scroll_preview_down: resolve!(scroll_preview_down),
326 mark_result: resolve!(mark_result),
327 mark_diff_result: resolve!(mark_diff_result),
328 select_from_preview: resolve!(select_from_preview),
329 }
330 }
331}
332
333#[derive(Serialize, Deserialize)]
334#[serde(default)]
335pub struct PersistedLayout {
336 pub panes_swapped: bool,
337 pub preview_percent: u16,
338 pub search_bar_at_bottom: bool,
339 pub preview_hidden: bool,
340}
341
342impl Default for PersistedLayout {
343 fn default() -> Self {
344 Self {
345 panes_swapped: false,
346 preview_percent: 50,
347 search_bar_at_bottom: false,
348 preview_hidden: false,
349 }
350 }
351}
352
353pub fn load_layout() -> PersistedLayout {
354 let path = config_dir().join("layout.toml");
355 let content = match std::fs::read_to_string(&path) {
356 Ok(s) => s,
357 Err(_) => return PersistedLayout::default(),
358 };
359 toml::from_str(&content).unwrap_or_default()
360}
361
362pub fn save_layout(layout: &PersistedLayout) {
363 let dir = config_dir();
364 let path = dir.join("layout.toml");
365 if let Ok(content) = toml::to_string(layout) {
366 let _ = std::fs::write(path, content);
367 }
368}
369
370const DEFAULT_CONFIG: &str = include_str!("../config/default.toml");
371
372#[derive(Clone, Default)]
373pub struct LoadedAppConfig {
374 pub keybindings: Keybindings,
375 pub log: LogConfig,
376}
377
378fn ensure_config_file() -> PathBuf {
379 let dir = config_dir();
380 let path = dir.join("config.toml");
381
382 if !path.exists() {
383 if let Err(e) = std::fs::create_dir_all(&dir) {
384 eprintln!(
385 "binocular: could not create config directory {}: {e}",
386 dir.display()
387 );
388 } else if let Err(e) = std::fs::write(&path, DEFAULT_CONFIG) {
389 eprintln!(
390 "binocular: could not write default config {}: {e}",
391 path.display()
392 );
393 }
394 }
395
396 path
397}
398
399pub fn load_app_config() -> LoadedAppConfig {
400 let path = ensure_config_file();
401 let content = match std::fs::read_to_string(&path) {
402 Ok(s) => s,
403 Err(_) => {
404 return LoadedAppConfig {
405 keybindings: Keybindings::default(),
406 log: LogConfig::default(),
407 };
408 }
409 };
410 let cfg: RawAppConfig = toml::from_str(&content).unwrap_or_else(|e| {
411 eprintln!("binocular: error reading config {}: {e}", path.display());
412 RawAppConfig::default()
413 });
414
415 LoadedAppConfig {
416 keybindings: Keybindings::from_config(cfg.keybindings),
417 log: cfg.log,
418 }
419}
420
421pub fn load_keybindings() -> Keybindings {
422 load_app_config().keybindings
423}
424
425pub fn load_log_max_entries() -> usize {
426 load_app_config().log.max_entries
427}