flatland-client-ui 0.2.32

Engine-agnostic play client UI state (world grid, input, presentation)
Documentation
//! Match key events against `client-settings.yaml` binding strings.

use crate::input::{UiKeyCode, UiKeyEvent, UiKeyEventKind};
use flatland_client_lib::ClientKeyBindings;

pub fn key_matches(key: &UiKeyEvent, spec: &str) -> bool {
    if key.kind != UiKeyEventKind::Press {
        return false;
    }
    let parts: Vec<&str> = spec.split('+').map(str::trim).collect();
    let want_shift = parts.iter().any(|p| *p == "Shift");
    let want_ctrl = parts.iter().any(|p| *p == "Control" || *p == "Ctrl");
    let key_part = parts.last().copied().unwrap_or("");
    if key.modifiers.control != want_ctrl {
        return false;
    }
    match key_part {
        "Tab" => {
            let has_shift = key.modifiers.shift || matches!(key.code, UiKeyCode::BackTab);
            if has_shift != want_shift {
                return false;
            }
            matches!(key.code, UiKeyCode::Tab | UiKeyCode::BackTab)
        }
        "Space" | " " => {
            let has_shift = key.modifiers.shift;
            if has_shift != want_shift {
                return false;
            }
            matches!(key.code, UiKeyCode::Char(' '))
        }
        s if s.len() == 1 => {
            let want = s.chars().next().unwrap_or('\0');
            // US QWERTY: Shift+digit emits the shifted glyph (e.g. Shift+0 → ')').
            let shifted_digit = match want {
                '0' => ')',
                '1' => '!',
                '2' => '@',
                '3' => '#',
                '4' => '$',
                '5' => '%',
                '6' => '^',
                '7' => '&',
                '8' => '*',
                '9' => '(',
                _ => '\0',
            };
            let UiKeyCode::Char(c) = key.code else {
                return false;
            };
            let char_ok = c.eq_ignore_ascii_case(&want)
                || (want_shift && shifted_digit != '\0' && c == shifted_digit);
            if !char_ok {
                return false;
            }
            // Terminals often emit uppercase Char for Shift+letter; some omit the
            // SHIFT modifier bit. Treat either as a match for Shift+letter binds.
            // For Shift+digit, accept SHIFT mod or the shifted glyph alone.
            let has_shift = key.modifiers.shift
                || (want.is_ascii_alphabetic() && c.is_ascii_uppercase())
                || (want_shift && shifted_digit != '\0' && c == shifted_digit);
            has_shift == want_shift
        }
        _ => false,
    }
}

