roam-sdk 0.3.0

Roam Research SDK and terminal UI client
Documentation
pub mod parser;
pub mod preset;

use std::collections::HashMap;

use crossterm::event::KeyEvent;

use crate::error::{Result, RoamError};
use preset::{get_preset, Action};

pub struct KeybindingMap {
    bindings: HashMap<KeyEvent, Action>,
}

impl KeybindingMap {
    pub fn from_preset(name: &str, overrides: &HashMap<String, String>) -> Result<Self> {
        let mut bindings = get_preset(name)
            .ok_or_else(|| RoamError::Config(format!("Unknown keybinding preset: {}", name)))?;

        for (action_name, key_str) in overrides {
            let action = Action::from_str(action_name)
                .ok_or_else(|| RoamError::Config(format!("Unknown action: {}", action_name)))?;
            let key_event = parser::parse_key(key_str)?;

            bindings.retain(|_, v| v != &action);
            bindings.insert(key_event, action);
        }

        Ok(Self { bindings })
    }

    pub fn resolve(&self, key: &KeyEvent) -> Option<&Action> {
        self.bindings.get(key).or_else(|| {
            // On terminals with enhanced keyboard protocol (macOS), the received
            // KeyEvent may carry extra kind/state fields that prevent a direct
            // HashMap match. Fall back to matching only code + modifiers.
            self.bindings.iter().find_map(|(k, v)| {
                if k.code == key.code && k.modifiers == key.modifiers {
                    Some(v)
                } else {
                    None
                }
            })
        })
    }

    pub fn hints(&self) -> Vec<(String, &'static str)> {
        let important = [
            Action::Quit,
            Action::Search,
            Action::Help,
            Action::MoveUp,
            Action::MoveDown,
        ];

        let mut hints = Vec::new();
        for action in &important {
            if let Some((key_event, _)) = self.bindings.iter().find(|(_, a)| *a == action) {
                hints.push((format_key_event(key_event), action.hint_text()));
            }
        }
        hints
    }

    pub fn all_hints(&self) -> Vec<(String, &'static str)> {
        let display_order = [
            Action::MoveUp,
            Action::MoveDown,
            Action::CursorLeft,
            Action::CursorRight,
            Action::Collapse,
            Action::Expand,
            Action::Enter,
            Action::Exit,
            Action::EditBlock,
            Action::CreateBlock,
            Action::Search,
            Action::QuickSwitcher,
            Action::Indent,
            Action::Unindent,
            Action::Undo,
            Action::Redo,
            Action::NavBack,
            Action::NavForward,
            Action::GoDaily,
            Action::NextDay,
            Action::PrevDay,
            Action::ToggleSidebar,
            Action::Help,
            Action::Quit,
        ];

        let mut hints = Vec::new();
        for action in &display_order {
            if let Some((key_event, _)) = self.bindings.iter().find(|(_, a)| *a == action) {
                hints.push((format_key_event(key_event), action.hint_text()));
            }
        }
        hints
    }
}

fn format_key_event(key: &KeyEvent) -> String {
    use crossterm::event::{KeyCode, KeyModifiers};

    let mut parts = Vec::new();

    if key.modifiers.contains(KeyModifiers::CONTROL) {
        parts.push("Ctrl".to_string());
    }
    if key.modifiers.contains(KeyModifiers::ALT) {
        parts.push("Alt".to_string());
    }
    if key.modifiers.contains(KeyModifiers::SHIFT) {
        parts.push("Shift".to_string());
    }

    let key_str = match key.code {
        KeyCode::Char(c) => c.to_string(),
        KeyCode::Enter => "Enter".to_string(),
        KeyCode::Esc => "Esc".to_string(),
        KeyCode::Tab => "Tab".to_string(),
        KeyCode::BackTab => "BackTab".to_string(),
        KeyCode::Backspace => "Backspace".to_string(),
        KeyCode::Delete => "Delete".to_string(),
        KeyCode::Up => "".to_string(),
        KeyCode::Down => "".to_string(),
        KeyCode::Left => "".to_string(),
        KeyCode::Right => "".to_string(),
        KeyCode::Home => "Home".to_string(),
        KeyCode::End => "End".to_string(),
        KeyCode::PageUp => "PageUp".to_string(),
        KeyCode::PageDown => "PageDown".to_string(),
        KeyCode::F(n) => format!("F{}", n),
        _ => "?".to_string(),
    };
    parts.push(key_str);

    parts.join("+")
}

