Skip to main content

flatland_client_ui/
app.rs

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