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    control_held: bool,
107    sprint_until: Instant,
108    sprint_toggle: bool,
109    last_forward: f32,
110    last_strafe: f32,
111    /// Set when a second WASD direction joins; suppresses bogus releases briefly.
112    chord_formed_at: Option<Instant>,
113    pub keys: ClientKeyBindings,
114}
115
116impl Default for MovementState {
117    fn default() -> Self {
118        Self {
119            held_dirs: HashMap::new(),
120            vertical_held: HashMap::new(),
121            shift_held: false,
122            control_held: false,
123            sprint_until: Instant::now(),
124            sprint_toggle: false,
125            last_forward: 0.0,
126            last_strafe: 0.0,
127            chord_formed_at: None,
128            keys: ClientKeyBindings::default(),
129        }
130    }
131}
132
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum MovementInput {
135    Stop,
136    Forward,
137    Back,
138    Left,
139    Right,
140    ForwardLeft,
141    ForwardRight,
142    BackLeft,
143    BackRight,
144}
145
146impl MovementInput {
147    pub fn components(self) -> (f32, f32) {
148        let (mut forward, mut strafe): (f32, f32) = match self {
149            Self::Stop => (0.0, 0.0),
150            Self::Forward => (1.0, 0.0),
151            Self::Back => (-1.0, 0.0),
152            Self::Left => (0.0, -1.0),
153            Self::Right => (0.0, 1.0),
154            Self::ForwardLeft => (1.0, -1.0),
155            Self::ForwardRight => (1.0, 1.0),
156            Self::BackLeft => (-1.0, -1.0),
157            Self::BackRight => (-1.0, 1.0),
158        };
159
160        let len = (forward * forward + strafe * strafe).sqrt();
161        if len > 1.0 {
162            forward /= len;
163            strafe /= len;
164        }
165        (forward, strafe)
166    }
167
168    pub fn label(self) -> &'static str {
169        match self {
170            Self::Stop => "stop",
171            Self::Forward => "up",
172            Self::Back => "down",
173            Self::Left => "left",
174            Self::Right => "right",
175            Self::ForwardLeft => "up-left",
176            Self::ForwardRight => "up-right",
177            Self::BackLeft => "down-left",
178            Self::BackRight => "down-right",
179        }
180    }
181}
182
183#[derive(Debug, Clone, Copy, PartialEq)]
184pub enum InputAction {
185    Quit,
186    Harvest,
187    Pickup,
188    Craft,
189    Interact,
190    TestDamage,
191    CycleCombatTarget {
192        reverse: bool,
193    },
194    CycleCombatTargetT2 {
195        reverse: bool,
196    },
197    AdvanceRotationT1,
198    AdvanceRotationT2,
199    ToggleAutoT1,
200    ToggleAutoT2,
201    ToggleLoadout,
202    ToggleRotationEditor,
203    LoadoutAssignT1,
204    LoadoutAssignT2,
205    LoadoutMenuUp,
206    LoadoutMenuDown,
207    LoadoutHotbarPrev,
208    LoadoutHotbarNext,
209    LoadoutBindHotbar,
210    LoadoutClearHotbar,
211    LoadoutToggleFocus,
212    RotationEditorListUp,
213    RotationEditorListDown,
214    RotationEditorEdit,
215    RotationEditorNew,
216    RotationEditorDelete,
217    RotationEditorBack,
218    RotationEditorAddAbility,
219    RotationEditorRemoveAbility,
220    RotationEditorMoveAbilityUp,
221    RotationEditorMoveAbilityDown,
222    RotationEditorAbilityUp,
223    RotationEditorAbilityDown,
224    RotationEditorPickerUp,
225    RotationEditorPickerDown,
226    RotationEditorPickAbility,
227    RotationEditorRename,
228    RotationEditorConfirmLabel,
229    RotationEditorLabelBackspace,
230    RotationEditorLabelChar(char),
231    RotationEditorSave,
232    CloseOverlay,
233    ClearCombatTarget,
234    ToggleStats,
235    ToggleEquip,
236    CycleCharacterSheetTab,
237    LedgerPeriodDigit(char),
238    ToggleInventory,
239    ToggleKeychain,
240    ToggleQuestMenu,
241    QuestMenuUp,
242    QuestMenuDown,
243    QuestWithdraw,
244    ToggleWorkersMenu,
245    ToggleHelp,
246    CycleHudView,
247    /// Hide / show the bottom system LOG panel (gfx dock).
248    ToggleHudLog,
249    StartChat {
250        whisper: bool,
251    },
252    SubmitChat,
253    CancelChat,
254    /// Dodge — `forward`/`strafe` are **currently held** WASD axes (0,0 = smart auto dodge).
255    Dodge {
256        forward: f32,
257        strafe: f32,
258    },
259    Lunge,
260    /// Alt + movement — leap one cell over a medium cliff (`plans/04` §4.5b).
261    DirectionalJump {
262        forward: f32,
263        strafe: f32,
264    },
265    ToggleBlock,
266    ToggleSprintMode,
267    ToggleMapTarget,
268    ConfirmMapTarget,
269    CancelMapTarget,
270    MapTargetNudge {
271        dx: i32,
272        dy: i32,
273    },
274    CancelAutoNav,
275    /// Hard stop: clear held keys and cancel auto-nav (Space).
276    StopMovement,
277    /// Context use: interact → pickup → harvest.
278    UseWorld,
279    /// Enter crown land claim editor (stand on unclaimed property zone).
280    ClaimPlotBegin,
281    /// Till the plot cell under you (`c` on your deed plot).
282    FarmCultivate,
283    /// Plant on tilled soil under you (`p` on your deed plot).
284    FarmPlant,
285    /// Start timed plot building craft (`B` / Shift+b).
286    PlotBuild,
287    /// Rename owned plot underfoot (`Shift+n`).
288    RenamePlotUnderfoot,
289    /// Lock/unlock nearby player building door (`l` / `L` near door).
290    DoorLockToggle,
291    /// Enter through an open player-building door (`Enter`).
292    EnterBuildingDoor,
293    /// Exit a player building through the exterior portal (`Enter` while inside).
294    ExitBuildingDoor,
295    /// Confirm claim purchase (Enter while claim_mode).
296    ClaimConfirm,
297    /// Cancel claim mode.
298    ClaimCancel,
299    /// Grow/shrink claim footprint.
300    ClaimNudge {
301        dw: i32,
302        dh: i32,
303    },
304    /// Nudge claim footprint SW corner one cell.
305    ClaimMoveNudge {
306        dx: i32,
307        dy: i32,
308    },
309    /// Set claim footprint preset (2×2 / 4×4 / 8×8).
310    ClaimPreset {
311        w: u32,
312        h: u32,
313    },
314    /// World Shift+m — relocate nearest owned/accessible placed chest.
315    RelocateBeginNearest,
316    /// Confirm relocate destination (Enter while relocate_mode).
317    RelocateConfirm,
318    /// Cancel relocate mode.
319    RelocateCancel,
320    /// Nudge the 1×1 relocate ghost one cell.
321    RelocateNudge {
322        dx: i32,
323        dy: i32,
324    },
325    CastHotbar {
326        slot: u8,
327    },
328    ClearCombatTargetT2,
329    None,
330}
331
332fn rotation_editor_action(key: UiKeyEvent, mode: RotationEditorMode) -> Option<InputAction> {
333    let shift = key.modifiers.shift;
334    Some(match mode {
335        RotationEditorMode::List => match key.code {
336            UiKeyCode::Esc => InputAction::CloseOverlay,
337            UiKeyCode::Up => InputAction::RotationEditorListUp,
338            UiKeyCode::Down => InputAction::RotationEditorListDown,
339            UiKeyCode::Enter => InputAction::RotationEditorEdit,
340            UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
341                'n' => InputAction::RotationEditorNew,
342                'd' => InputAction::RotationEditorDelete,
343                _ => return None,
344            },
345            _ => return None,
346        },
347        RotationEditorMode::EditSequence => match key.code {
348            UiKeyCode::Up if shift => InputAction::RotationEditorMoveAbilityUp,
349            UiKeyCode::Down if shift => InputAction::RotationEditorMoveAbilityDown,
350            UiKeyCode::Esc => InputAction::RotationEditorBack,
351            UiKeyCode::Char('[') | UiKeyCode::Char(';') => InputAction::RotationEditorMoveAbilityUp,
352            UiKeyCode::Char(']') | UiKeyCode::Char('/') | UiKeyCode::Char('\\') => {
353                InputAction::RotationEditorMoveAbilityDown
354            }
355            UiKeyCode::Up => InputAction::RotationEditorAbilityUp,
356            UiKeyCode::Down => InputAction::RotationEditorAbilityDown,
357            UiKeyCode::Delete => InputAction::RotationEditorRemoveAbility,
358            UiKeyCode::Enter => InputAction::RotationEditorSave,
359            UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
360                'a' => InputAction::RotationEditorAddAbility,
361                'x' => InputAction::RotationEditorRemoveAbility,
362                'r' => InputAction::RotationEditorRename,
363                's' => InputAction::RotationEditorSave,
364                _ => return None,
365            },
366            _ => return None,
367        },
368        RotationEditorMode::PickAbility => match key.code {
369            UiKeyCode::Esc => InputAction::RotationEditorBack,
370            UiKeyCode::Up => InputAction::RotationEditorPickerUp,
371            UiKeyCode::Down => InputAction::RotationEditorPickerDown,
372            UiKeyCode::Enter => InputAction::RotationEditorPickAbility,
373            _ => return None,
374        },
375        RotationEditorMode::EditLabel => match key.code {
376            UiKeyCode::Esc => InputAction::RotationEditorBack,
377            UiKeyCode::Enter => InputAction::RotationEditorConfirmLabel,
378            UiKeyCode::Backspace => InputAction::RotationEditorLabelBackspace,
379            UiKeyCode::Char(c) if !key.modifiers.control => InputAction::RotationEditorLabelChar(c),
380            _ => return None,
381        },
382    })
383}
384
385impl MovementState {
386    pub fn with_keys(keys: ClientKeyBindings) -> Self {
387        Self {
388            keys,
389            ..Default::default()
390        }
391    }
392
393    pub fn idle_timeout(&self) -> Duration {
394        MOVEMENT_IDLE_TIMEOUT
395    }
396
397    /// Mark `dir` as held. Soft-refresh companions that are still within the idle
398    /// window so a chord survives when the terminal only repeats the last key.
399    /// Never resurrects keys that have already timed out.
400    fn touch_dir(&mut self, dir: DirectionKey) {
401        let now = Instant::now();
402        let idle = self.idle_timeout();
403        let joining_chord = !self.held_dirs.contains_key(&dir)
404            && self.held_dirs.values().any(|at| at.elapsed() < idle);
405        if joining_chord {
406            self.chord_formed_at = Some(now);
407        }
408        self.held_dirs.insert(dir, now);
409        for (other, at) in self.held_dirs.iter_mut() {
410            if *other != dir && at.elapsed() < idle {
411                *at = now;
412            }
413        }
414    }
415
416    fn release_dir(&mut self, dir: DirectionKey) {
417        self.held_dirs.remove(&dir);
418    }
419
420    fn refresh_sprint(&mut self, key: &UiKeyEvent) {
421        let shift = key.modifiers.shift
422            || matches!(
423                key.code,
424                UiKeyCode::Char(c)
425                    if c.is_ascii_uppercase() && direction_from_key(key.code).is_some()
426            );
427        if shift {
428            self.sprint_until = Instant::now() + SPRINT_SHIFT_REFRESH;
429        }
430    }
431
432    fn dir_active(&self, dir: DirectionKey) -> bool {
433        let idle = self.idle_timeout();
434        self.held_dirs
435            .get(&dir)
436            .is_some_and(|at| at.elapsed() < idle)
437    }
438
439    fn vertical_active(&self, key: VerticalKey) -> bool {
440        let idle = self.idle_timeout();
441        self.vertical_held
442            .get(&key)
443            .is_some_and(|at| at.elapsed() < idle)
444    }
445
446    pub fn reset(&mut self) {
447        self.held_dirs.clear();
448        self.vertical_held.clear();
449        self.shift_held = false;
450        self.control_held = false;
451        self.sprint_until = Instant::now();
452        self.chord_formed_at = None;
453    }
454
455    pub fn sprint_mode(&self) -> bool {
456        self.sprint_toggle
457    }
458
459    pub fn toggle_sprint_mode(&mut self) {
460        self.sprint_toggle = !self.sprint_toggle;
461    }
462
463    pub fn vertical_axis(&self) -> f32 {
464        let up = self.vertical_active(VerticalKey::Up);
465        let down = self.vertical_active(VerticalKey::Down);
466        match (up, down) {
467            (true, false) => 1.0,
468            (false, true) => -1.0,
469            _ => 0.0,
470        }
471    }
472
473    pub fn sneaking(&self) -> bool {
474        self.control_held
475    }
476
477    pub fn sprinting(&self) -> bool {
478        if self.sneaking() {
479            return false;
480        }
481        self.sprint_toggle || self.shift_held || Instant::now() < self.sprint_until
482    }
483
484    pub fn last_move_axes(&self) -> (f32, f32) {
485        (self.last_forward, self.last_strafe)
486    }
487
488    fn remember_movement(&mut self) {
489        let (forward, strafe) = self.current().components();
490        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
491            self.last_forward = forward;
492            self.last_strafe = strafe;
493        }
494    }
495
496    pub fn current(&self) -> MovementInput {
497        let up = self.dir_active(DirectionKey::Up);
498        let down = self.dir_active(DirectionKey::Down);
499        let left = self.dir_active(DirectionKey::Left);
500        let right = self.dir_active(DirectionKey::Right);
501
502        // Opposing axes cancel; remaining axes combine into 8-way movement.
503        let forward = match (up, down) {
504            (true, false) => 1,
505            (false, true) => -1,
506            _ => 0,
507        };
508        let strafe = match (left, right) {
509            (true, false) => -1,
510            (false, true) => 1,
511            _ => 0,
512        };
513        match (forward, strafe) {
514            (1, 0) => MovementInput::Forward,
515            (-1, 0) => MovementInput::Back,
516            (0, -1) => MovementInput::Left,
517            (0, 1) => MovementInput::Right,
518            (1, -1) => MovementInput::ForwardLeft,
519            (1, 1) => MovementInput::ForwardRight,
520            (-1, -1) => MovementInput::BackLeft,
521            (-1, 1) => MovementInput::BackRight,
522            _ => MovementInput::Stop,
523        }
524    }
525
526    pub fn apply_ui_key(
527        &mut self,
528        key: UiKeyEvent,
529        overlay: ActiveOverlay,
530        map_target_active: bool,
531    ) -> InputAction {
532        self.apply_ui_key_ex(key, overlay, map_target_active, false, false)
533    }
534
535    pub fn apply_ui_key_ex(
536        &mut self,
537        key: UiKeyEvent,
538        overlay: ActiveOverlay,
539        map_target_active: bool,
540        relocate_active: bool,
541        claim_active: bool,
542    ) -> InputAction {
543        if key.kind == UiKeyEventKind::Press
544            && key.modifiers.control
545            && matches!(key.code, UiKeyCode::Char('q') | UiKeyCode::Char('c'))
546        {
547            return InputAction::Quit;
548        }
549
550        if overlay != ActiveOverlay::None {
551            // Always honor releases in overlays so WASD cannot stick under a menu.
552            if key.kind == UiKeyEventKind::Release {
553                if let Some(dir) = direction_from_key(key.code) {
554                    self.release_dir(dir);
555                    self.remember_movement();
556                }
557                match key.code {
558                    UiKeyCode::Char('u') => {
559                        self.vertical_held.remove(&VerticalKey::Up);
560                    }
561                    UiKeyCode::Char('j') => {
562                        self.vertical_held.remove(&VerticalKey::Down);
563                    }
564                    _ => {}
565                }
566                return InputAction::None;
567            }
568            if key.kind == UiKeyEventKind::Press {
569                let action = match overlay {
570                    ActiveOverlay::Loadout => match key.code {
571                        UiKeyCode::Esc => Some(InputAction::CloseOverlay),
572                        UiKeyCode::Up => Some(InputAction::LoadoutMenuUp),
573                        UiKeyCode::Down => Some(InputAction::LoadoutMenuDown),
574                        UiKeyCode::Left | UiKeyCode::Char('[') => {
575                            Some(InputAction::LoadoutHotbarPrev)
576                        }
577                        UiKeyCode::Right | UiKeyCode::Char(']') => {
578                            Some(InputAction::LoadoutHotbarNext)
579                        }
580                        UiKeyCode::Tab | UiKeyCode::BackTab => {
581                            Some(InputAction::LoadoutToggleFocus)
582                        }
583                        UiKeyCode::Enter => Some(InputAction::LoadoutBindHotbar),
584                        UiKeyCode::Delete | UiKeyCode::Backspace => {
585                            Some(InputAction::LoadoutClearHotbar)
586                        }
587                        UiKeyCode::Char('1') => Some(InputAction::LoadoutAssignT1),
588                        UiKeyCode::Char('2') => Some(InputAction::LoadoutAssignT2),
589                        _ => None,
590                    },
591                    ActiveOverlay::RotationEditor(mode) => rotation_editor_action(key, mode),
592                    ActiveOverlay::Stats => match key.code {
593                        UiKeyCode::Esc => Some(InputAction::ToggleStats),
594                        UiKeyCode::Tab | UiKeyCode::BackTab => {
595                            Some(InputAction::CycleCharacterSheetTab)
596                        }
597                        UiKeyCode::Char('i') | UiKeyCode::Char('I') => {
598                            Some(InputAction::ToggleStats)
599                        }
600                        UiKeyCode::Char(c) if matches!(c, '1' | '2' | '3' | '4') => {
601                            Some(InputAction::LedgerPeriodDigit(c))
602                        }
603                        _ => None,
604                    },
605                    ActiveOverlay::None => None,
606                };
607                if let Some(action) = action {
608                    return action;
609                }
610                if let Some(action) = combat_action_for_key(&key, &self.keys) {
611                    return match action {
612                        CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
613                        CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
614                        _ => InputAction::None,
615                    };
616                }
617            }
618            return InputAction::None;
619        }
620
621        // Relocate mode owns WASD entirely (press + repeat). No character movement.
622        if relocate_active {
623            if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
624                return match key.code {
625                    UiKeyCode::Esc => InputAction::RelocateCancel,
626                    UiKeyCode::Enter => InputAction::RelocateConfirm,
627                    UiKeyCode::Char(' ') => InputAction::StopMovement,
628                    UiKeyCode::Char('w') | UiKeyCode::Up => {
629                        InputAction::RelocateNudge { dx: 0, dy: 1 }
630                    }
631                    UiKeyCode::Char('s') | UiKeyCode::Down => {
632                        InputAction::RelocateNudge { dx: 0, dy: -1 }
633                    }
634                    UiKeyCode::Char('a') | UiKeyCode::Left => {
635                        InputAction::RelocateNudge { dx: -1, dy: 0 }
636                    }
637                    UiKeyCode::Char('d') | UiKeyCode::Right => {
638                        InputAction::RelocateNudge { dx: 1, dy: 0 }
639                    }
640                    UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
641                        'w' => InputAction::RelocateNudge { dx: 0, dy: 1 },
642                        's' => InputAction::RelocateNudge { dx: 0, dy: -1 },
643                        'a' => InputAction::RelocateNudge { dx: -1, dy: 0 },
644                        'd' => InputAction::RelocateNudge { dx: 1, dy: 0 },
645                        _ => InputAction::None,
646                    },
647                    _ => InputAction::None,
648                };
649            }
650            return InputAction::None;
651        }
652
653        // Claim mode owns WASD for footprint position (press + repeat). No character movement.
654        if claim_active {
655            if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
656                let shift = key.modifiers.shift;
657                let ctrl = key.modifiers.control;
658                return match key.code {
659                    UiKeyCode::Esc => InputAction::ClaimCancel,
660                    UiKeyCode::Enter => InputAction::ClaimConfirm,
661                    UiKeyCode::Char(' ') => InputAction::StopMovement,
662                    UiKeyCode::Char('[') => {
663                        let (dw, dh) = if shift {
664                            (-1, 0)
665                        } else if ctrl {
666                            (0, -1)
667                        } else {
668                            (-1, -1)
669                        };
670                        InputAction::ClaimNudge { dw, dh }
671                    }
672                    UiKeyCode::Char(']') => {
673                        let (dw, dh) = if shift {
674                            (1, 0)
675                        } else if ctrl {
676                            (0, 1)
677                        } else {
678                            (1, 1)
679                        };
680                        InputAction::ClaimNudge { dw, dh }
681                    }
682                    UiKeyCode::Char('2') => InputAction::ClaimPreset { w: 2, h: 2 },
683                    UiKeyCode::Char('4') => InputAction::ClaimPreset { w: 4, h: 4 },
684                    UiKeyCode::Char('8') => InputAction::ClaimPreset { w: 8, h: 8 },
685                    // Shift+A is buy-all (gfx NetCmd); do not also move.
686                    UiKeyCode::Char('a') | UiKeyCode::Char('A') if shift => InputAction::None,
687                    UiKeyCode::Char('w') | UiKeyCode::Up => {
688                        InputAction::ClaimMoveNudge { dx: 0, dy: 1 }
689                    }
690                    UiKeyCode::Char('s') | UiKeyCode::Down => {
691                        InputAction::ClaimMoveNudge { dx: 0, dy: -1 }
692                    }
693                    UiKeyCode::Char('a') | UiKeyCode::Left => {
694                        InputAction::ClaimMoveNudge { dx: -1, dy: 0 }
695                    }
696                    UiKeyCode::Char('d') | UiKeyCode::Right => {
697                        InputAction::ClaimMoveNudge { dx: 1, dy: 0 }
698                    }
699                    UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
700                        'w' => InputAction::ClaimMoveNudge { dx: 0, dy: 1 },
701                        's' => InputAction::ClaimMoveNudge { dx: 0, dy: -1 },
702                        'a' if !shift => InputAction::ClaimMoveNudge { dx: -1, dy: 0 },
703                        'd' => InputAction::ClaimMoveNudge { dx: 1, dy: 0 },
704                        _ => InputAction::None,
705                    },
706                    _ => InputAction::None,
707                };
708            }
709            return InputAction::None;
710        }
711
712        // Map-target mode owns WASD entirely (press + repeat). No character movement.
713        if map_target_active {
714            if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
715                return match key.code {
716                    UiKeyCode::Esc => InputAction::CancelMapTarget,
717                    UiKeyCode::Enter => InputAction::ConfirmMapTarget,
718                    UiKeyCode::Char('m') => InputAction::CancelMapTarget,
719                    UiKeyCode::Char(' ') => InputAction::StopMovement,
720                    UiKeyCode::Char('w') | UiKeyCode::Up => {
721                        InputAction::MapTargetNudge { dx: 0, dy: 1 }
722                    }
723                    UiKeyCode::Char('s') | UiKeyCode::Down => {
724                        InputAction::MapTargetNudge { dx: 0, dy: -1 }
725                    }
726                    UiKeyCode::Char('a') | UiKeyCode::Left => {
727                        InputAction::MapTargetNudge { dx: -1, dy: 0 }
728                    }
729                    UiKeyCode::Char('d') | UiKeyCode::Right => {
730                        InputAction::MapTargetNudge { dx: 1, dy: 0 }
731                    }
732                    UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
733                        'w' => InputAction::MapTargetNudge { dx: 0, dy: 1 },
734                        's' => InputAction::MapTargetNudge { dx: 0, dy: -1 },
735                        'a' => InputAction::MapTargetNudge { dx: -1, dy: 0 },
736                        'd' => InputAction::MapTargetNudge { dx: 1, dy: 0 },
737                        _ => InputAction::None,
738                    },
739                    _ => InputAction::None,
740                };
741            }
742            return InputAction::None;
743        }
744
745        if key.kind == UiKeyEventKind::Press {
746            // Combat binds first so configurable keys (dodge/block/lunge/hotbar) win.
747            if let Some(action) = combat_action_for_key(&key, &self.keys) {
748                return match action {
749                    CombatKeyAction::CycleTargetT1 { reverse } => {
750                        InputAction::CycleCombatTarget { reverse }
751                    }
752                    CombatKeyAction::CycleTargetT2 { reverse } => {
753                        InputAction::CycleCombatTargetT2 { reverse }
754                    }
755                    CombatKeyAction::ToggleAutoT1 => InputAction::ToggleAutoT1,
756                    CombatKeyAction::ToggleAutoT2 => InputAction::ToggleAutoT2,
757                    CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
758                    CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
759                    CombatKeyAction::Dodge => {
760                        let (forward, strafe) = self.current().components();
761                        InputAction::Dodge { forward, strafe }
762                    }
763                    CombatKeyAction::Lunge => InputAction::Lunge,
764                    CombatKeyAction::ToggleBlock => InputAction::ToggleBlock,
765                    CombatKeyAction::ClearTargetT1 => InputAction::ClearCombatTarget,
766                    CombatKeyAction::ClearTargetT2 => InputAction::ClearCombatTargetT2,
767                    CombatKeyAction::Hotbar(slot) => InputAction::CastHotbar { slot },
768                };
769            }
770
771            match key.code {
772                UiKeyCode::Tab => return InputAction::CycleCharacterSheetTab,
773                UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
774                    'f' => return InputAction::UseWorld,
775                    'k' if !key.modifiers.control => return InputAction::ClaimPlotBegin,
776                    'n' if key.modifiers.shift => return InputAction::RenamePlotUnderfoot,
777                    'n' if !key.modifiers.control => return InputAction::Craft,
778                    ',' => return InputAction::ToggleKeychain,
779                    'i' => return InputAction::ToggleStats,
780                    'p' if !key.modifiers.control => return InputAction::ToggleEquip,
781                    '1' | '2' | '3' | '4' => {
782                        return InputAction::LedgerPeriodDigit(c.to_ascii_lowercase())
783                    }
784                    'b' if !key.modifiers.control => return InputAction::ToggleInventory,
785                    'v' if !key.modifiers.control => return InputAction::ToggleQuestMenu,
786                    'h' if !key.modifiers.control => return InputAction::ToggleWorkersMenu,
787                    '?' | '/' => return InputAction::ToggleHelp,
788                    '.' => return InputAction::CycleHudView,
789                    '\'' => return InputAction::ToggleHudLog,
790                    '-' => return InputAction::TestDamage,
791                    't' => return InputAction::StartChat { whisper: false },
792                    'g' => return InputAction::StartChat { whisper: true },
793                    'x' => return InputAction::ToggleSprintMode,
794                    'm' if key.modifiers.shift => return InputAction::RelocateBeginNearest,
795                    'm' => return InputAction::ToggleMapTarget,
796                    ' ' => {
797                        self.reset();
798                        return InputAction::StopMovement;
799                    }
800                    _ => {}
801                },
802                UiKeyCode::Enter => return InputAction::SubmitChat,
803                _ => {}
804            }
805        }
806
807        if is_shift_key(key.code) {
808            match key.kind {
809                UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
810                    self.shift_held = true;
811                    self.sprint_until = Instant::now() + Duration::from_secs(30);
812                }
813                UiKeyEventKind::Release => {
814                    self.shift_held = false;
815                    if !self.sprint_toggle {
816                        self.sprint_until = Instant::now();
817                    }
818                }
819            }
820            return InputAction::None;
821        }
822
823        let vertical = match key.code {
824            UiKeyCode::Char('u') => Some(VerticalKey::Up),
825            UiKeyCode::Char('j') => Some(VerticalKey::Down),
826            _ => None,
827        };
828        if let Some(v) = vertical {
829            match key.kind {
830                UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
831                    self.vertical_held.insert(v, Instant::now());
832                }
833                UiKeyEventKind::Release => {
834                    self.vertical_held.remove(&v);
835                }
836            }
837            return InputAction::None;
838        }
839
840        let Some(dir) = direction_from_key(key.code) else {
841            return InputAction::None;
842        };
843
844        if key.modifiers.alt && key.kind == UiKeyEventKind::Press {
845            let (forward, strafe) = direction_axes(dir);
846            return InputAction::DirectionalJump { forward, strafe };
847        }
848
849        match key.kind {
850            UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
851                self.touch_dir(dir);
852                self.refresh_sprint(&key);
853                self.remember_movement();
854                InputAction::None
855            }
856            UiKeyEventKind::Release => {
857                let was_held = self.held_dirs.contains_key(&dir);
858                let in_chord_grace = self
859                    .chord_formed_at
860                    .is_some_and(|t| t.elapsed() < CHORD_RELEASE_GRACE);
861                // Single-key: stop immediately. Chord just formed: ignore the
862                // common fake Release of the first key when the second is pressed.
863                if was_held && !in_chord_grace {
864                    self.release_dir(dir);
865                    if self.held_dirs.len() < 2 {
866                        self.chord_formed_at = None;
867                    }
868                    self.remember_movement();
869                    if self.current() == MovementInput::Stop {
870                        return InputAction::StopMovement;
871                    }
872                }
873                InputAction::None
874            }
875        }
876    }
877
878    /// Drop keys whose last press/repeat is older than `idle`.
879    /// Call once per movement tick. Never permanently latches chords.
880    pub fn expire_idle(&mut self, idle: Duration) {
881        self.held_dirs.retain(|_, at| at.elapsed() < idle);
882        self.vertical_held.retain(|_, at| at.elapsed() < idle);
883        if self.held_dirs.len() < 2 {
884            self.chord_formed_at = None;
885        }
886    }
887
888    /// Gfx path: treat physical `is_key_down` as source of truth for cardinal holds.
889    /// Macroquad only edge-triggers Press; without this, idle expiry stops movement
890    /// until OS key-repeat arrives.
891    pub fn sync_physical_holds(
892        &mut self,
893        forward: bool,
894        back: bool,
895        left: bool,
896        right: bool,
897        vertical_up: bool,
898        vertical_down: bool,
899        shift: bool,
900        control: bool,
901    ) {
902        let now = Instant::now();
903        for (dir, held) in [
904            (DirectionKey::Up, forward),
905            (DirectionKey::Down, back),
906            (DirectionKey::Left, left),
907            (DirectionKey::Right, right),
908        ] {
909            if held {
910                self.held_dirs.insert(dir, now);
911            } else {
912                self.held_dirs.remove(&dir);
913            }
914        }
915        for (key, held) in [
916            (VerticalKey::Up, vertical_up),
917            (VerticalKey::Down, vertical_down),
918        ] {
919            if held {
920                self.vertical_held.insert(key, now);
921            } else {
922                self.vertical_held.remove(&key);
923            }
924        }
925        self.shift_held = shift;
926        self.control_held = control;
927        if shift {
928            self.sprint_until = now + SPRINT_SHIFT_REFRESH;
929        }
930        if self.held_dirs.len() < 2 {
931            self.chord_formed_at = None;
932        }
933        self.remember_movement();
934    }
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940    use crate::input::{UiKeyCode, UiKeyEventKind, UiKeyModifiers};
941
942    fn key(code: UiKeyCode, kind: UiKeyEventKind) -> UiKeyEvent {
943        UiKeyEvent {
944            code,
945            modifiers: UiKeyModifiers::default(),
946            kind,
947        }
948    }
949
950    #[test]
951    fn combat_keys_on_left_hand() {
952        let mut state = MovementState::default();
953        assert_eq!(
954            state.apply_ui_key(
955                key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
956                ActiveOverlay::None,
957                false
958            ),
959            InputAction::Dodge {
960                forward: 0.0,
961                strafe: 0.0,
962            }
963        );
964        assert_eq!(
965            state.apply_ui_key(
966                key(UiKeyCode::Char('q'), UiKeyEventKind::Press),
967                ActiveOverlay::None,
968                false
969            ),
970            InputAction::ToggleBlock
971        );
972        assert_eq!(
973            state.apply_ui_key(
974                UiKeyEvent {
975                    code: UiKeyCode::Char(' '),
976                    modifiers: UiKeyModifiers {
977                        shift: true,
978                        control: false,
979                        alt: false
980                    },
981                    kind: UiKeyEventKind::Press
982                },
983                ActiveOverlay::None,
984                false
985            ),
986            InputAction::Lunge
987        );
988        // e is no longer a dedicated diagonal
989        assert_eq!(
990            state.apply_ui_key(
991                key(UiKeyCode::Char('e'), UiKeyEventKind::Press),
992                ActiveOverlay::None,
993                false
994            ),
995            InputAction::None
996        );
997        assert_eq!(state.current(), MovementInput::Stop);
998    }
999
1000    #[test]
1001    fn dodge_uses_held_wasd_not_sticky_last_move() {
1002        let mut state = MovementState::default();
1003        // Walk forward, then release — sticky last move must not leak into bare C.
1004        let _ = state.apply_ui_key(
1005            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1006            ActiveOverlay::None,
1007            false,
1008        );
1009        let _ = state.apply_ui_key(
1010            key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1011            ActiveOverlay::None,
1012            false,
1013        );
1014        assert_eq!(
1015            state.apply_ui_key(
1016                key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
1017                ActiveOverlay::None,
1018                false
1019            ),
1020            InputAction::Dodge {
1021                forward: 0.0,
1022                strafe: 0.0,
1023            }
1024        );
1025
1026        // Held W + C = directional burst axes.
1027        let _ = state.apply_ui_key(
1028            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1029            ActiveOverlay::None,
1030            false,
1031        );
1032        assert_eq!(
1033            state.apply_ui_key(
1034                key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
1035                ActiveOverlay::None,
1036                false
1037            ),
1038            InputAction::Dodge {
1039                forward: 1.0,
1040                strafe: 0.0,
1041            }
1042        );
1043    }
1044
1045    #[test]
1046    fn world_and_utility_row_bindings() {
1047        let mut state = MovementState::default();
1048        assert_eq!(
1049            state.apply_ui_key(
1050                key(UiKeyCode::Char('f'), UiKeyEventKind::Press),
1051                ActiveOverlay::None,
1052                false
1053            ),
1054            InputAction::UseWorld
1055        );
1056        assert_eq!(
1057            state.apply_ui_key(
1058                key(UiKeyCode::Char('n'), UiKeyEventKind::Press),
1059                ActiveOverlay::None,
1060                false
1061            ),
1062            InputAction::Craft
1063        );
1064        assert_eq!(
1065            state.apply_ui_key(
1066                key(UiKeyCode::Char(','), UiKeyEventKind::Press),
1067                ActiveOverlay::None,
1068                false
1069            ),
1070            InputAction::ToggleKeychain
1071        );
1072        assert_eq!(
1073            state.apply_ui_key(
1074                key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
1075                ActiveOverlay::None,
1076                false
1077            ),
1078            InputAction::ToggleHelp
1079        );
1080        assert_eq!(
1081            state.apply_ui_key(
1082                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1083                ActiveOverlay::None,
1084                false
1085            ),
1086            InputAction::CastHotbar { slot: 1 }
1087        );
1088    }
1089
1090    #[test]
1091    fn map_target_repeat_nudges_cursor() {
1092        let mut state = MovementState::default();
1093        assert_eq!(
1094            state.apply_ui_key(
1095                key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1096                ActiveOverlay::None,
1097                true
1098            ),
1099            InputAction::MapTargetNudge { dx: 1, dy: 0 }
1100        );
1101        assert_eq!(
1102            state.apply_ui_key(
1103                key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1104                ActiveOverlay::None,
1105                true
1106            ),
1107            InputAction::MapTargetNudge { dx: 1, dy: 0 }
1108        );
1109        assert_eq!(state.current(), MovementInput::Stop);
1110    }
1111
1112    #[test]
1113    fn release_stops_movement() {
1114        let mut state = MovementState::default();
1115        state.apply_ui_key(
1116            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1117            ActiveOverlay::None,
1118            false,
1119        );
1120        assert_eq!(state.current(), MovementInput::Forward);
1121        assert_eq!(
1122            state.apply_ui_key(
1123                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1124                ActiveOverlay::None,
1125                false,
1126            ),
1127            InputAction::StopMovement
1128        );
1129        assert_eq!(state.current(), MovementInput::Stop);
1130    }
1131
1132    #[test]
1133    fn sync_physical_holds_keeps_and_clears_wasd() {
1134        let mut state = MovementState::default();
1135        state.sync_physical_holds(true, false, false, false, false, false, false, false);
1136        assert_eq!(state.current(), MovementInput::Forward);
1137        // Simulate gfx frames with W still down — must not depend on event repeats.
1138        state.expire_idle(Duration::from_millis(0));
1139        state.sync_physical_holds(true, false, false, false, false, false, false, false);
1140        assert_eq!(state.current(), MovementInput::Forward);
1141        state.sync_physical_holds(true, false, false, true, false, false, true, false);
1142        assert_eq!(state.current(), MovementInput::ForwardRight);
1143        assert!(state.sprinting());
1144        state.sync_physical_holds(false, false, false, false, false, false, false, false);
1145        assert_eq!(state.current(), MovementInput::Stop);
1146    }
1147
1148    #[test]
1149    fn idle_timeout_stops_movement() {
1150        let mut state = MovementState::default();
1151        state.apply_ui_key(
1152            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1153            ActiveOverlay::None,
1154            false,
1155        );
1156        assert_eq!(state.current(), MovementInput::Forward);
1157        state.expire_idle(Duration::from_millis(0));
1158        assert_eq!(state.current(), MovementInput::Stop);
1159    }
1160
1161    #[test]
1162    fn shift_uppercase_w_moves_forward() {
1163        let mut state = MovementState::default();
1164        let mut key = key(UiKeyCode::Char('W'), UiKeyEventKind::Press);
1165        key.modifiers = UiKeyModifiers {
1166            shift: true,
1167            control: false,
1168            alt: false,
1169        };
1170        state.apply_ui_key(key, ActiveOverlay::None, false);
1171        assert_eq!(state.current(), MovementInput::Forward);
1172        assert!(state.sprinting());
1173    }
1174
1175    #[test]
1176    fn diagonal_w_and_d() {
1177        let mut state = MovementState::default();
1178        state.apply_ui_key(
1179            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1180            ActiveOverlay::None,
1181            false,
1182        );
1183        state.apply_ui_key(
1184            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1185            ActiveOverlay::None,
1186            false,
1187        );
1188        assert_eq!(state.current(), MovementInput::ForwardRight);
1189        let (f, s) = state.current().components();
1190        assert!(f > 0.0 && s > 0.0);
1191    }
1192
1193    #[test]
1194    fn single_d_after_expired_chord_is_right_only() {
1195        // Regression: ghost keys from a latched chord must not resurrect as diagonal.
1196        let mut state = MovementState::default();
1197        state.apply_ui_key(
1198            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1199            ActiveOverlay::None,
1200            false,
1201        );
1202        state.apply_ui_key(
1203            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1204            ActiveOverlay::None,
1205            false,
1206        );
1207        assert_eq!(state.current(), MovementInput::ForwardRight);
1208        state.expire_idle(Duration::from_millis(0));
1209        assert_eq!(state.current(), MovementInput::Stop);
1210        state.apply_ui_key(
1211            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1212            ActiveOverlay::None,
1213            false,
1214        );
1215        assert_eq!(state.current(), MovementInput::Right);
1216    }
1217
1218    #[test]
1219    fn release_of_one_key_keeps_other_axis() {
1220        let mut state = MovementState::default();
1221        state.apply_ui_key(
1222            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1223            ActiveOverlay::None,
1224            false,
1225        );
1226        state.apply_ui_key(
1227            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1228            ActiveOverlay::None,
1229            false,
1230        );
1231        // After chord grace, releasing W leaves D alone.
1232        state.chord_formed_at = Some(Instant::now() - Duration::from_millis(500));
1233        assert_eq!(
1234            state.apply_ui_key(
1235                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1236                ActiveOverlay::None,
1237                false,
1238            ),
1239            InputAction::None
1240        );
1241        assert_eq!(state.current(), MovementInput::Right);
1242    }
1243
1244    #[test]
1245    fn chord_grace_keeps_diagonal_despite_spurious_release() {
1246        let mut state = MovementState::default();
1247        state.apply_ui_key(
1248            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1249            ActiveOverlay::None,
1250            false,
1251        );
1252        state.apply_ui_key(
1253            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1254            ActiveOverlay::None,
1255            false,
1256        );
1257        // Immediate fake Release of W (common when D is pressed).
1258        assert_eq!(
1259            state.apply_ui_key(
1260                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1261                ActiveOverlay::None,
1262                false,
1263            ),
1264            InputAction::None
1265        );
1266        assert_eq!(state.current(), MovementInput::ForwardRight);
1267        state.apply_ui_key(
1268            key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1269            ActiveOverlay::None,
1270            false,
1271        );
1272        assert_eq!(state.current(), MovementInput::ForwardRight);
1273    }
1274
1275    #[test]
1276    fn space_hard_stops() {
1277        let mut state = MovementState::default();
1278        state.apply_ui_key(
1279            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1280            ActiveOverlay::None,
1281            false,
1282        );
1283        state.apply_ui_key(
1284            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1285            ActiveOverlay::None,
1286            false,
1287        );
1288        assert_eq!(
1289            state.apply_ui_key(
1290                key(UiKeyCode::Char(' '), UiKeyEventKind::Press),
1291                ActiveOverlay::None,
1292                false
1293            ),
1294            InputAction::StopMovement
1295        );
1296        assert_eq!(state.current(), MovementInput::Stop);
1297    }
1298
1299    #[test]
1300    fn last_direction_remembered_after_release() {
1301        let mut state = MovementState::default();
1302        state.apply_ui_key(
1303            key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1304            ActiveOverlay::None,
1305            false,
1306        );
1307        let _ = state.apply_ui_key(
1308            key(UiKeyCode::Char('s'), UiKeyEventKind::Release),
1309            ActiveOverlay::None,
1310            false,
1311        );
1312        assert_eq!(state.current(), MovementInput::Stop);
1313        let (f, _) = state.last_move_axes();
1314        assert!(f < 0.0);
1315    }
1316
1317    #[test]
1318    fn repeat_keeps_diagonal_pair() {
1319        let mut state = MovementState::default();
1320        state.apply_ui_key(
1321            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1322            ActiveOverlay::None,
1323            false,
1324        );
1325        state.apply_ui_key(
1326            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1327            ActiveOverlay::None,
1328            false,
1329        );
1330        state.apply_ui_key(
1331            key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1332            ActiveOverlay::None,
1333            false,
1334        );
1335        assert_eq!(state.current(), MovementInput::ForwardRight);
1336    }
1337
1338    #[test]
1339    fn shift_held_sprints_without_modifier_on_repeat() {
1340        let mut state = MovementState::default();
1341        let shift_press = UiKeyEvent {
1342            code: UiKeyCode::ShiftLeft,
1343            modifiers: UiKeyModifiers::default(),
1344            kind: UiKeyEventKind::Press,
1345        };
1346        state.apply_ui_key(shift_press, ActiveOverlay::None, false);
1347        state.apply_ui_key(
1348            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1349            ActiveOverlay::None,
1350            false,
1351        );
1352        assert!(state.sprinting());
1353        state.apply_ui_key(
1354            key(UiKeyCode::Char('w'), UiKeyEventKind::Repeat),
1355            ActiveOverlay::None,
1356            false,
1357        );
1358        assert!(state.sprinting());
1359    }
1360
1361    #[test]
1362    fn diagonal_normalized() {
1363        let (f, s) = MovementInput::ForwardRight.components();
1364        let len = (f * f + s * s).sqrt();
1365        assert!((len - 1.0).abs() < 0.001);
1366    }
1367
1368    #[test]
1369    fn overlay_esc_closes() {
1370        let mut state = MovementState::default();
1371        assert_eq!(
1372            state.apply_ui_key(
1373                key(UiKeyCode::Esc, UiKeyEventKind::Press),
1374                ActiveOverlay::Loadout,
1375                false
1376            ),
1377            InputAction::CloseOverlay
1378        );
1379    }
1380
1381    #[test]
1382    fn stats_overlay_tab_cycles_sheet_not_combat_target() {
1383        let mut state = MovementState::default();
1384        assert_eq!(
1385            state.apply_ui_key(
1386                key(UiKeyCode::Tab, UiKeyEventKind::Press),
1387                ActiveOverlay::Stats,
1388                false
1389            ),
1390            InputAction::CycleCharacterSheetTab
1391        );
1392        assert_eq!(
1393            state.apply_ui_key(
1394                key(UiKeyCode::BackTab, UiKeyEventKind::Press),
1395                ActiveOverlay::Stats,
1396                false
1397            ),
1398            InputAction::CycleCharacterSheetTab
1399        );
1400        assert_eq!(
1401            state.apply_ui_key(
1402                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1403                ActiveOverlay::Stats,
1404                false
1405            ),
1406            InputAction::LedgerPeriodDigit('1')
1407        );
1408        assert_eq!(
1409            state.apply_ui_key(
1410                key(UiKeyCode::Char('i'), UiKeyEventKind::Press),
1411                ActiveOverlay::Stats,
1412                false
1413            ),
1414            InputAction::ToggleStats
1415        );
1416        // Combat Tab targeting must not win while the sheet is open.
1417        assert_ne!(
1418            state.apply_ui_key(
1419                key(UiKeyCode::Tab, UiKeyEventKind::Press),
1420                ActiveOverlay::Stats,
1421                false
1422            ),
1423            InputAction::CycleCombatTarget { reverse: false }
1424        );
1425    }
1426
1427    #[test]
1428    fn overlay_allows_toggle_keys() {
1429        let mut state = MovementState::default();
1430        assert_eq!(
1431            state.apply_ui_key(
1432                key(UiKeyCode::Char('l'), UiKeyEventKind::Press),
1433                ActiveOverlay::Loadout,
1434                false
1435            ),
1436            InputAction::ToggleLoadout
1437        );
1438        assert_eq!(
1439            state.apply_ui_key(
1440                key(UiKeyCode::Char('o'), UiKeyEventKind::Press),
1441                ActiveOverlay::RotationEditor(RotationEditorMode::List),
1442                false
1443            ),
1444            InputAction::ToggleRotationEditor
1445        );
1446    }
1447
1448    #[test]
1449    fn overlay_honors_key_release() {
1450        let mut state = MovementState::default();
1451        state.apply_ui_key(
1452            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1453            ActiveOverlay::None,
1454            false,
1455        );
1456        assert_eq!(state.current(), MovementInput::Forward);
1457        state.apply_ui_key(
1458            key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1459            ActiveOverlay::Loadout,
1460            false,
1461        );
1462        assert_eq!(state.current(), MovementInput::Stop);
1463    }
1464
1465    #[test]
1466    fn overlay_assign_keys() {
1467        let mut state = MovementState::default();
1468        assert_eq!(
1469            state.apply_ui_key(
1470                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1471                ActiveOverlay::Loadout,
1472                false
1473            ),
1474            InputAction::LoadoutAssignT1
1475        );
1476        assert_eq!(
1477            state.apply_ui_key(
1478                key(UiKeyCode::Char('2'), UiKeyEventKind::Press),
1479                ActiveOverlay::Loadout,
1480                false
1481            ),
1482            InputAction::LoadoutAssignT2
1483        );
1484        assert_eq!(
1485            state.apply_ui_key(
1486                key(UiKeyCode::Enter, UiKeyEventKind::Press),
1487                ActiveOverlay::Loadout,
1488                false
1489            ),
1490            InputAction::LoadoutBindHotbar
1491        );
1492        assert_eq!(
1493            state.apply_ui_key(
1494                key(UiKeyCode::Delete, UiKeyEventKind::Press),
1495                ActiveOverlay::Loadout,
1496                false
1497            ),
1498            InputAction::LoadoutClearHotbar
1499        );
1500        assert_eq!(
1501            state.apply_ui_key(
1502                key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1503                ActiveOverlay::Loadout,
1504                false
1505            ),
1506            InputAction::LoadoutHotbarNext
1507        );
1508    }
1509
1510    #[test]
1511    fn rotation_editor_slash_moves_ability_down() {
1512        let mut state = MovementState::default();
1513        assert_eq!(
1514            state.apply_ui_key(
1515                key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
1516                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1517                false
1518            ),
1519            InputAction::RotationEditorMoveAbilityDown
1520        );
1521    }
1522
1523    #[test]
1524    fn rotation_editor_bracket_keys_reorder() {
1525        let mut state = MovementState::default();
1526        assert_eq!(
1527            state.apply_ui_key(
1528                key(UiKeyCode::Char('['), UiKeyEventKind::Press),
1529                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1530                false
1531            ),
1532            InputAction::RotationEditorMoveAbilityUp
1533        );
1534        assert_eq!(
1535            state.apply_ui_key(
1536                key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1537                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1538                false
1539            ),
1540            InputAction::RotationEditorMoveAbilityDown
1541        );
1542    }
1543
1544    #[test]
1545    fn rotation_editor_s_saves() {
1546        let mut state = MovementState::default();
1547        assert_eq!(
1548            state.apply_ui_key(
1549                key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1550                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1551                false
1552            ),
1553            InputAction::RotationEditorSave
1554        );
1555    }
1556
1557    #[test]
1558    fn claim_mode_wasd_moves_footprint() {
1559        let mut state = MovementState::default();
1560        assert_eq!(
1561            state.apply_ui_key_ex(
1562                key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1563                ActiveOverlay::None,
1564                false,
1565                false,
1566                true,
1567            ),
1568            InputAction::ClaimMoveNudge { dx: 0, dy: 1 }
1569        );
1570        assert_eq!(
1571            state.apply_ui_key_ex(
1572                key(UiKeyCode::Char('a'), UiKeyEventKind::Press),
1573                ActiveOverlay::None,
1574                false,
1575                false,
1576                true,
1577            ),
1578            InputAction::ClaimMoveNudge { dx: -1, dy: 0 }
1579        );
1580        assert_eq!(
1581            state.apply_ui_key_ex(
1582                key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1583                ActiveOverlay::None,
1584                false,
1585                false,
1586                true,
1587            ),
1588            InputAction::ClaimNudge { dw: 1, dh: 1 }
1589        );
1590        assert_eq!(
1591            state.apply_ui_key_ex(
1592                key(UiKeyCode::Enter, UiKeyEventKind::Press),
1593                ActiveOverlay::None,
1594                false,
1595                false,
1596                true,
1597            ),
1598            InputAction::ClaimConfirm
1599        );
1600    }
1601}