ghr-cli 0.6.0

A fast terminal dashboard for GitHub pull requests, issues, and notifications.
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

use crate::config::DEFAULT_COMMAND_PALETTE_KEY;

#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct KeyBinding {
    label: String,
    code: KeyCode,
    modifiers: KeyModifiers,
}

impl KeyBinding {
    pub(super) fn matches(&self, key: KeyEvent) -> bool {
        let expected_modifiers = command_palette_modifier_bits(self.modifiers);
        let actual_modifiers = command_palette_modifier_bits(key.modifiers);
        if expected_modifiers != actual_modifiers {
            return false;
        }
        if self.modifiers.contains(KeyModifiers::SHIFT)
            && !key.modifiers.contains(KeyModifiers::SHIFT)
        {
            return false;
        }

        match (&self.code, key.code) {
            (KeyCode::Char(expected), KeyCode::Char(actual))
                if expected_modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
            {
                expected.eq_ignore_ascii_case(&actual)
            }
            (KeyCode::Char(expected), KeyCode::Char(actual)) => *expected == actual,
            (expected, actual) => *expected == actual,
        }
    }

    pub(super) fn is_plain_text_char(&self) -> bool {
        matches!(self.code, KeyCode::Char(value) if !value.is_control())
            && !self
                .modifiers
                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
    }
}

pub(super) fn normalized_command_palette_key(value: &str) -> String {
    command_palette_key_binding(value).label
}

pub(super) fn command_palette_key_binding(value: &str) -> KeyBinding {
    parse_key_binding(value).unwrap_or_else(|| {
        parse_key_binding(DEFAULT_COMMAND_PALETTE_KEY).expect("default command palette key parses")
    })
}

fn parse_key_binding(value: &str) -> Option<KeyBinding> {
    let value = value.trim();
    if value.is_empty() {
        return None;
    }
    if value.chars().count() == 1 {
        let ch = value.chars().next()?;
        return Some(KeyBinding {
            label: value.to_string(),
            code: KeyCode::Char(ch),
            modifiers: KeyModifiers::NONE,
        });
    }

    let mut modifiers = KeyModifiers::NONE;
    let mut key = None;
    for raw_part in value.split('+') {
        let part = raw_part.trim();
        if part.is_empty() {
            return None;
        }
        match part.to_ascii_lowercase().as_str() {
            "ctrl" | "control" => modifiers.insert(KeyModifiers::CONTROL),
            "alt" | "option" => modifiers.insert(KeyModifiers::ALT),
            "shift" => modifiers.insert(KeyModifiers::SHIFT),
            _ if key.is_none() => key = Some(part.to_string()),
            _ => return None,
        }
    }

    let key = key?;
    let code = parse_key_code(&key)?;
    Some(KeyBinding {
        label: key_binding_label(modifiers, &code),
        code,
        modifiers,
    })
}

fn parse_key_code(value: &str) -> Option<KeyCode> {
    let lower = value.to_ascii_lowercase();
    match lower.as_str() {
        "esc" | "escape" => Some(KeyCode::Esc),
        "enter" | "return" => Some(KeyCode::Enter),
        "tab" => Some(KeyCode::Tab),
        "backtab" => Some(KeyCode::BackTab),
        "backspace" => Some(KeyCode::Backspace),
        "space" => Some(KeyCode::Char(' ')),
        _ if value.chars().count() == 1 => Some(KeyCode::Char(value.chars().next()?)),
        _ => None,
    }
}

fn key_binding_label(modifiers: KeyModifiers, code: &KeyCode) -> String {
    let mut parts = Vec::new();
    if modifiers.contains(KeyModifiers::CONTROL) {
        parts.push("Ctrl".to_string());
    }
    if modifiers.contains(KeyModifiers::ALT) {
        parts.push("Alt".to_string());
    }
    if modifiers.contains(KeyModifiers::SHIFT) {
        parts.push("Shift".to_string());
    }
    parts.push(key_code_label_with_modifiers(code, modifiers));
    parts.join("+")
}

fn key_code_label_with_modifiers(code: &KeyCode, modifiers: KeyModifiers) -> String {
    match code {
        KeyCode::Char(value)
            if modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
                && value.is_ascii_alphabetic() =>
        {
            value.to_ascii_uppercase().to_string()
        }
        _ => key_code_label(code),
    }
}

fn key_code_label(code: &KeyCode) -> String {
    match code {
        KeyCode::Esc => "Esc".to_string(),
        KeyCode::Enter => "Enter".to_string(),
        KeyCode::Tab => "Tab".to_string(),
        KeyCode::BackTab => "Shift+Tab".to_string(),
        KeyCode::Backspace => "Backspace".to_string(),
        KeyCode::Char(' ') => "Space".to_string(),
        KeyCode::Char(value) => value.to_string(),
        other => format!("{other:?}"),
    }
}

fn command_palette_modifier_bits(modifiers: KeyModifiers) -> KeyModifiers {
    modifiers & (KeyModifiers::CONTROL | KeyModifiers::ALT)
}