pub fn combat_action_for_key(
    key: &UiKeyEvent,
    keys: &ClientKeyBindings,
) -> Option<CombatKeyAction> {
    if key_matches(key, &keys.target_t2) {
        return Some(CombatKeyAction::CycleTargetT2 { reverse: false });
    }
    if key_matches(key, &keys.target_t1) {
        let reverse = matches!(key.code, UiKeyCode::BackTab) || key.modifiers.shift;
        return Some(CombatKeyAction::CycleTargetT1 { reverse });
    }
    if key_matches(key, &keys.clear_target_t2) {
        return Some(CombatKeyAction::ClearTargetT2);
    }
    if key_matches(key, &keys.clear_target_t1) {
        return Some(CombatKeyAction::ClearTargetT1);
    }
    if key_matches(key, &keys.auto_t2) {
        return Some(CombatKeyAction::ToggleAutoT2);
    }
    if key_matches(key, &keys.auto_t1) {
        return Some(CombatKeyAction::ToggleAutoT1);
    }
    if key_matches(key, &keys.loadout_menu) {
        return Some(CombatKeyAction::ToggleLoadout);
    }
    if key_matches(key, &keys.rotation_editor) {
        return Some(CombatKeyAction::ToggleRotationEditor);
    }
    if key_matches(key, &keys.dodge) {
        return Some(CombatKeyAction::Dodge);
    }
    if key_matches(key, &keys.lunge) {
        return Some(CombatKeyAction::Lunge);
    }
    if key_matches(key, &keys.block) {
        return Some(CombatKeyAction::ToggleBlock);
    }
    // Hotbar 1–9 (no modifiers) — after clears so Shift+0 is not eaten as hotbar.
    if !key.modifiers.control && !key.modifiers.alt {
        if let UiKeyCode::Char(c) = key.code {
            if c.is_ascii_digit() && c != '0' && !key.modifiers.shift {
                let slot = c.to_digit(10).unwrap_or(0) as u8;
                if (1..=9).contains(&slot) {
                    return Some(CombatKeyAction::Hotbar(slot));
                }
            }
        }
    }
    None
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum CombatKeyAction {
    CycleTargetT1 {
        reverse: bool,
    },
    CycleTargetT2 {
        reverse: bool,
    },
    ToggleAutoT1,
    ToggleAutoT2,
    ToggleLoadout,
    ToggleRotationEditor,
    Dodge,
    Lunge,
    ToggleBlock,
    ClearTargetT1,
    ClearTargetT2,
    /// Hotbar key `1`–`9`.
    Hotbar(u8),
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::input::{UiKeyEventKind, UiKeyModifiers};

    fn mods(shift: bool, control: bool, alt: bool) -> UiKeyModifiers {
        UiKeyModifiers {
            shift,
            control,
            alt,
        }
    }

    fn key(code: UiKeyCode, modifiers: UiKeyModifiers) -> UiKeyEvent {
        UiKeyEvent {
            code,
            modifiers,
            kind: UiKeyEventKind::Press,
        }
    }

    #[test]
    fn shift_r_matches_uppercase_char() {
        let keys = ClientKeyBindings::default();
        let event = key(UiKeyCode::Char('R'), mods(true, false, false));
        assert!(key_matches(&event, &keys.auto_t2));
        assert_eq!(
            combat_action_for_key(&event, &keys),
            Some(CombatKeyAction::ToggleAutoT2)
        );
    }

    #[test]
    fn shift_r_matches_uppercase_without_shift_mod() {
        let keys = ClientKeyBindings::default();
        let event = key(UiKeyCode::Char('R'), mods(false, false, false));
        assert_eq!(
            combat_action_for_key(&event, &keys),
            Some(CombatKeyAction::ToggleAutoT2)
        );
    }

    #[test]
    fn plain_r_is_t1_auto() {
        let keys = ClientKeyBindings::default();
        let event = key(UiKeyCode::Char('r'), mods(false, false, false));
        assert_eq!(
            combat_action_for_key(&event, &keys),
            Some(CombatKeyAction::ToggleAutoT1)
        );
    }

    #[test]
    fn shift_r_does_not_match_t1() {
        let keys = ClientKeyBindings::default();
        let event = key(UiKeyCode::Char('R'), mods(true, false, false));
        assert!(!key_matches(&event, &keys.auto_t1));
    }

    #[test]
    fn dodge_c_and_block_q() {
        let keys = ClientKeyBindings::default();
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char('c'), mods(false, false, false)), &keys),
            Some(CombatKeyAction::Dodge)
        );
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char('q'), mods(false, false, false)), &keys),
            Some(CombatKeyAction::ToggleBlock)
        );
    }

    #[test]
    fn shift_space_is_lunge() {
        let keys = ClientKeyBindings::default();
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char(' '), mods(true, false, false)), &keys),
            Some(CombatKeyAction::Lunge)
        );
    }

    #[test]
    fn clear_targets_on_0_and_shift_0() {
        let keys = ClientKeyBindings::default();
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char('0'), mods(false, false, false)), &keys),
            Some(CombatKeyAction::ClearTargetT1)
        );
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char('0'), mods(true, false, false)), &keys),
            Some(CombatKeyAction::ClearTargetT2)
        );
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char(')'), mods(true, false, false)), &keys),
            Some(CombatKeyAction::ClearTargetT2)
        );
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char(')'), mods(false, false, false)), &keys),
            Some(CombatKeyAction::ClearTargetT2)
        );
    }

    #[test]
    fn hotbar_digits() {
        let keys = ClientKeyBindings::default();
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char('1'), mods(false, false, false)), &keys),
            Some(CombatKeyAction::Hotbar(1))
        );
        assert_eq!(
            combat_action_for_key(&key(UiKeyCode::Char('4'), mods(false, false, false)), &keys),
            Some(CombatKeyAction::Hotbar(4))
        );
    }
}