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