Skip to main content

binocular/
config.rs

1use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5pub fn config_dir() -> PathBuf {
6    // Respect XDG_CONFIG_HOME on all platforms.
7    if let Ok(xdg) = std::env::var("XDG_CONFIG_HOME") {
8        return PathBuf::from(xdg).join("binocular");
9    }
10
11    // On macOS, prefer ~/.config over ~/Library/Application Support.
12    #[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// ── Raw config struct (serde) ─────────────────────────────────────────────────
169
170#[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    /// Maximum number of log entries kept in memory (initial load + streaming).
205    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}