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