Skip to main content

santui_core/
config.rs

1use std::fmt;
2use std::path::PathBuf;
3use std::sync::mpsc;
4use std::time::{Duration, SystemTime};
5
6use notify::{EventKind, RecommendedWatcher, RecursiveMode, Watcher};
7
8/// Top-level Santui configuration, deserialized from `config.toml`.
9#[derive(Debug, Clone, Default, serde::Deserialize, serde::Serialize)]
10pub struct Config {
11    /// Default theme name (must match a built-in theme or a custom theme name).
12    pub theme: Option<String>,
13    /// Custom theme color overrides.
14    pub custom_theme: Option<CustomThemeColors>,
15    /// Key-binding overrides.
16    #[serde(default)]
17    pub keybindings: KeyBindings,
18    /// Enable mouse capture (click, scroll) in the terminal.
19    /// When disabled, text selection works without holding Shift.
20    /// Toggle at runtime with Alt+M.
21    #[serde(default = "default_mouse_capture")]
22    pub mouse_capture: bool,
23    /// Plugin-specific settings (reserved — schema defined for future use).
24    #[serde(default)]
25    pub plugins: Option<PluginConfig>,
26    /// Optional santui-server connection for state sync.
27    #[serde(default)]
28    pub server: Option<ServerConfig>,
29}
30
31fn default_mouse_capture() -> bool {
32    false
33}
34
35/// Connection settings for a remote [`santui-server`](https://github.com/sonyarianto/santui).
36///
37/// When configured, the TUI app pushes plugin data changes to the server for
38/// cross-device sync and persistence.
39#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
40pub struct ServerConfig {
41    /// Base URL of the santui-server, e.g. `"http://localhost:9876"`.
42    pub url: String,
43}
44
45/// Per-color-field overrides for a custom theme.
46///
47/// Each field is an optional hex colour string like `"#ff8800"` or `"ff8800"`.
48#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
49pub struct CustomThemeColors {
50    pub name: Option<String>,
51    pub accent: Option<String>,
52    pub highlight: Option<String>,
53    pub logo: Option<String>,
54    pub text: Option<String>,
55    pub text_muted: Option<String>,
56    pub background: Option<String>,
57    pub background_panel: Option<String>,
58    pub background_overlay: Option<String>,
59    pub border: Option<String>,
60    pub success: Option<String>,
61    pub error: Option<String>,
62    pub inverted_text: Option<String>,
63}
64
65/// Key-binding overrides.
66#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
67pub struct KeyBindings {
68    /// Key to open the command palette. Default: Ctrl+P.
69    /// Format: "ctrl+p", "ctrl+space", "alt+p"
70    #[serde(default = "KeyBindings::default_open_palette")]
71    pub open_palette: String,
72
73    /// Key to quit the application. Default: "q"
74    #[serde(default = "KeyBindings::default_quit")]
75    pub quit: String,
76
77    /// Key to show the about screen. Default: "?"
78    #[serde(default = "KeyBindings::default_about")]
79    pub about: String,
80}
81
82impl Default for KeyBindings {
83    fn default() -> Self {
84        Self {
85            open_palette: Self::default_open_palette(),
86            quit: Self::default_quit(),
87            about: Self::default_about(),
88        }
89    }
90}
91
92impl KeyBindings {
93    fn default_open_palette() -> String {
94        "ctrl+p".into()
95    }
96
97    fn default_quit() -> String {
98        "q".into()
99    }
100
101    fn default_about() -> String {
102        "?".into()
103    }
104
105    /// Parse "ctrl+p" → (KeyCode::Char('p'), KeyModifiers::CONTROL).
106    pub fn parse_key(
107        s: &str,
108    ) -> Option<(crossterm::event::KeyCode, crossterm::event::KeyModifiers)> {
109        let s = s.to_lowercase();
110        let parts: Vec<&str> = s.split('+').collect();
111        let key_str = *parts.last()?;
112        let mut mods = crossterm::event::KeyModifiers::NONE;
113        for part in &parts[..parts.len().saturating_sub(1)] {
114            match *part {
115                "ctrl" => mods |= crossterm::event::KeyModifiers::CONTROL,
116                "alt" => mods |= crossterm::event::KeyModifiers::ALT,
117                "shift" => mods |= crossterm::event::KeyModifiers::SHIFT,
118                _ => return None,
119            }
120        }
121        let code = match key_str {
122            "space" => crossterm::event::KeyCode::Char(' '),
123            s if s.len() == 1 => crossterm::event::KeyCode::Char(s.chars().next()?),
124            _ => return None,
125        };
126        Some((code, mods))
127    }
128}
129
130/// Plugin-specific configuration (reserved for future use).
131#[derive(Debug, Clone, serde::Deserialize, serde::Serialize)]
132pub struct PluginConfig {}
133
134impl Config {
135    /// Load `config.toml` from `dir` or return a default config if the file
136    /// doesn't exist.
137    pub fn load_from(dir: &std::path::Path) -> Self {
138        Self::try_load_from(dir).unwrap_or_else(|_| Config::default())
139    }
140
141    /// Like `load_from`, but returns an error message instead of silently
142    /// falling back to defaults.
143    pub fn try_load_from(dir: &std::path::Path) -> Result<Self, String> {
144        let path = dir.join("config.toml");
145        if !path.exists() {
146            return Err("config.toml not found".into());
147        }
148        let content = std::fs::read_to_string(&path)
149            .map_err(|e| format!("Failed to read config.toml: {e}"))?;
150        toml::from_str(&content).map_err(|e| format!("Failed to parse config.toml: {e}"))
151    }
152
153    /// Write the config to `dir/config.toml`.
154    pub fn save_to(&self, dir: &std::path::Path) -> Result<(), Box<dyn std::error::Error>> {
155        let path = dir.join("config.toml");
156        let content = toml::to_string_pretty(self)?;
157        std::fs::write(&path, content)?;
158        Ok(())
159    }
160}
161
162/// Watches `config.toml` for changes via filesystem notifications, with
163/// periodic mtime polling as a fallback.
164///
165/// Call [`ConfigManager::poll`] once per frame in the main loop.  When the
166/// file has been modified externally `dirty` is set to `true` and the new
167/// config is available via [`ConfigManager::config`].
168pub struct ConfigManager {
169    dir: PathBuf,
170    config: Config,
171    last_modified: Option<SystemTime>,
172    /// Set to `true` by [`poll`](ConfigManager::poll) when the file changed.
173    pub dirty: bool,
174    /// Error message from the last load/parse attempt, cleared on ack.
175    error: Option<String>,
176    /// Main loop tick rate (how often the UI refreshes and polls for input).
177    tick_rate: Duration,
178    /// Throttle: only poll the filesystem every N frames (fallback path).
179    poll_skip: u32,
180    /// Filesystem watcher (kept alive for the lifetime of the manager).
181    _watcher: Option<RecommendedWatcher>,
182    /// Channel receiver for filesystem events.
183    event_rx: Option<mpsc::Receiver<notify::Result<notify::Event>>>,
184}
185
186impl fmt::Debug for ConfigManager {
187    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
188        f.debug_struct("ConfigManager")
189            .field("dir", &self.dir)
190            .field("config", &self.config)
191            .field("last_modified", &self.last_modified)
192            .field("dirty", &self.dirty)
193            .field("error", &self.error)
194            .field("tick_rate", &self.tick_rate)
195            .field("poll_skip", &self.poll_skip)
196            .finish()
197    }
198}
199
200impl ConfigManager {
201    /// Create a new manager, immediately loading the config from `dir` and
202    /// attempting to set up a filesystem watcher.
203    pub fn new(dir: PathBuf) -> Self {
204        let last_modified = dir
205            .join("config.toml")
206            .metadata()
207            .ok()
208            .and_then(|m| m.modified().ok());
209        let (config, error) = match Config::try_load_from(&dir) {
210            Ok(cfg) => (cfg, None),
211            Err(e) => (Config::default(), Some(e)),
212        };
213
214        let (watcher, event_rx) = Self::try_create_watcher(&dir);
215
216        ConfigManager {
217            dir,
218            config,
219            last_modified,
220            dirty: false,
221            error,
222            tick_rate: Duration::from_millis(100),
223            poll_skip: 0,
224            _watcher: watcher,
225            event_rx,
226        }
227    }
228
229    /// Create a new manager without a filesystem watcher (polling only).
230    /// Behaviour is identical to [`new`](Self::new) but skips the watcher
231    /// setup entirely.  Useful in tests where deterministic poll timing is
232    /// required.
233    #[cfg(test)]
234    pub fn new_polling_only(dir: PathBuf) -> Self {
235        let last_modified = dir
236            .join("config.toml")
237            .metadata()
238            .ok()
239            .and_then(|m| m.modified().ok());
240        let (config, error) = match Config::try_load_from(&dir) {
241            Ok(cfg) => (cfg, None),
242            Err(e) => (Config::default(), Some(e)),
243        };
244        ConfigManager {
245            dir,
246            config,
247            last_modified,
248            dirty: false,
249            error,
250            tick_rate: Duration::from_millis(100),
251            poll_skip: 0,
252            _watcher: None,
253            event_rx: None,
254        }
255    }
256
257    /// Try to set up a filesystem watcher on the config directory.
258    /// Returns `None` on any failure (e.g. platform not supported).
259    fn try_create_watcher(
260        dir: &std::path::Path,
261    ) -> (
262        Option<RecommendedWatcher>,
263        Option<mpsc::Receiver<notify::Result<notify::Event>>>,
264    ) {
265        let (tx, rx) = mpsc::channel();
266        match RecommendedWatcher::new(tx, notify::Config::default()) {
267            Ok(mut w) => match w.watch(dir, RecursiveMode::NonRecursive) {
268                Ok(()) => {
269                    log::info!("[santui] Filesystem watcher active on {:?}", dir);
270                    (Some(w), Some(rx))
271                }
272                Err(e) => {
273                    log::warn!("[santui] Failed to watch config dir: {e}; falling back to polling");
274                    (None, None)
275                }
276            },
277            Err(e) => {
278                log::warn!(
279                    "[santui] Failed to create filesystem watcher: {e}; falling back to polling"
280                );
281                (None, None)
282            }
283        }
284    }
285
286    /// Re-read config from disk.  Call this once per frame.
287    ///
288    /// When a filesystem watcher is active, events are drained immediately
289    /// (no throttling).  The mtime-based polling fallback runs every 30 frames
290    /// on platforms without a working watcher.
291    pub fn poll(&mut self) {
292        // Drain filesystem events immediately when watcher is available.
293        if let Some(ref rx) = self.event_rx {
294            let mut changed = false;
295            while let Ok(Ok(event)) = rx.try_recv() {
296                if Self::event_matches_config(&event) {
297                    changed = true;
298                }
299            }
300            if changed {
301                return self.reload();
302            }
303        }
304
305        // Polling fallback throttle.
306        self.poll_skip = self.poll_skip.saturating_sub(1);
307        if self.poll_skip > 0 {
308            return;
309        }
310        self.poll_skip = 30;
311        self.reload_if_modified();
312    }
313
314    /// Check whether a notify event applies to `config.toml`.
315    fn event_matches_config(event: &notify::Event) -> bool {
316        matches!(event.kind, EventKind::Create(_) | EventKind::Modify(_))
317            && event
318                .paths
319                .iter()
320                .any(|p| p.file_name().is_some_and(|n| n == "config.toml"))
321    }
322
323    /// Check if the file has been modified (by comparing mtime against
324    /// `last_modified`) and reload if so.  This is the polling fallback.
325    fn reload_if_modified(&mut self) {
326        let path = self.dir.join("config.toml");
327        let modified = match path.metadata().ok().and_then(|m| m.modified().ok()) {
328            Some(t) => t,
329            None => return,
330        };
331        let changed = match self.last_modified {
332            Some(last) => modified != last,
333            None => true,
334        };
335        if !changed {
336            return;
337        }
338        self.last_modified = Some(modified);
339        self.reload_inner();
340    }
341
342    /// Reload config from disk and update error/dirty state.
343    fn reload(&mut self) {
344        self.reload_if_modified();
345    }
346
347    fn reload_inner(&mut self) {
348        match Config::try_load_from(&self.dir) {
349            Ok(cfg) => {
350                self.config = cfg;
351                self.error = None;
352                self.dirty = true;
353            }
354            Err(e) => {
355                self.error = Some(e);
356            }
357        }
358    }
359
360    /// Acknowledge the dirty flag (call after applying changes).
361    pub fn ack(&mut self) {
362        self.dirty = false;
363    }
364
365    pub fn config(&self) -> &Config {
366        &self.config
367    }
368
369    /// Error message from the last failed config load/parse, if any.
370    pub fn error(&self) -> Option<&str> {
371        self.error.as_deref()
372    }
373
374    /// Update the `theme` field and immediately persist.
375    /// When selecting a built-in theme, custom overrides are cleared so they
376    /// don't leak into the newly chosen theme.
377    pub fn save_theme(&mut self, theme_name: &str) {
378        self.config.theme = Some(theme_name.to_string());
379        self.config.custom_theme = None;
380        self.persist();
381    }
382
383    /// Set custom theme colour overrides in config and persist.
384    pub fn save_custom_theme(&mut self, colors: CustomThemeColors) {
385        self.config.custom_theme = Some(colors);
386        self.persist();
387    }
388
389    pub fn tick_rate(&self) -> Duration {
390        self.tick_rate
391    }
392
393    pub fn set_tick_rate(&mut self, duration: Duration) {
394        self.tick_rate = duration;
395    }
396
397    /// Remove custom theme colour overrides from config and persist.
398    pub fn clear_custom_theme(&mut self) {
399        if self.config.custom_theme.is_some() {
400            self.config.custom_theme = None;
401            self.persist();
402        }
403    }
404
405    /// Write the in-memory config to disk and sync the modification timestamp
406    /// so reload calls don't re-detect our own write.
407    ///
408    /// Uses a temp-file + atomic rename to eliminate the TOCTOU window between
409    /// writing and reading back the mtime (see AGENTS.md: no fragile solutions).
410    fn persist(&mut self) {
411        let path = self.dir.join("config.toml");
412        let tmp_path = self.dir.join("config.toml.tmp");
413
414        let content = match toml::to_string_pretty(&self.config) {
415            Ok(c) => c,
416            Err(e) => {
417                log::error!("[santui] Failed to serialize config: {e}");
418                return;
419            }
420        };
421
422        if let Err(e) = std::fs::write(&tmp_path, &content) {
423            log::error!("[santui] Failed to write config: {e}");
424            let _ = std::fs::remove_file(&tmp_path);
425            return;
426        }
427
428        // Capture mtime before the atomic rename — no window for an external
429        // writer to slip in between our write and our metadata read.
430        self.last_modified = tmp_path.metadata().ok().and_then(|m| m.modified().ok());
431
432        if let Err(e) = std::fs::rename(&tmp_path, &path) {
433            log::error!("[santui] Failed to rename config: {e}");
434            let _ = std::fs::remove_file(&tmp_path);
435        }
436    }
437}
438
439#[cfg(test)]
440mod tests {
441    use super::*;
442    use crossterm::event::{KeyCode, KeyModifiers};
443    use std::sync::atomic::{AtomicUsize, Ordering};
444
445    static NEXT_ID: AtomicUsize = AtomicUsize::new(0);
446
447    struct TempDir {
448        path: PathBuf,
449    }
450
451    impl TempDir {
452        fn new() -> Self {
453            let id = NEXT_ID.fetch_add(1, Ordering::Relaxed);
454            let mut p = std::env::temp_dir();
455            p.push(format!("santui_cfg_test_{id}"));
456            let _ = std::fs::remove_dir_all(&p);
457            std::fs::create_dir_all(&p).unwrap();
458            TempDir { path: p }
459        }
460
461        fn path(&self) -> &std::path::Path {
462            &self.path
463        }
464    }
465
466    impl Drop for TempDir {
467        fn drop(&mut self) {
468            let _ = std::fs::remove_dir_all(&self.path);
469        }
470    }
471
472    #[test]
473    fn config_default_all_none() {
474        let cfg = Config::default();
475        assert!(cfg.theme.is_none());
476        assert!(cfg.custom_theme.is_none());
477        assert_eq!(cfg.keybindings.open_palette, "ctrl+p");
478        assert_eq!(cfg.keybindings.quit, "q");
479        assert_eq!(cfg.keybindings.about, "?");
480        assert!(cfg.plugins.is_none());
481        assert!(cfg.server.is_none());
482    }
483
484    #[test]
485    fn try_load_from_missing_dir_returns_err() {
486        let tmp = TempDir::new();
487        let result = Config::try_load_from(tmp.path());
488        assert!(result.is_err());
489        assert!(result.unwrap_err().contains("not found"));
490    }
491
492    #[test]
493    fn try_load_from_invalid_toml_returns_err() {
494        let tmp = TempDir::new();
495        std::fs::write(tmp.path().join("config.toml"), "not toml {{{").unwrap();
496        let result = Config::try_load_from(tmp.path());
497        assert!(result.is_err());
498        assert!(result.unwrap_err().contains("parse"));
499    }
500
501    #[test]
502    fn load_from_missing_dir_returns_default() {
503        let tmp = TempDir::new();
504        let cfg = Config::load_from(tmp.path());
505        assert!(cfg.theme.is_none());
506    }
507
508    #[test]
509    fn save_to_and_load_from_roundtrip() {
510        let tmp = TempDir::new();
511        let cfg = Config {
512            mouse_capture: false,
513            theme: Some("Nord".into()),
514            custom_theme: Some(CustomThemeColors {
515                name: None,
516                accent: Some("#ff8800".into()),
517                highlight: None,
518                logo: None,
519                text: None,
520                text_muted: None,
521                background: None,
522                background_panel: None,
523                background_overlay: None,
524                border: None,
525                success: None,
526                error: None,
527                inverted_text: None,
528            }),
529            keybindings: KeyBindings::default(),
530            plugins: None,
531            server: None,
532        };
533        cfg.save_to(tmp.path()).unwrap();
534        let loaded = Config::load_from(tmp.path());
535        assert_eq!(loaded.theme.as_deref(), Some("Nord"));
536    }
537
538    #[test]
539    fn config_manager_new_sets_last_modified() {
540        let tmp = TempDir::new();
541        let p = tmp.path().join("config.toml");
542        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
543        let mgr = ConfigManager::new(tmp.path().to_path_buf());
544        assert!(mgr.last_modified.is_some());
545        assert!(!mgr.dirty);
546    }
547
548    #[test]
549    fn config_manager_error_on_missing_file() {
550        let tmp = TempDir::new();
551        let mgr = ConfigManager::new(tmp.path().to_path_buf());
552        assert!(mgr.error().is_some());
553    }
554
555    #[test]
556    fn config_manager_error_cleared_on_successful_load() {
557        let tmp = TempDir::new();
558        let p = tmp.path().join("config.toml");
559        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
560        let mgr = ConfigManager::new(tmp.path().to_path_buf());
561        assert!(mgr.error().is_none());
562    }
563
564    #[test]
565    fn config_manager_clear_noop_when_no_custom() {
566        let tmp = TempDir::new();
567        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
568        mgr.clear_custom_theme();
569        assert!(mgr.config().custom_theme.is_none());
570    }
571
572    #[test]
573    fn config_manager_tick_rate_default_and_set() {
574        let tmp = TempDir::new();
575        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
576        assert_eq!(mgr.tick_rate(), Duration::from_millis(100));
577        mgr.set_tick_rate(Duration::from_millis(200));
578        assert_eq!(mgr.tick_rate(), Duration::from_millis(200));
579    }
580
581    #[test]
582    fn config_manager_poll_throttle_skips() {
583        let tmp = TempDir::new();
584        let p = tmp.path().join("config.toml");
585        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
586        let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
587        mgr.poll_skip = 5;
588        mgr.ack();
589        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
590        mgr.poll();
591        assert!(!mgr.dirty, "should skip due to poll_skip");
592    }
593
594    #[test]
595    fn config_manager_poll_no_file_returns_early() {
596        let tmp = TempDir::new();
597        let mut mgr = ConfigManager::new_polling_only(tmp.path().to_path_buf());
598        mgr.poll_skip = 0;
599        // File doesn't exist, metadata returns None → poll returns early.
600        mgr.poll();
601        assert!(!mgr.dirty);
602    }
603
604    #[test]
605    fn config_manager_watcher_detects_external_change() {
606        let tmp = TempDir::new();
607        let p = tmp.path().join("config.toml");
608        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
609        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
610        mgr.ack();
611        assert!(!mgr.dirty);
612
613        // Write a different config externally; the watcher should pick it up
614        // on the next poll (regardless of poll_skip).
615        std::fs::write(&p, r#"theme = "Dracula""#).unwrap();
616        mgr.poll();
617        assert!(mgr.dirty, "watcher should detect external change");
618        assert_eq!(mgr.config().theme.as_deref(), Some("Dracula"));
619    }
620
621    #[test]
622    fn config_manager_watcher_ignores_own_write() {
623        let tmp = TempDir::new();
624        let p = tmp.path().join("config.toml");
625        std::fs::write(&p, r#"theme = "Nord""#).unwrap();
626        let mut mgr = ConfigManager::new(tmp.path().to_path_buf());
627        mgr.ack();
628        assert!(!mgr.dirty);
629
630        // persist() sets last_modified, so the watcher event should not
631        // trigger a reload.
632        mgr.save_theme("Dracula");
633        assert!(!mgr.dirty, "own write should not set dirty");
634
635        // Reading the file back should show the saved value.
636        let loaded = Config::load_from(tmp.path());
637        assert_eq!(loaded.theme.as_deref(), Some("Dracula"));
638    }
639
640    // ------------------------------------------------------------------
641    // KeyBindings tests
642    // ------------------------------------------------------------------
643
644    #[test]
645    fn keybindings_default_values() {
646        let kb = KeyBindings::default();
647        assert_eq!(kb.open_palette, "ctrl+p");
648        assert_eq!(kb.quit, "q");
649        assert_eq!(kb.about, "?");
650    }
651
652    #[test]
653    fn parse_key_ctrl_p() {
654        let (code, mods) = KeyBindings::parse_key("ctrl+p").unwrap();
655        assert_eq!(code, KeyCode::Char('p'));
656        assert!(mods.contains(KeyModifiers::CONTROL));
657    }
658
659    #[test]
660    fn parse_key_plain_char() {
661        let (code, mods) = KeyBindings::parse_key("q").unwrap();
662        assert_eq!(code, KeyCode::Char('q'));
663        assert!(mods.is_empty());
664    }
665
666    #[test]
667    fn parse_key_question_mark() {
668        let (code, mods) = KeyBindings::parse_key("?").unwrap();
669        assert_eq!(code, KeyCode::Char('?'));
670        assert!(mods.is_empty());
671    }
672
673    #[test]
674    fn parse_key_ctrl_space() {
675        let (code, mods) = KeyBindings::parse_key("ctrl+space").unwrap();
676        assert_eq!(code, KeyCode::Char(' '));
677        assert!(mods.contains(KeyModifiers::CONTROL));
678    }
679
680    #[test]
681    fn parse_key_invalid_returns_none() {
682        assert!(KeyBindings::parse_key("xyz+abc").is_none());
683    }
684
685    #[test]
686    fn keybindings_deserialize_from_toml() {
687        let toml_str = r#"
688            [keybindings]
689            open_palette = "alt+p"
690            quit = "ctrl+q"
691            about = "shift+/"
692        "#;
693        let cfg: Config = toml::from_str(toml_str).unwrap();
694        assert_eq!(cfg.keybindings.open_palette, "alt+p");
695        assert_eq!(cfg.keybindings.quit, "ctrl+q");
696        assert_eq!(cfg.keybindings.about, "shift+/");
697    }
698
699    #[test]
700    fn keybindings_missing_from_toml_uses_defaults() {
701        let toml_str = r#"
702            theme = "Nord"
703        "#;
704        let cfg: Config = toml::from_str(toml_str).unwrap();
705        assert_eq!(cfg.keybindings.open_palette, "ctrl+p");
706        assert_eq!(cfg.keybindings.quit, "q");
707        assert_eq!(cfg.keybindings.about, "?");
708    }
709}