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