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