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