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