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