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