#[cfg(test)]
mod tests {
    use super::*;
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

    #[test]
    fn from_preset_vim_resolves_j_to_move_down() {
        let map = KeybindingMap::from_preset("vim", &HashMap::new()).unwrap();
        let key = KeyEvent::new(KeyCode::Char('j'), KeyModifiers::NONE);
        assert_eq!(map.resolve(&key), Some(&Action::MoveDown));
    }

    #[test]
    fn from_preset_vim_resolves_q_to_quit() {
        let map = KeybindingMap::from_preset("vim", &HashMap::new()).unwrap();
        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
        assert_eq!(map.resolve(&key), Some(&Action::Quit));
    }

    #[test]
    fn override_replaces_existing_binding() {
        let mut overrides = HashMap::new();
        overrides.insert("quit".into(), "Ctrl+q".into());

        let map = KeybindingMap::from_preset("vim", &overrides).unwrap();

        let old_key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
        assert_eq!(map.resolve(&old_key), None);

        let new_key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
        assert_eq!(map.resolve(&new_key), Some(&Action::Quit));
    }

    #[test]
    fn unknown_preset_returns_error() {
        let result = KeybindingMap::from_preset("unknown", &HashMap::new());
        assert!(result.is_err());
    }

    #[test]
    fn unknown_action_in_override_returns_error() {
        let mut overrides = HashMap::new();
        overrides.insert("nonexistent".into(), "q".into());

        let result = KeybindingMap::from_preset("vim", &overrides);
        assert!(result.is_err());
    }

    #[test]
    fn resolve_returns_none_for_unbound_key() {
        let map = KeybindingMap::from_preset("vim", &HashMap::new()).unwrap();
        let key = KeyEvent::new(KeyCode::Char('z'), KeyModifiers::NONE);
        assert_eq!(map.resolve(&key), None);
    }

    #[test]
    fn hints_returns_important_actions() {
        let map = KeybindingMap::from_preset("vim", &HashMap::new()).unwrap();
        let hints = map.hints();
        let hint_labels: Vec<&str> = hints.iter().map(|(_, label)| *label).collect();
        assert!(hint_labels.contains(&"quit"));
        assert!(hint_labels.contains(&"search"));
    }

    #[test]
    fn all_hints_includes_all_actions() {
        let map = KeybindingMap::from_preset("vim", &HashMap::new()).unwrap();
        let all = map.all_hints();
        let labels: Vec<&str> = all.iter().map(|(_, label)| *label).collect();
        assert!(labels.contains(&"back"));
        assert!(labels.contains(&"forward"));
        assert!(labels.contains(&"quit"));
        assert!(labels.contains(&"search"));
        assert!(labels.contains(&"edit"));
        assert!(labels.contains(&"undo"));
        assert!(labels.contains(&"redo"));
        assert!(labels.contains(&"switcher"));
        assert!(all.len() > map.hints().len());
    }

    #[test]
    fn format_key_event_simple_char() {
        let key = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
        assert_eq!(format_key_event(&key), "q");
    }

    #[test]
    fn format_key_event_with_ctrl() {
        let key = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
        assert_eq!(format_key_event(&key), "Ctrl+c");
    }

    #[test]
    fn format_key_event_arrow() {
        let key = KeyEvent::new(KeyCode::Up, KeyModifiers::NONE);
        assert_eq!(format_key_event(&key), "");
    }

    #[test]
    fn resolve_matches_despite_extra_state_bits() {
        use crossterm::event::{KeyEventKind, KeyEventState};
        let map = KeybindingMap::from_preset("vim", &HashMap::new()).unwrap();
        // Simulate terminal sending Ctrl+K with extra state bits (e.g. CAPS_LOCK)
        let key = KeyEvent {
            code: KeyCode::Char('k'),
            modifiers: KeyModifiers::CONTROL,
            kind: KeyEventKind::Press,
            state: KeyEventState::CAPS_LOCK,
        };
        assert_eq!(map.resolve(&key), Some(&Action::QuickSwitcher));
    }
}