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