Skip to main content

flatland_client_ui/
keymap.rs

1//! Match key events against `client-settings.yaml` binding strings.
2
3use crate::input::{UiKeyCode, UiKeyEvent, UiKeyEventKind};
4use flatland_client_lib::ClientKeyBindings;
5
6pub fn key_matches(key: &UiKeyEvent, spec: &str) -> bool {
7    if key.kind != UiKeyEventKind::Press {
8        return false;
9    }
10    let parts: Vec<&str> = spec.split('+').map(str::trim).collect();
11    let want_shift = parts.iter().any(|p| *p == "Shift");
12    let want_ctrl = parts.iter().any(|p| *p == "Control" || *p == "Ctrl");
13    let key_part = parts.last().copied().unwrap_or("");
14    if key.modifiers.control != want_ctrl {
15        return false;
16    }
17    match key_part {
18        "Tab" => {
19            let has_shift = key.modifiers.shift || matches!(key.code, UiKeyCode::BackTab);
20            if has_shift != want_shift {
21                return false;
22            }
23            matches!(key.code, UiKeyCode::Tab | UiKeyCode::BackTab)
24        }
25        "Space" | " " => {
26            let has_shift = key.modifiers.shift;
27            if has_shift != want_shift {
28                return false;
29            }
30            matches!(key.code, UiKeyCode::Char(' '))
31        }
32        s if s.len() == 1 => {
33            let want = s.chars().next().unwrap_or('\0');
34            // US QWERTY: Shift+digit emits the shifted glyph (e.g. Shift+0 → ')').
35            let shifted_digit = match want {
36                '0' => ')',
37                '1' => '!',
38                '2' => '@',
39                '3' => '#',
40                '4' => '$',
41                '5' => '%',
42                '6' => '^',
43                '7' => '&',
44                '8' => '*',
45                '9' => '(',
46                _ => '\0',
47            };
48            let UiKeyCode::Char(c) = key.code else {
49                return false;
50            };
51            let char_ok = c.eq_ignore_ascii_case(&want)
52                || (want_shift && shifted_digit != '\0' && c == shifted_digit);
53            if !char_ok {
54                return false;
55            }
56            // Terminals often emit uppercase Char for Shift+letter; some omit the
57            // SHIFT modifier bit. Treat either as a match for Shift+letter binds.
58            // For Shift+digit, accept SHIFT mod or the shifted glyph alone.
59            let has_shift = key.modifiers.shift
60                || (want.is_ascii_alphabetic() && c.is_ascii_uppercase())
61                || (want_shift && shifted_digit != '\0' && c == shifted_digit);
62            has_shift == want_shift
63        }
64        _ => false,
65    }
66}
67
68pub fn combat_action_for_key(
69    key: &UiKeyEvent,
70    keys: &ClientKeyBindings,
71) -> Option<CombatKeyAction> {
72    if key_matches(key, &keys.target_t2) {
73        return Some(CombatKeyAction::CycleTargetT2 { reverse: false });
74    }
75    if key_matches(key, &keys.target_t1) {
76        let reverse = matches!(key.code, UiKeyCode::BackTab) || key.modifiers.shift;
77        return Some(CombatKeyAction::CycleTargetT1 { reverse });
78    }
79    if key_matches(key, &keys.clear_target_t2) {
80        return Some(CombatKeyAction::ClearTargetT2);
81    }
82    if key_matches(key, &keys.clear_target_t1) {
83        return Some(CombatKeyAction::ClearTargetT1);
84    }
85    if key_matches(key, &keys.auto_t2) {
86        return Some(CombatKeyAction::ToggleAutoT2);
87    }
88    if key_matches(key, &keys.auto_t1) {
89        return Some(CombatKeyAction::ToggleAutoT1);
90    }
91    if key_matches(key, &keys.loadout_menu) {
92        return Some(CombatKeyAction::ToggleLoadout);
93    }
94    if key_matches(key, &keys.rotation_editor) {
95        return Some(CombatKeyAction::ToggleRotationEditor);
96    }
97    if key_matches(key, &keys.dodge) {
98        return Some(CombatKeyAction::Dodge);
99    }
100    if key_matches(key, &keys.lunge) {
101        return Some(CombatKeyAction::Lunge);
102    }
103    if key_matches(key, &keys.block) {
104        return Some(CombatKeyAction::ToggleBlock);
105    }
106    // Hotbar 1–9 (no modifiers) — after clears so Shift+0 is not eaten as hotbar.
107    if !key.modifiers.control && !key.modifiers.alt {
108        if let UiKeyCode::Char(c) = key.code {
109            if c.is_ascii_digit() && c != '0' && !key.modifiers.shift {
110                let slot = c.to_digit(10).unwrap_or(0) as u8;
111                if (1..=9).contains(&slot) {
112                    return Some(CombatKeyAction::Hotbar(slot));
113                }
114            }
115        }
116    }
117    None
118}
119
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum CombatKeyAction {
122    CycleTargetT1 {
123        reverse: bool,
124    },
125    CycleTargetT2 {
126        reverse: bool,
127    },
128    ToggleAutoT1,
129    ToggleAutoT2,
130    ToggleLoadout,
131    ToggleRotationEditor,
132    Dodge,
133    Lunge,
134    ToggleBlock,
135    ClearTargetT1,
136    ClearTargetT2,
137    /// Hotbar key `1`–`9`.
138    Hotbar(u8),
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::input::{UiKeyEventKind, UiKeyModifiers};
145
146    fn mods(shift: bool, control: bool, alt: bool) -> UiKeyModifiers {
147        UiKeyModifiers {
148            shift,
149            control,
150            alt,
151        }
152    }
153
154    fn key(code: UiKeyCode, modifiers: UiKeyModifiers) -> UiKeyEvent {
155        UiKeyEvent {
156            code,
157            modifiers,
158            kind: UiKeyEventKind::Press,
159        }
160    }
161
162    #[test]
163    fn shift_r_matches_uppercase_char() {
164        let keys = ClientKeyBindings::default();
165        let event = key(UiKeyCode::Char('R'), mods(true, false, false));
166        assert!(key_matches(&event, &keys.auto_t2));
167        assert_eq!(
168            combat_action_for_key(&event, &keys),
169            Some(CombatKeyAction::ToggleAutoT2)
170        );
171    }
172
173    #[test]
174    fn shift_r_matches_uppercase_without_shift_mod() {
175        let keys = ClientKeyBindings::default();
176        let event = key(UiKeyCode::Char('R'), mods(false, false, false));
177        assert_eq!(
178            combat_action_for_key(&event, &keys),
179            Some(CombatKeyAction::ToggleAutoT2)
180        );
181    }
182
183    #[test]
184    fn plain_r_is_t1_auto() {
185        let keys = ClientKeyBindings::default();
186        let event = key(UiKeyCode::Char('r'), mods(false, false, false));
187        assert_eq!(
188            combat_action_for_key(&event, &keys),
189            Some(CombatKeyAction::ToggleAutoT1)
190        );
191    }
192
193    #[test]
194    fn shift_r_does_not_match_t1() {
195        let keys = ClientKeyBindings::default();
196        let event = key(UiKeyCode::Char('R'), mods(true, false, false));
197        assert!(!key_matches(&event, &keys.auto_t1));
198    }
199
200    #[test]
201    fn dodge_c_and_block_q() {
202        let keys = ClientKeyBindings::default();
203        assert_eq!(
204            combat_action_for_key(&key(UiKeyCode::Char('c'), mods(false, false, false)), &keys),
205            Some(CombatKeyAction::Dodge)
206        );
207        assert_eq!(
208            combat_action_for_key(&key(UiKeyCode::Char('q'), mods(false, false, false)), &keys),
209            Some(CombatKeyAction::ToggleBlock)
210        );
211    }
212
213    #[test]
214    fn shift_space_is_lunge() {
215        let keys = ClientKeyBindings::default();
216        assert_eq!(
217            combat_action_for_key(&key(UiKeyCode::Char(' '), mods(true, false, false)), &keys),
218            Some(CombatKeyAction::Lunge)
219        );
220    }
221
222    #[test]
223    fn clear_targets_on_0_and_shift_0() {
224        let keys = ClientKeyBindings::default();
225        assert_eq!(
226            combat_action_for_key(&key(UiKeyCode::Char('0'), mods(false, false, false)), &keys),
227            Some(CombatKeyAction::ClearTargetT1)
228        );
229        assert_eq!(
230            combat_action_for_key(&key(UiKeyCode::Char('0'), mods(true, false, false)), &keys),
231            Some(CombatKeyAction::ClearTargetT2)
232        );
233        assert_eq!(
234            combat_action_for_key(&key(UiKeyCode::Char(')'), mods(true, false, false)), &keys),
235            Some(CombatKeyAction::ClearTargetT2)
236        );
237        assert_eq!(
238            combat_action_for_key(&key(UiKeyCode::Char(')'), mods(false, false, false)), &keys),
239            Some(CombatKeyAction::ClearTargetT2)
240        );
241    }
242
243    #[test]
244    fn hotbar_digits() {
245        let keys = ClientKeyBindings::default();
246        assert_eq!(
247            combat_action_for_key(&key(UiKeyCode::Char('1'), mods(false, false, false)), &keys),
248            Some(CombatKeyAction::Hotbar(1))
249        );
250        assert_eq!(
251            combat_action_for_key(&key(UiKeyCode::Char('4'), mods(false, false, false)), &keys),
252            Some(CombatKeyAction::Hotbar(4))
253        );
254    }
255}