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