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