Skip to main content

flatland_client_ui/
app.rs

1use std::collections::HashMap;
2use std::time::{Duration, Instant};
3
4use crate::input::{UiKeyCode, UiKeyEvent, UiKeyEventKind};
5use flatland_client_lib::{ClientKeyBindings, RotationEditorMode};
6
7use crate::keymap::{combat_action_for_key, CombatKeyAction};
8
9/// Which modal overlay is open (if any).
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
11pub enum ActiveOverlay {
12    #[default]
13    None,
14    Loadout,
15    RotationEditor(RotationEditorMode),
16}
17
18/// Fallback stop window when the terminal omits key-release events.
19/// Long enough to bridge OS key-repeat initial delay; release events stop instantly.
20pub const MOVEMENT_IDLE_TIMEOUT: Duration = Duration::from_millis(320);
21/// After a second direction joins, ignore releases briefly (terminals often emit a
22/// fake Release for the first key when the second is pressed).
23const CHORD_RELEASE_GRACE: Duration = Duration::from_millis(220);
24const SPRINT_SHIFT_REFRESH: Duration = Duration::from_millis(700);
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
27enum DirectionKey {
28    Up,
29    Down,
30    Left,
31    Right,
32}
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
35enum VerticalKey {
36    Up,
37    Down,
38}
39
40fn direction_from_key(code: UiKeyCode) -> Option<DirectionKey> {
41    match code {
42        UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
43            'w' => Some(DirectionKey::Up),
44            's' => Some(DirectionKey::Down),
45            'a' => Some(DirectionKey::Left),
46            'd' => Some(DirectionKey::Right),
47            _ => None,
48        },
49        UiKeyCode::Up => Some(DirectionKey::Up),
50        UiKeyCode::Down => Some(DirectionKey::Down),
51        UiKeyCode::Left => Some(DirectionKey::Left),
52        UiKeyCode::Right => Some(DirectionKey::Right),
53        _ => None,
54    }
55}
56
57fn is_shift_key(code: UiKeyCode) -> bool {
58    matches!(code, UiKeyCode::ShiftLeft | UiKeyCode::ShiftRight)
59}
60
61#[derive(Debug, Default)]
62pub struct MapTargetState {
63    pub active: bool,
64    pub cursor_x: f32,
65    pub cursor_y: f32,
66}
67
68impl MapTargetState {
69    pub fn activate_at(&mut self, x: f32, y: f32) {
70        self.active = true;
71        self.cursor_x = x;
72        self.cursor_y = y;
73    }
74
75    pub fn deactivate(&mut self) {
76        self.active = false;
77    }
78
79    pub fn nudge(&mut self, dx: i32, dy: i32, max_x: f32, max_y: f32) {
80        self.cursor_x = (self.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
81        self.cursor_y = (self.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
82    }
83}
84
85/// Keyboard movement state.
86///
87/// Cardinals: WASD / arrows. Hold two cardinals for diagonal (mouse pathing also works).
88/// Space hard-stops. Idle timeout is a fallback if the terminal omits releases.
89#[derive(Debug)]
90pub struct MovementState {
91    held_dirs: HashMap<DirectionKey, Instant>,
92    vertical_held: HashMap<VerticalKey, Instant>,
93    shift_held: bool,
94    sprint_until: Instant,
95    sprint_toggle: bool,
96    last_forward: f32,
97    last_strafe: f32,
98    /// Set when a second WASD direction joins; suppresses bogus releases briefly.
99    chord_formed_at: Option<Instant>,
100    pub keys: ClientKeyBindings,
101}
102
103impl Default for MovementState {
104    fn default() -> Self {
105        Self {
106            held_dirs: HashMap::new(),
107            vertical_held: HashMap::new(),
108            shift_held: false,
109            sprint_until: Instant::now(),
110            sprint_toggle: false,
111            last_forward: 0.0,
112            last_strafe: 0.0,
113            chord_formed_at: None,
114            keys: ClientKeyBindings::default(),
115        }
116    }
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum MovementInput {
121    Stop,
122    Forward,
123    Back,
124    Left,
125    Right,
126    ForwardLeft,
127    ForwardRight,
128    BackLeft,
129    BackRight,
130}
131
132impl MovementInput {
133    pub fn components(self) -> (f32, f32) {
134        let (mut forward, mut strafe): (f32, f32) = match self {
135            Self::Stop => (0.0, 0.0),
136            Self::Forward => (1.0, 0.0),
137            Self::Back => (-1.0, 0.0),
138            Self::Left => (0.0, -1.0),
139            Self::Right => (0.0, 1.0),
140            Self::ForwardLeft => (1.0, -1.0),
141            Self::ForwardRight => (1.0, 1.0),
142            Self::BackLeft => (-1.0, -1.0),
143            Self::BackRight => (-1.0, 1.0),
144        };
145
146        let len = (forward * forward + strafe * strafe).sqrt();
147        if len > 1.0 {
148            forward /= len;
149            strafe /= len;
150        }
151        (forward, strafe)
152    }
153
154    pub fn label(self) -> &'static str {
155        match self {
156            Self::Stop => "stop",
157            Self::Forward => "up",
158            Self::Back => "down",
159            Self::Left => "left",
160            Self::Right => "right",
161            Self::ForwardLeft => "up-left",
162            Self::ForwardRight => "up-right",
163            Self::BackLeft => "down-left",
164            Self::BackRight => "down-right",
165        }
166    }
167}
168
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170pub enum InputAction {
171    Quit,
172    Harvest,
173    Pickup,
174    Craft,
175    Interact,
176    TestDamage,
177    CycleCombatTarget {
178        reverse: bool,
179    },
180    CycleCombatTargetT2 {
181        reverse: bool,
182    },
183    AdvanceRotationT1,
184    AdvanceRotationT2,
185    ToggleAutoT1,
186    ToggleAutoT2,
187    ToggleLoadout,
188    ToggleRotationEditor,
189    LoadoutAssignT1,
190    LoadoutAssignT2,
191    LoadoutMenuUp,
192    LoadoutMenuDown,
193    RotationEditorListUp,
194    RotationEditorListDown,
195    RotationEditorEdit,
196    RotationEditorNew,
197    RotationEditorDelete,
198    RotationEditorBack,
199    RotationEditorAddAbility,
200    RotationEditorRemoveAbility,
201    RotationEditorMoveAbilityUp,
202    RotationEditorMoveAbilityDown,
203    RotationEditorAbilityUp,
204    RotationEditorAbilityDown,
205    RotationEditorPickerUp,
206    RotationEditorPickerDown,
207    RotationEditorPickAbility,
208    RotationEditorRename,
209    RotationEditorConfirmLabel,
210    RotationEditorLabelBackspace,
211    RotationEditorLabelChar(char),
212    RotationEditorSave,
213    CloseOverlay,
214    ClearCombatTarget,
215    ToggleStats,
216    ToggleInventory,
217    ToggleKeychain,
218    ToggleQuestMenu,
219    QuestMenuUp,
220    QuestMenuDown,
221    QuestWithdraw,
222    ToggleHelp,
223    CycleHudView,
224    StartChat {
225        whisper: bool,
226    },
227    SubmitChat,
228    CancelChat,
229    Dodge,
230    Lunge,
231    ToggleBlock,
232    ToggleSprintMode,
233    ToggleMapTarget,
234    ConfirmMapTarget,
235    CancelMapTarget,
236    MapTargetNudge {
237        dx: i32,
238        dy: i32,
239    },
240    CancelAutoNav,
241    /// Hard stop: clear held keys and cancel auto-nav (Space).
242    StopMovement,
243    /// Context use: interact → pickup → harvest.
244    UseWorld,
245    CastHotbar {
246        slot: u8,
247    },
248    ClearCombatTargetT2,
249    None,
250}
251
252fn rotation_editor_action(key: UiKeyEvent, mode: RotationEditorMode) -> Option<InputAction> {
253    let shift = key.modifiers.shift;
254    Some(match mode {
255        RotationEditorMode::List => match key.code {
256            UiKeyCode::Esc => InputAction::CloseOverlay,
257            UiKeyCode::Up => InputAction::RotationEditorListUp,
258            UiKeyCode::Down => InputAction::RotationEditorListDown,
259            UiKeyCode::Enter => InputAction::RotationEditorEdit,
260            UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
261                'n' => InputAction::RotationEditorNew,
262                'd' => InputAction::RotationEditorDelete,
263                _ => return None,
264            },
265            _ => return None,
266        },
267        RotationEditorMode::EditSequence => match key.code {
268            UiKeyCode::Up if shift => InputAction::RotationEditorMoveAbilityUp,
269            UiKeyCode::Down if shift => InputAction::RotationEditorMoveAbilityDown,
270            UiKeyCode::Esc => InputAction::RotationEditorBack,
271            UiKeyCode::Char('[') | UiKeyCode::Char(';') => InputAction::RotationEditorMoveAbilityUp,
272            UiKeyCode::Char(']') | UiKeyCode::Char('/') | UiKeyCode::Char('\\') => {
273                InputAction::RotationEditorMoveAbilityDown
274            }
275            UiKeyCode::Up => InputAction::RotationEditorAbilityUp,
276            UiKeyCode::Down => InputAction::RotationEditorAbilityDown,
277            UiKeyCode::Delete => InputAction::RotationEditorRemoveAbility,
278            UiKeyCode::Enter => InputAction::RotationEditorSave,
279            UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
280                'a' => InputAction::RotationEditorAddAbility,
281                'x' => InputAction::RotationEditorRemoveAbility,
282                'r' => InputAction::RotationEditorRename,
283                's' => InputAction::RotationEditorSave,
284                _ => return None,
285            },
286            _ => return None,
287        },
288        RotationEditorMode::PickAbility => match key.code {
289            UiKeyCode::Esc => InputAction::RotationEditorBack,
290            UiKeyCode::Up => InputAction::RotationEditorPickerUp,
291            UiKeyCode::Down => InputAction::RotationEditorPickerDown,
292            UiKeyCode::Enter => InputAction::RotationEditorPickAbility,
293            _ => return None,
294        },
295        RotationEditorMode::EditLabel => match key.code {
296            UiKeyCode::Esc => InputAction::RotationEditorBack,
297            UiKeyCode::Enter => InputAction::RotationEditorConfirmLabel,
298            UiKeyCode::Backspace => InputAction::RotationEditorLabelBackspace,
299            UiKeyCode::Char(c) if !key.modifiers.control => InputAction::RotationEditorLabelChar(c),
300            _ => return None,
301        },
302    })
303}
304
305impl MovementState {
306    pub fn with_keys(keys: ClientKeyBindings) -> Self {
307        Self {
308            keys,
309            ..Default::default()
310        }
311    }
312
313    pub fn idle_timeout(&self) -> Duration {
314        MOVEMENT_IDLE_TIMEOUT
315    }
316
317    /// Mark `dir` as held. Soft-refresh companions that are still within the idle
318    /// window so a chord survives when the terminal only repeats the last key.
319    /// Never resurrects keys that have already timed out.
320    fn touch_dir(&mut self, dir: DirectionKey) {
321        let now = Instant::now();
322        let idle = self.idle_timeout();
323        let joining_chord = !self.held_dirs.contains_key(&dir)
324            && self.held_dirs.values().any(|at| at.elapsed() < idle);
325        if joining_chord {
326            self.chord_formed_at = Some(now);
327        }
328        self.held_dirs.insert(dir, now);
329        for (other, at) in self.held_dirs.iter_mut() {
330            if *other != dir && at.elapsed() < idle {
331                *at = now;
332            }
333        }
334    }
335
336    fn release_dir(&mut self, dir: DirectionKey) {
337        self.held_dirs.remove(&dir);
338    }
339
340    fn refresh_sprint(&mut self, key: &UiKeyEvent) {
341        let shift = key.modifiers.shift
342            || matches!(
343                key.code,
344                UiKeyCode::Char(c)
345                    if c.is_ascii_uppercase() && direction_from_key(key.code).is_some()
346            );
347        if shift {
348            self.sprint_until = Instant::now() + SPRINT_SHIFT_REFRESH;
349        }
350    }
351
352    fn dir_active(&self, dir: DirectionKey) -> bool {
353        let idle = self.idle_timeout();
354        self.held_dirs
355            .get(&dir)
356            .is_some_and(|at| at.elapsed() < idle)
357    }
358
359    fn vertical_active(&self, key: VerticalKey) -> bool {
360        let idle = self.idle_timeout();
361        self.vertical_held
362            .get(&key)
363            .is_some_and(|at| at.elapsed() < idle)
364    }
365
366    pub fn reset(&mut self) {
367        self.held_dirs.clear();
368        self.vertical_held.clear();
369        self.shift_held = false;
370        self.sprint_until = Instant::now();
371        self.chord_formed_at = None;
372    }
373
374    pub fn sprint_mode(&self) -> bool {
375        self.sprint_toggle
376    }
377
378    pub fn toggle_sprint_mode(&mut self) {
379        self.sprint_toggle = !self.sprint_toggle;
380    }
381
382    pub fn vertical_axis(&self) -> f32 {
383        let up = self.vertical_active(VerticalKey::Up);
384        let down = self.vertical_active(VerticalKey::Down);
385        match (up, down) {
386            (true, false) => 1.0,
387            (false, true) => -1.0,
388            _ => 0.0,
389        }
390    }
391
392    pub fn sprinting(&self) -> bool {
393        self.sprint_toggle || self.shift_held || Instant::now() < self.sprint_until
394    }
395
396    pub fn last_move_axes(&self) -> (f32, f32) {
397        (self.last_forward, self.last_strafe)
398    }
399
400    fn remember_movement(&mut self) {
401        let (forward, strafe) = self.current().components();
402        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
403            self.last_forward = forward;
404            self.last_strafe = strafe;
405        }
406    }
407
408    pub fn current(&self) -> MovementInput {
409        let up = self.dir_active(DirectionKey::Up);
410        let down = self.dir_active(DirectionKey::Down);
411        let left = self.dir_active(DirectionKey::Left);
412        let right = self.dir_active(DirectionKey::Right);
413
414        // Opposing axes cancel; remaining axes combine into 8-way movement.
415        let forward = match (up, down) {
416            (true, false) => 1,
417            (false, true) => -1,
418            _ => 0,
419        };
420        let strafe = match (left, right) {
421            (true, false) => -1,
422            (false, true) => 1,
423            _ => 0,
424        };
425        match (forward, strafe) {
426            (1, 0) => MovementInput::Forward,
427            (-1, 0) => MovementInput::Back,
428            (0, -1) => MovementInput::Left,
429            (0, 1) => MovementInput::Right,
430            (1, -1) => MovementInput::ForwardLeft,
431            (1, 1) => MovementInput::ForwardRight,
432            (-1, -1) => MovementInput::BackLeft,
433            (-1, 1) => MovementInput::BackRight,
434            _ => MovementInput::Stop,
435        }
436    }
437
438    pub fn apply_ui_key(
439        &mut self,
440        key: UiKeyEvent,
441        overlay: ActiveOverlay,
442        map_target_active: bool,
443    ) -> InputAction {
444        if key.kind == UiKeyEventKind::Press
445            && key.modifiers.control
446            && matches!(key.code, UiKeyCode::Char('q') | UiKeyCode::Char('c'))
447        {
448            return InputAction::Quit;
449        }
450
451        if overlay != ActiveOverlay::None {
452            // Always honor releases in overlays so WASD cannot stick under a menu.
453            if key.kind == UiKeyEventKind::Release {
454                if let Some(dir) = direction_from_key(key.code) {
455                    self.release_dir(dir);
456                    self.remember_movement();
457                }
458                match key.code {
459                    UiKeyCode::Char('u') => {
460                        self.vertical_held.remove(&VerticalKey::Up);
461                    }
462                    UiKeyCode::Char('j') => {
463                        self.vertical_held.remove(&VerticalKey::Down);
464                    }
465                    _ => {}
466                }
467                return InputAction::None;
468            }
469            if key.kind == UiKeyEventKind::Press {
470                let action = match overlay {
471                    ActiveOverlay::Loadout => match key.code {
472                        UiKeyCode::Esc => Some(InputAction::CloseOverlay),
473                        UiKeyCode::Up => Some(InputAction::LoadoutMenuUp),
474                        UiKeyCode::Down => Some(InputAction::LoadoutMenuDown),
475                        UiKeyCode::Char('1') => Some(InputAction::LoadoutAssignT1),
476                        UiKeyCode::Char('2') => Some(InputAction::LoadoutAssignT2),
477                        _ => None,
478                    },
479                    ActiveOverlay::RotationEditor(mode) => rotation_editor_action(key, mode),
480                    ActiveOverlay::None => None,
481                };
482                if let Some(action) = action {
483                    return action;
484                }
485                if let Some(action) = combat_action_for_key(&key, &self.keys) {
486                    return match action {
487                        CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
488                        CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
489                        _ => InputAction::None,
490                    };
491                }
492            }
493            return InputAction::None;
494        }
495
496        // Map-target mode owns WASD entirely (press + repeat). No character movement.
497        if map_target_active {
498            if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
499                return match key.code {
500                    UiKeyCode::Esc => InputAction::CancelMapTarget,
501                    UiKeyCode::Enter => InputAction::ConfirmMapTarget,
502                    UiKeyCode::Char('m') => InputAction::CancelMapTarget,
503                    UiKeyCode::Char(' ') => InputAction::StopMovement,
504                    UiKeyCode::Char('w') | UiKeyCode::Up => {
505                        InputAction::MapTargetNudge { dx: 0, dy: 1 }
506                    }
507                    UiKeyCode::Char('s') | UiKeyCode::Down => {
508                        InputAction::MapTargetNudge { dx: 0, dy: -1 }
509                    }
510                    UiKeyCode::Char('a') | UiKeyCode::Left => {
511                        InputAction::MapTargetNudge { dx: -1, dy: 0 }
512                    }
513                    UiKeyCode::Char('d') | UiKeyCode::Right => {
514                        InputAction::MapTargetNudge { dx: 1, dy: 0 }
515                    }
516                    UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
517                        'w' => InputAction::MapTargetNudge { dx: 0, dy: 1 },
518                        's' => InputAction::MapTargetNudge { dx: 0, dy: -1 },
519                        'a' => InputAction::MapTargetNudge { dx: -1, dy: 0 },
520                        'd' => InputAction::MapTargetNudge { dx: 1, dy: 0 },
521                        _ => InputAction::None,
522                    },
523                    _ => InputAction::None,
524                };
525            }
526            return InputAction::None;
527        }
528
529        if key.kind == UiKeyEventKind::Press {
530            // Combat binds first so configurable keys (dodge/block/lunge/hotbar) win.
531            if let Some(action) = combat_action_for_key(&key, &self.keys) {
532                return match action {
533                    CombatKeyAction::CycleTargetT1 { reverse } => {
534                        InputAction::CycleCombatTarget { reverse }
535                    }
536                    CombatKeyAction::CycleTargetT2 { reverse } => {
537                        InputAction::CycleCombatTargetT2 { reverse }
538                    }
539                    CombatKeyAction::ToggleAutoT1 => InputAction::ToggleAutoT1,
540                    CombatKeyAction::ToggleAutoT2 => InputAction::ToggleAutoT2,
541                    CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
542                    CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
543                    CombatKeyAction::Dodge => InputAction::Dodge,
544                    CombatKeyAction::Lunge => InputAction::Lunge,
545                    CombatKeyAction::ToggleBlock => InputAction::ToggleBlock,
546                    CombatKeyAction::ClearTargetT1 => InputAction::ClearCombatTarget,
547                    CombatKeyAction::ClearTargetT2 => InputAction::ClearCombatTargetT2,
548                    CombatKeyAction::Hotbar(slot) => InputAction::CastHotbar { slot },
549                };
550            }
551
552            match key.code {
553                UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
554                    'f' => return InputAction::UseWorld,
555                    'n' if !key.modifiers.control => return InputAction::Craft,
556                    ',' => return InputAction::ToggleKeychain,
557                    'i' => return InputAction::ToggleStats,
558                    'b' if !key.modifiers.control => return InputAction::ToggleInventory,
559                    'v' if !key.modifiers.control => return InputAction::ToggleQuestMenu,
560                    '?' | '/' => return InputAction::ToggleHelp,
561                    '.' => return InputAction::CycleHudView,
562                    '-' => return InputAction::TestDamage,
563                    't' => return InputAction::StartChat { whisper: false },
564                    'g' => return InputAction::StartChat { whisper: true },
565                    'x' => return InputAction::ToggleSprintMode,
566                    'm' => return InputAction::ToggleMapTarget,
567                    ' ' => {
568                        self.reset();
569                        return InputAction::StopMovement;
570                    }
571                    _ => {}
572                },
573                UiKeyCode::Enter => return InputAction::SubmitChat,
574                _ => {}
575            }
576        }
577
578        if is_shift_key(key.code) {
579            match key.kind {
580                UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
581                    self.shift_held = true;
582                    self.sprint_until = Instant::now() + Duration::from_secs(30);
583                }
584                UiKeyEventKind::Release => {
585                    self.shift_held = false;
586                    if !self.sprint_toggle {
587                        self.sprint_until = Instant::now();
588                    }
589                }
590            }
591            return InputAction::None;
592        }
593
594        let vertical = match key.code {
595            UiKeyCode::Char('u') => Some(VerticalKey::Up),
596            UiKeyCode::Char('j') => Some(VerticalKey::Down),
597            _ => None,
598        };
599        if let Some(v) = vertical {
600            match key.kind {
601                UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
602                    self.vertical_held.insert(v, Instant::now());
603                }
604                UiKeyEventKind::Release => {
605                    self.vertical_held.remove(&v);
606                }
607            }
608            return InputAction::None;
609        }
610
611        let Some(dir) = direction_from_key(key.code) else {
612            return InputAction::None;
613        };
614
615        match key.kind {
616            UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
617                self.touch_dir(dir);
618                self.refresh_sprint(&key);
619                self.remember_movement();
620                InputAction::None
621            }
622            UiKeyEventKind::Release => {
623                let was_held = self.held_dirs.contains_key(&dir);
624                let in_chord_grace = self
625                    .chord_formed_at
626                    .is_some_and(|t| t.elapsed() < CHORD_RELEASE_GRACE);
627                // Single-key: stop immediately. Chord just formed: ignore the
628                // common fake Release of the first key when the second is pressed.
629                if was_held && !in_chord_grace {
630                    self.release_dir(dir);
631                    if self.held_dirs.len() < 2 {
632                        self.chord_formed_at = None;
633                    }
634                    self.remember_movement();
635                    if self.current() == MovementInput::Stop {
636                        return InputAction::StopMovement;
637                    }
638                }
639                InputAction::None
640            }
641        }
642    }
643
644    /// Drop keys whose last press/repeat is older than `idle`.
645    /// Call once per movement tick. Never permanently latches chords.
646    pub fn expire_idle(&mut self, idle: Duration) {
647        self.held_dirs.retain(|_, at| at.elapsed() < idle);
648        self.vertical_held.retain(|_, at| at.elapsed() < idle);
649        if self.held_dirs.len() < 2 {
650            self.chord_formed_at = None;
651        }
652    }
653}
654
655#[cfg(test)]
656mod tests {
657    use super::*;
658    use crate::input::{UiKeyCode, UiKeyEventKind, UiKeyModifiers};
659
660    fn key(code: UiKeyCode, kind: UiKeyEventKind) -> UiKeyEvent {
661        UiKeyEvent {
662            code,
663            modifiers: UiKeyModifiers::default(),
664            kind,
665        }
666    }
667
668    #[test]
669    fn combat_keys_on_left_hand() {
670        let mut state = MovementState::default();
671        assert_eq!(
672            state.apply_ui_key(
673                key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
674                ActiveOverlay::None,
675                false
676            ),
677            InputAction::Dodge
678        );
679        assert_eq!(
680            state.apply_ui_key(
681                key(UiKeyCode::Char('q'), UiKeyEventKind::Press),
682                ActiveOverlay::None,
683                false
684            ),
685            InputAction::ToggleBlock
686        );
687        assert_eq!(
688            state.apply_ui_key(
689                UiKeyEvent {
690                    code: UiKeyCode::Char(' '),
691                    modifiers: UiKeyModifiers {
692                        shift: true,
693                        control: false,
694                        alt: false
695                    },
696                    kind: UiKeyEventKind::Press
697                },
698                ActiveOverlay::None,
699                false
700            ),
701            InputAction::Lunge
702        );
703        // e is no longer a dedicated diagonal
704        assert_eq!(
705            state.apply_ui_key(
706                key(UiKeyCode::Char('e'), UiKeyEventKind::Press),
707                ActiveOverlay::None,
708                false
709            ),
710            InputAction::None
711        );
712        assert_eq!(state.current(), MovementInput::Stop);
713    }
714
715    #[test]
716    fn world_and_utility_row_bindings() {
717        let mut state = MovementState::default();
718        assert_eq!(
719            state.apply_ui_key(
720                key(UiKeyCode::Char('f'), UiKeyEventKind::Press),
721                ActiveOverlay::None,
722                false
723            ),
724            InputAction::UseWorld
725        );
726        assert_eq!(
727            state.apply_ui_key(
728                key(UiKeyCode::Char('n'), UiKeyEventKind::Press),
729                ActiveOverlay::None,
730                false
731            ),
732            InputAction::Craft
733        );
734        assert_eq!(
735            state.apply_ui_key(
736                key(UiKeyCode::Char(','), UiKeyEventKind::Press),
737                ActiveOverlay::None,
738                false
739            ),
740            InputAction::ToggleKeychain
741        );
742        assert_eq!(
743            state.apply_ui_key(
744                key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
745                ActiveOverlay::None,
746                false
747            ),
748            InputAction::ToggleHelp
749        );
750        assert_eq!(
751            state.apply_ui_key(
752                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
753                ActiveOverlay::None,
754                false
755            ),
756            InputAction::CastHotbar { slot: 1 }
757        );
758    }
759
760    #[test]
761    fn map_target_repeat_nudges_cursor() {
762        let mut state = MovementState::default();
763        assert_eq!(
764            state.apply_ui_key(
765                key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
766                ActiveOverlay::None,
767                true
768            ),
769            InputAction::MapTargetNudge { dx: 1, dy: 0 }
770        );
771        assert_eq!(
772            state.apply_ui_key(
773                key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
774                ActiveOverlay::None,
775                true
776            ),
777            InputAction::MapTargetNudge { dx: 1, dy: 0 }
778        );
779        assert_eq!(state.current(), MovementInput::Stop);
780    }
781
782    #[test]
783    fn release_stops_movement() {
784        let mut state = MovementState::default();
785        state.apply_ui_key(
786            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
787            ActiveOverlay::None,
788            false,
789        );
790        assert_eq!(state.current(), MovementInput::Forward);
791        assert_eq!(
792            state.apply_ui_key(
793                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
794                ActiveOverlay::None,
795                false,
796            ),
797            InputAction::StopMovement
798        );
799        assert_eq!(state.current(), MovementInput::Stop);
800    }
801
802    #[test]
803    fn idle_timeout_stops_movement() {
804        let mut state = MovementState::default();
805        state.apply_ui_key(
806            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
807            ActiveOverlay::None,
808            false,
809        );
810        assert_eq!(state.current(), MovementInput::Forward);
811        state.expire_idle(Duration::from_millis(0));
812        assert_eq!(state.current(), MovementInput::Stop);
813    }
814
815    #[test]
816    fn shift_uppercase_w_moves_forward() {
817        let mut state = MovementState::default();
818        let mut key = key(UiKeyCode::Char('W'), UiKeyEventKind::Press);
819        key.modifiers = UiKeyModifiers {
820            shift: true,
821            control: false,
822            alt: false,
823        };
824        state.apply_ui_key(key, ActiveOverlay::None, false);
825        assert_eq!(state.current(), MovementInput::Forward);
826        assert!(state.sprinting());
827    }
828
829    #[test]
830    fn diagonal_w_and_d() {
831        let mut state = MovementState::default();
832        state.apply_ui_key(
833            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
834            ActiveOverlay::None,
835            false,
836        );
837        state.apply_ui_key(
838            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
839            ActiveOverlay::None,
840            false,
841        );
842        assert_eq!(state.current(), MovementInput::ForwardRight);
843        let (f, s) = state.current().components();
844        assert!(f > 0.0 && s > 0.0);
845    }
846
847    #[test]
848    fn single_d_after_expired_chord_is_right_only() {
849        // Regression: ghost keys from a latched chord must not resurrect as diagonal.
850        let mut state = MovementState::default();
851        state.apply_ui_key(
852            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
853            ActiveOverlay::None,
854            false,
855        );
856        state.apply_ui_key(
857            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
858            ActiveOverlay::None,
859            false,
860        );
861        assert_eq!(state.current(), MovementInput::ForwardRight);
862        state.expire_idle(Duration::from_millis(0));
863        assert_eq!(state.current(), MovementInput::Stop);
864        state.apply_ui_key(
865            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
866            ActiveOverlay::None,
867            false,
868        );
869        assert_eq!(state.current(), MovementInput::Right);
870    }
871
872    #[test]
873    fn release_of_one_key_keeps_other_axis() {
874        let mut state = MovementState::default();
875        state.apply_ui_key(
876            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
877            ActiveOverlay::None,
878            false,
879        );
880        state.apply_ui_key(
881            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
882            ActiveOverlay::None,
883            false,
884        );
885        // After chord grace, releasing W leaves D alone.
886        state.chord_formed_at = Some(Instant::now() - Duration::from_millis(500));
887        assert_eq!(
888            state.apply_ui_key(
889                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
890                ActiveOverlay::None,
891                false,
892            ),
893            InputAction::None
894        );
895        assert_eq!(state.current(), MovementInput::Right);
896    }
897
898    #[test]
899    fn chord_grace_keeps_diagonal_despite_spurious_release() {
900        let mut state = MovementState::default();
901        state.apply_ui_key(
902            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
903            ActiveOverlay::None,
904            false,
905        );
906        state.apply_ui_key(
907            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
908            ActiveOverlay::None,
909            false,
910        );
911        // Immediate fake Release of W (common when D is pressed).
912        assert_eq!(
913            state.apply_ui_key(
914                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
915                ActiveOverlay::None,
916                false,
917            ),
918            InputAction::None
919        );
920        assert_eq!(state.current(), MovementInput::ForwardRight);
921        state.apply_ui_key(
922            key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
923            ActiveOverlay::None,
924            false,
925        );
926        assert_eq!(state.current(), MovementInput::ForwardRight);
927    }
928
929    #[test]
930    fn space_hard_stops() {
931        let mut state = MovementState::default();
932        state.apply_ui_key(
933            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
934            ActiveOverlay::None,
935            false,
936        );
937        state.apply_ui_key(
938            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
939            ActiveOverlay::None,
940            false,
941        );
942        assert_eq!(
943            state.apply_ui_key(
944                key(UiKeyCode::Char(' '), UiKeyEventKind::Press),
945                ActiveOverlay::None,
946                false
947            ),
948            InputAction::StopMovement
949        );
950        assert_eq!(state.current(), MovementInput::Stop);
951    }
952
953    #[test]
954    fn last_direction_remembered_after_release() {
955        let mut state = MovementState::default();
956        state.apply_ui_key(
957            key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
958            ActiveOverlay::None,
959            false,
960        );
961        let _ = state.apply_ui_key(
962            key(UiKeyCode::Char('s'), UiKeyEventKind::Release),
963            ActiveOverlay::None,
964            false,
965        );
966        assert_eq!(state.current(), MovementInput::Stop);
967        let (f, _) = state.last_move_axes();
968        assert!(f < 0.0);
969    }
970
971    #[test]
972    fn repeat_keeps_diagonal_pair() {
973        let mut state = MovementState::default();
974        state.apply_ui_key(
975            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
976            ActiveOverlay::None,
977            false,
978        );
979        state.apply_ui_key(
980            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
981            ActiveOverlay::None,
982            false,
983        );
984        state.apply_ui_key(
985            key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
986            ActiveOverlay::None,
987            false,
988        );
989        assert_eq!(state.current(), MovementInput::ForwardRight);
990    }
991
992    #[test]
993    fn shift_held_sprints_without_modifier_on_repeat() {
994        let mut state = MovementState::default();
995        let shift_press = UiKeyEvent {
996            code: UiKeyCode::ShiftLeft,
997            modifiers: UiKeyModifiers::default(),
998            kind: UiKeyEventKind::Press,
999        };
1000        state.apply_ui_key(shift_press, ActiveOverlay::None, false);
1001        state.apply_ui_key(
1002            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1003            ActiveOverlay::None,
1004            false,
1005        );
1006        assert!(state.sprinting());
1007        state.apply_ui_key(
1008            key(UiKeyCode::Char('w'), UiKeyEventKind::Repeat),
1009            ActiveOverlay::None,
1010            false,
1011        );
1012        assert!(state.sprinting());
1013    }
1014
1015    #[test]
1016    fn diagonal_normalized() {
1017        let (f, s) = MovementInput::ForwardRight.components();
1018        let len = (f * f + s * s).sqrt();
1019        assert!((len - 1.0).abs() < 0.001);
1020    }
1021
1022    #[test]
1023    fn overlay_esc_closes() {
1024        let mut state = MovementState::default();
1025        assert_eq!(
1026            state.apply_ui_key(
1027                key(UiKeyCode::Esc, UiKeyEventKind::Press),
1028                ActiveOverlay::Loadout,
1029                false
1030            ),
1031            InputAction::CloseOverlay
1032        );
1033    }
1034
1035    #[test]
1036    fn overlay_allows_toggle_keys() {
1037        let mut state = MovementState::default();
1038        assert_eq!(
1039            state.apply_ui_key(
1040                key(UiKeyCode::Char('l'), UiKeyEventKind::Press),
1041                ActiveOverlay::Loadout,
1042                false
1043            ),
1044            InputAction::ToggleLoadout
1045        );
1046        assert_eq!(
1047            state.apply_ui_key(
1048                key(UiKeyCode::Char('o'), UiKeyEventKind::Press),
1049                ActiveOverlay::RotationEditor(RotationEditorMode::List),
1050                false
1051            ),
1052            InputAction::ToggleRotationEditor
1053        );
1054    }
1055
1056    #[test]
1057    fn overlay_honors_key_release() {
1058        let mut state = MovementState::default();
1059        state.apply_ui_key(
1060            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1061            ActiveOverlay::None,
1062            false,
1063        );
1064        assert_eq!(state.current(), MovementInput::Forward);
1065        state.apply_ui_key(
1066            key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1067            ActiveOverlay::Loadout,
1068            false,
1069        );
1070        assert_eq!(state.current(), MovementInput::Stop);
1071    }
1072
1073    #[test]
1074    fn overlay_assign_keys() {
1075        let mut state = MovementState::default();
1076        assert_eq!(
1077            state.apply_ui_key(
1078                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1079                ActiveOverlay::Loadout,
1080                false
1081            ),
1082            InputAction::LoadoutAssignT1
1083        );
1084        assert_eq!(
1085            state.apply_ui_key(
1086                key(UiKeyCode::Char('2'), UiKeyEventKind::Press),
1087                ActiveOverlay::Loadout,
1088                false
1089            ),
1090            InputAction::LoadoutAssignT2
1091        );
1092    }
1093
1094    #[test]
1095    fn rotation_editor_slash_moves_ability_down() {
1096        let mut state = MovementState::default();
1097        assert_eq!(
1098            state.apply_ui_key(
1099                key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
1100                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1101                false
1102            ),
1103            InputAction::RotationEditorMoveAbilityDown
1104        );
1105    }
1106
1107    #[test]
1108    fn rotation_editor_bracket_keys_reorder() {
1109        let mut state = MovementState::default();
1110        assert_eq!(
1111            state.apply_ui_key(
1112                key(UiKeyCode::Char('['), UiKeyEventKind::Press),
1113                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1114                false
1115            ),
1116            InputAction::RotationEditorMoveAbilityUp
1117        );
1118        assert_eq!(
1119            state.apply_ui_key(
1120                key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1121                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1122                false
1123            ),
1124            InputAction::RotationEditorMoveAbilityDown
1125        );
1126    }
1127
1128    #[test]
1129    fn rotation_editor_s_saves() {
1130        let mut state = MovementState::default();
1131        assert_eq!(
1132            state.apply_ui_key(
1133                key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1134                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1135                false
1136            ),
1137            InputAction::RotationEditorSave
1138        );
1139    }
1140}