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/// Long enough to bridge OS key-repeat initial delay; release events stop instantly.
22pub const MOVEMENT_IDLE_TIMEOUT: Duration = Duration::from_millis(320);
23/// After a second direction joins, ignore releases briefly (terminals often emit a
24/// fake Release for the first key when the second is pressed).
25const CHORD_RELEASE_GRACE: Duration = Duration::from_millis(220);
26const SPRINT_SHIFT_REFRESH: Duration = Duration::from_millis(700);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29enum DirectionKey {
30    Up,
31    Down,
32    Left,
33    Right,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37enum VerticalKey {
38    Up,
39    Down,
40}
41
42fn direction_from_key(code: UiKeyCode) -> Option<DirectionKey> {
43    match code {
44        UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
45            'w' => Some(DirectionKey::Up),
46            's' => Some(DirectionKey::Down),
47            'a' => Some(DirectionKey::Left),
48            'd' => Some(DirectionKey::Right),
49            _ => None,
50        },
51        UiKeyCode::Up => Some(DirectionKey::Up),
52        UiKeyCode::Down => Some(DirectionKey::Down),
53        UiKeyCode::Left => Some(DirectionKey::Left),
54        UiKeyCode::Right => Some(DirectionKey::Right),
55        _ => None,
56    }
57}
58
59fn direction_axes(dir: DirectionKey) -> (f32, f32) {
60    match dir {
61        DirectionKey::Up => (1.0, 0.0),
62        DirectionKey::Down => (-1.0, 0.0),
63        DirectionKey::Left => (0.0, -1.0),
64        DirectionKey::Right => (0.0, 1.0),
65    }
66}
67
68fn is_shift_key(code: UiKeyCode) -> bool {
69    matches!(code, UiKeyCode::ShiftLeft | UiKeyCode::ShiftRight)
70}
71
72#[derive(Debug, Default)]
73pub struct MapTargetState {
74    pub active: bool,
75    pub cursor_x: f32,
76    pub cursor_y: f32,
77}
78
79impl MapTargetState {
80    pub fn activate_at(&mut self, x: f32, y: f32) {
81        self.active = true;
82        self.cursor_x = x;
83        self.cursor_y = y;
84    }
85
86    pub fn deactivate(&mut self) {
87        self.active = false;
88    }
89
90    pub fn nudge(&mut self, dx: i32, dy: i32, max_x: f32, max_y: f32) {
91        self.cursor_x = (self.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
92        self.cursor_y = (self.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
93    }
94}
95
96/// Keyboard movement state.
97///
98/// Cardinals: WASD / arrows. Hold two cardinals for diagonal (mouse pathing also works).
99/// Space hard-stops. Idle timeout is a fallback if the terminal omits releases.
100#[derive(Debug)]
101pub struct MovementState {
102    held_dirs: HashMap<DirectionKey, Instant>,
103    vertical_held: HashMap<VerticalKey, Instant>,
104    shift_held: bool,
105    sprint_until: Instant,
106    sprint_toggle: bool,
107    last_forward: f32,
108    last_strafe: f32,
109    /// Set when a second WASD direction joins; suppresses bogus releases briefly.
110    chord_formed_at: Option<Instant>,
111    pub keys: ClientKeyBindings,
112}
113
114impl Default for MovementState {
115    fn default() -> Self {
116        Self {
117            held_dirs: HashMap::new(),
118            vertical_held: HashMap::new(),
119            shift_held: false,
120            sprint_until: Instant::now(),
121            sprint_toggle: false,
122            last_forward: 0.0,
123            last_strafe: 0.0,
124            chord_formed_at: None,
125            keys: ClientKeyBindings::default(),
126        }
127    }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum MovementInput {
132    Stop,
133    Forward,
134    Back,
135    Left,
136    Right,
137    ForwardLeft,
138    ForwardRight,
139    BackLeft,
140    BackRight,
141}
142
143impl MovementInput {
144    pub fn components(self) -> (f32, f32) {
145        let (mut forward, mut strafe): (f32, f32) = match self {
146            Self::Stop => (0.0, 0.0),
147            Self::Forward => (1.0, 0.0),
148            Self::Back => (-1.0, 0.0),
149            Self::Left => (0.0, -1.0),
150            Self::Right => (0.0, 1.0),
151            Self::ForwardLeft => (1.0, -1.0),
152            Self::ForwardRight => (1.0, 1.0),
153            Self::BackLeft => (-1.0, -1.0),
154            Self::BackRight => (-1.0, 1.0),
155        };
156
157        let len = (forward * forward + strafe * strafe).sqrt();
158        if len > 1.0 {
159            forward /= len;
160            strafe /= len;
161        }
162        (forward, strafe)
163    }
164
165    pub fn label(self) -> &'static str {
166        match self {
167            Self::Stop => "stop",
168            Self::Forward => "up",
169            Self::Back => "down",
170            Self::Left => "left",
171            Self::Right => "right",
172            Self::ForwardLeft => "up-left",
173            Self::ForwardRight => "up-right",
174            Self::BackLeft => "down-left",
175            Self::BackRight => "down-right",
176        }
177    }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq)]
181pub enum InputAction {
182    Quit,
183    Harvest,
184    Pickup,
185    Craft,
186    Interact,
187    TestDamage,
188    CycleCombatTarget {
189        reverse: bool,
190    },
191    CycleCombatTargetT2 {
192        reverse: bool,
193    },
194    AdvanceRotationT1,
195    AdvanceRotationT2,
196    ToggleAutoT1,
197    ToggleAutoT2,
198    ToggleLoadout,
199    ToggleRotationEditor,
200    LoadoutAssignT1,
201    LoadoutAssignT2,
202    LoadoutMenuUp,
203    LoadoutMenuDown,
204    RotationEditorListUp,
205    RotationEditorListDown,
206    RotationEditorEdit,
207    RotationEditorNew,
208    RotationEditorDelete,
209    RotationEditorBack,
210    RotationEditorAddAbility,
211    RotationEditorRemoveAbility,
212    RotationEditorMoveAbilityUp,
213    RotationEditorMoveAbilityDown,
214    RotationEditorAbilityUp,
215    RotationEditorAbilityDown,
216    RotationEditorPickerUp,
217    RotationEditorPickerDown,
218    RotationEditorPickAbility,
219    RotationEditorRename,
220    RotationEditorConfirmLabel,
221    RotationEditorLabelBackspace,
222    RotationEditorLabelChar(char),
223    RotationEditorSave,
224    CloseOverlay,
225    ClearCombatTarget,
226    ToggleStats,
227    ToggleEquip,
228    CycleCharacterSheetTab,
229    LedgerPeriodDigit(char),
230    ToggleInventory,
231    ToggleKeychain,
232    ToggleQuestMenu,
233    QuestMenuUp,
234    QuestMenuDown,
235    QuestWithdraw,
236    ToggleWorkersMenu,
237    ToggleHelp,
238    CycleHudView,
239    StartChat {
240        whisper: bool,
241    },
242    SubmitChat,
243    CancelChat,
244    Dodge,
245    Lunge,
246    /// Alt + movement — leap one cell over a medium cliff (`plans/04` §4.5b).
247    DirectionalJump {
248        forward: f32,
249        strafe: f32,
250    },
251    ToggleBlock,
252    ToggleSprintMode,
253    ToggleMapTarget,
254    ConfirmMapTarget,
255    CancelMapTarget,
256    MapTargetNudge {
257        dx: i32,
258        dy: i32,
259    },
260    CancelAutoNav,
261    /// Hard stop: clear held keys and cancel auto-nav (Space).
262    StopMovement,
263    /// Context use: interact → pickup → harvest.
264    UseWorld,
265    CastHotbar {
266        slot: u8,
267    },
268    ClearCombatTargetT2,
269    None,
270}
271
272fn rotation_editor_action(key: UiKeyEvent, mode: RotationEditorMode) -> Option<InputAction> {
273    let shift = key.modifiers.shift;
274    Some(match mode {
275        RotationEditorMode::List => match key.code {
276            UiKeyCode::Esc => InputAction::CloseOverlay,
277            UiKeyCode::Up => InputAction::RotationEditorListUp,
278            UiKeyCode::Down => InputAction::RotationEditorListDown,
279            UiKeyCode::Enter => InputAction::RotationEditorEdit,
280            UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
281                'n' => InputAction::RotationEditorNew,
282                'd' => InputAction::RotationEditorDelete,
283                _ => return None,
284            },
285            _ => return None,
286        },
287        RotationEditorMode::EditSequence => match key.code {
288            UiKeyCode::Up if shift => InputAction::RotationEditorMoveAbilityUp,
289            UiKeyCode::Down if shift => InputAction::RotationEditorMoveAbilityDown,
290            UiKeyCode::Esc => InputAction::RotationEditorBack,
291            UiKeyCode::Char('[') | UiKeyCode::Char(';') => InputAction::RotationEditorMoveAbilityUp,
292            UiKeyCode::Char(']') | UiKeyCode::Char('/') | UiKeyCode::Char('\\') => {
293                InputAction::RotationEditorMoveAbilityDown
294            }
295            UiKeyCode::Up => InputAction::RotationEditorAbilityUp,
296            UiKeyCode::Down => InputAction::RotationEditorAbilityDown,
297            UiKeyCode::Delete => InputAction::RotationEditorRemoveAbility,
298            UiKeyCode::Enter => InputAction::RotationEditorSave,
299            UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
300                'a' => InputAction::RotationEditorAddAbility,
301                'x' => InputAction::RotationEditorRemoveAbility,
302                'r' => InputAction::RotationEditorRename,
303                's' => InputAction::RotationEditorSave,
304                _ => return None,
305            },
306            _ => return None,
307        },
308        RotationEditorMode::PickAbility => match key.code {
309            UiKeyCode::Esc => InputAction::RotationEditorBack,
310            UiKeyCode::Up => InputAction::RotationEditorPickerUp,
311            UiKeyCode::Down => InputAction::RotationEditorPickerDown,
312            UiKeyCode::Enter => InputAction::RotationEditorPickAbility,
313            _ => return None,
314        },
315        RotationEditorMode::EditLabel => match key.code {
316            UiKeyCode::Esc => InputAction::RotationEditorBack,
317            UiKeyCode::Enter => InputAction::RotationEditorConfirmLabel,
318            UiKeyCode::Backspace => InputAction::RotationEditorLabelBackspace,
319            UiKeyCode::Char(c) if !key.modifiers.control => InputAction::RotationEditorLabelChar(c),
320            _ => return None,
321        },
322    })
323}
324
325impl MovementState {
326    pub fn with_keys(keys: ClientKeyBindings) -> Self {
327        Self {
328            keys,
329            ..Default::default()
330        }
331    }
332
333    pub fn idle_timeout(&self) -> Duration {
334        MOVEMENT_IDLE_TIMEOUT
335    }
336
337    /// Mark `dir` as held. Soft-refresh companions that are still within the idle
338    /// window so a chord survives when the terminal only repeats the last key.
339    /// Never resurrects keys that have already timed out.
340    fn touch_dir(&mut self, dir: DirectionKey) {
341        let now = Instant::now();
342        let idle = self.idle_timeout();
343        let joining_chord = !self.held_dirs.contains_key(&dir)
344            && self.held_dirs.values().any(|at| at.elapsed() < idle);
345        if joining_chord {
346            self.chord_formed_at = Some(now);
347        }
348        self.held_dirs.insert(dir, now);
349        for (other, at) in self.held_dirs.iter_mut() {
350            if *other != dir && at.elapsed() < idle {
351                *at = now;
352            }
353        }
354    }
355
356    fn release_dir(&mut self, dir: DirectionKey) {
357        self.held_dirs.remove(&dir);
358    }
359
360    fn refresh_sprint(&mut self, key: &UiKeyEvent) {
361        let shift = key.modifiers.shift
362            || matches!(
363                key.code,
364                UiKeyCode::Char(c)
365                    if c.is_ascii_uppercase() && direction_from_key(key.code).is_some()
366            );
367        if shift {
368            self.sprint_until = Instant::now() + SPRINT_SHIFT_REFRESH;
369        }
370    }
371
372    fn dir_active(&self, dir: DirectionKey) -> bool {
373        let idle = self.idle_timeout();
374        self.held_dirs
375            .get(&dir)
376            .is_some_and(|at| at.elapsed() < idle)
377    }
378
379    fn vertical_active(&self, key: VerticalKey) -> bool {
380        let idle = self.idle_timeout();
381        self.vertical_held
382            .get(&key)
383            .is_some_and(|at| at.elapsed() < idle)
384    }
385
386    pub fn reset(&mut self) {
387        self.held_dirs.clear();
388        self.vertical_held.clear();
389        self.shift_held = false;
390        self.sprint_until = Instant::now();
391        self.chord_formed_at = None;
392    }
393
394    pub fn sprint_mode(&self) -> bool {
395        self.sprint_toggle
396    }
397
398    pub fn toggle_sprint_mode(&mut self) {
399        self.sprint_toggle = !self.sprint_toggle;
400    }
401
402    pub fn vertical_axis(&self) -> f32 {
403        let up = self.vertical_active(VerticalKey::Up);
404        let down = self.vertical_active(VerticalKey::Down);
405        match (up, down) {
406            (true, false) => 1.0,
407            (false, true) => -1.0,
408            _ => 0.0,
409        }
410    }
411
412    pub fn sprinting(&self) -> bool {
413        self.sprint_toggle || self.shift_held || Instant::now() < self.sprint_until
414    }
415
416    pub fn last_move_axes(&self) -> (f32, f32) {
417        (self.last_forward, self.last_strafe)
418    }
419
420    fn remember_movement(&mut self) {
421        let (forward, strafe) = self.current().components();
422        if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
423            self.last_forward = forward;
424            self.last_strafe = strafe;
425        }
426    }
427
428    pub fn current(&self) -> MovementInput {
429        let up = self.dir_active(DirectionKey::Up);
430        let down = self.dir_active(DirectionKey::Down);
431        let left = self.dir_active(DirectionKey::Left);
432        let right = self.dir_active(DirectionKey::Right);
433
434        // Opposing axes cancel; remaining axes combine into 8-way movement.
435        let forward = match (up, down) {
436            (true, false) => 1,
437            (false, true) => -1,
438            _ => 0,
439        };
440        let strafe = match (left, right) {
441            (true, false) => -1,
442            (false, true) => 1,
443            _ => 0,
444        };
445        match (forward, strafe) {
446            (1, 0) => MovementInput::Forward,
447            (-1, 0) => MovementInput::Back,
448            (0, -1) => MovementInput::Left,
449            (0, 1) => MovementInput::Right,
450            (1, -1) => MovementInput::ForwardLeft,
451            (1, 1) => MovementInput::ForwardRight,
452            (-1, -1) => MovementInput::BackLeft,
453            (-1, 1) => MovementInput::BackRight,
454            _ => MovementInput::Stop,
455        }
456    }
457
458    pub fn apply_ui_key(
459        &mut self,
460        key: UiKeyEvent,
461        overlay: ActiveOverlay,
462        map_target_active: bool,
463    ) -> InputAction {
464        if key.kind == UiKeyEventKind::Press
465            && key.modifiers.control
466            && matches!(key.code, UiKeyCode::Char('q') | UiKeyCode::Char('c'))
467        {
468            return InputAction::Quit;
469        }
470
471        if overlay != ActiveOverlay::None {
472            // Always honor releases in overlays so WASD cannot stick under a menu.
473            if key.kind == UiKeyEventKind::Release {
474                if let Some(dir) = direction_from_key(key.code) {
475                    self.release_dir(dir);
476                    self.remember_movement();
477                }
478                match key.code {
479                    UiKeyCode::Char('u') => {
480                        self.vertical_held.remove(&VerticalKey::Up);
481                    }
482                    UiKeyCode::Char('j') => {
483                        self.vertical_held.remove(&VerticalKey::Down);
484                    }
485                    _ => {}
486                }
487                return InputAction::None;
488            }
489            if key.kind == UiKeyEventKind::Press {
490                let action = match overlay {
491                    ActiveOverlay::Loadout => match key.code {
492                        UiKeyCode::Esc => Some(InputAction::CloseOverlay),
493                        UiKeyCode::Up => Some(InputAction::LoadoutMenuUp),
494                        UiKeyCode::Down => Some(InputAction::LoadoutMenuDown),
495                        UiKeyCode::Char('1') => Some(InputAction::LoadoutAssignT1),
496                        UiKeyCode::Char('2') => Some(InputAction::LoadoutAssignT2),
497                        _ => None,
498                    },
499                    ActiveOverlay::RotationEditor(mode) => rotation_editor_action(key, mode),
500                    ActiveOverlay::Stats => match key.code {
501                        UiKeyCode::Esc => Some(InputAction::ToggleStats),
502                        UiKeyCode::Tab | UiKeyCode::BackTab => {
503                            Some(InputAction::CycleCharacterSheetTab)
504                        }
505                        UiKeyCode::Char('i') | UiKeyCode::Char('I') => {
506                            Some(InputAction::ToggleStats)
507                        }
508                        UiKeyCode::Char(c) if matches!(c, '1' | '2' | '3' | '4') => {
509                            Some(InputAction::LedgerPeriodDigit(c))
510                        }
511                        _ => None,
512                    },
513                    ActiveOverlay::None => None,
514                };
515                if let Some(action) = action {
516                    return action;
517                }
518                if let Some(action) = combat_action_for_key(&key, &self.keys) {
519                    return match action {
520                        CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
521                        CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
522                        _ => InputAction::None,
523                    };
524                }
525            }
526            return InputAction::None;
527        }
528
529        // Map-target mode owns WASD entirely (press + repeat). No character movement.
530        if map_target_active {
531            if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
532                return match key.code {
533                    UiKeyCode::Esc => InputAction::CancelMapTarget,
534                    UiKeyCode::Enter => InputAction::ConfirmMapTarget,
535                    UiKeyCode::Char('m') => InputAction::CancelMapTarget,
536                    UiKeyCode::Char(' ') => InputAction::StopMovement,
537                    UiKeyCode::Char('w') | UiKeyCode::Up => {
538                        InputAction::MapTargetNudge { dx: 0, dy: 1 }
539                    }
540                    UiKeyCode::Char('s') | UiKeyCode::Down => {
541                        InputAction::MapTargetNudge { dx: 0, dy: -1 }
542                    }
543                    UiKeyCode::Char('a') | UiKeyCode::Left => {
544                        InputAction::MapTargetNudge { dx: -1, dy: 0 }
545                    }
546                    UiKeyCode::Char('d') | UiKeyCode::Right => {
547                        InputAction::MapTargetNudge { dx: 1, dy: 0 }
548                    }
549                    UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
550                        'w' => InputAction::MapTargetNudge { dx: 0, dy: 1 },
551                        's' => InputAction::MapTargetNudge { dx: 0, dy: -1 },
552                        'a' => InputAction::MapTargetNudge { dx: -1, dy: 0 },
553                        'd' => InputAction::MapTargetNudge { dx: 1, dy: 0 },
554                        _ => InputAction::None,
555                    },
556                    _ => InputAction::None,
557                };
558            }
559            return InputAction::None;
560        }
561
562        if key.kind == UiKeyEventKind::Press {
563            // Combat binds first so configurable keys (dodge/block/lunge/hotbar) win.
564            if let Some(action) = combat_action_for_key(&key, &self.keys) {
565                return match action {
566                    CombatKeyAction::CycleTargetT1 { reverse } => {
567                        InputAction::CycleCombatTarget { reverse }
568                    }
569                    CombatKeyAction::CycleTargetT2 { reverse } => {
570                        InputAction::CycleCombatTargetT2 { reverse }
571                    }
572                    CombatKeyAction::ToggleAutoT1 => InputAction::ToggleAutoT1,
573                    CombatKeyAction::ToggleAutoT2 => InputAction::ToggleAutoT2,
574                    CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
575                    CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
576                    CombatKeyAction::Dodge => InputAction::Dodge,
577                    CombatKeyAction::Lunge => InputAction::Lunge,
578                    CombatKeyAction::ToggleBlock => InputAction::ToggleBlock,
579                    CombatKeyAction::ClearTargetT1 => InputAction::ClearCombatTarget,
580                    CombatKeyAction::ClearTargetT2 => InputAction::ClearCombatTargetT2,
581                    CombatKeyAction::Hotbar(slot) => InputAction::CastHotbar { slot },
582                };
583            }
584
585            match key.code {
586                UiKeyCode::Tab => return InputAction::CycleCharacterSheetTab,
587                UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
588                    'f' => return InputAction::UseWorld,
589                    'n' if !key.modifiers.control => return InputAction::Craft,
590                    ',' => return InputAction::ToggleKeychain,
591                    'i' => return InputAction::ToggleStats,
592                    'p' if !key.modifiers.control => return InputAction::ToggleEquip,
593                    '1' | '2' | '3' | '4' => {
594                        return InputAction::LedgerPeriodDigit(c.to_ascii_lowercase())
595                    }
596                    'b' if !key.modifiers.control => return InputAction::ToggleInventory,
597                    'v' if !key.modifiers.control => return InputAction::ToggleQuestMenu,
598                    'h' if !key.modifiers.control => return InputAction::ToggleWorkersMenu,
599                    '?' | '/' => return InputAction::ToggleHelp,
600                    '.' => return InputAction::CycleHudView,
601                    '-' => return InputAction::TestDamage,
602                    't' => return InputAction::StartChat { whisper: false },
603                    'g' => return InputAction::StartChat { whisper: true },
604                    'x' => return InputAction::ToggleSprintMode,
605                    'm' => return InputAction::ToggleMapTarget,
606                    ' ' => {
607                        self.reset();
608                        return InputAction::StopMovement;
609                    }
610                    _ => {}
611                },
612                UiKeyCode::Enter => return InputAction::SubmitChat,
613                _ => {}
614            }
615        }
616
617        if is_shift_key(key.code) {
618            match key.kind {
619                UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
620                    self.shift_held = true;
621                    self.sprint_until = Instant::now() + Duration::from_secs(30);
622                }
623                UiKeyEventKind::Release => {
624                    self.shift_held = false;
625                    if !self.sprint_toggle {
626                        self.sprint_until = Instant::now();
627                    }
628                }
629            }
630            return InputAction::None;
631        }
632
633        let vertical = match key.code {
634            UiKeyCode::Char('u') => Some(VerticalKey::Up),
635            UiKeyCode::Char('j') => Some(VerticalKey::Down),
636            _ => None,
637        };
638        if let Some(v) = vertical {
639            match key.kind {
640                UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
641                    self.vertical_held.insert(v, Instant::now());
642                }
643                UiKeyEventKind::Release => {
644                    self.vertical_held.remove(&v);
645                }
646            }
647            return InputAction::None;
648        }
649
650        let Some(dir) = direction_from_key(key.code) else {
651            return InputAction::None;
652        };
653
654        if key.modifiers.alt && key.kind == UiKeyEventKind::Press {
655            let (forward, strafe) = direction_axes(dir);
656            return InputAction::DirectionalJump { forward, strafe };
657        }
658
659        match key.kind {
660            UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
661                self.touch_dir(dir);
662                self.refresh_sprint(&key);
663                self.remember_movement();
664                InputAction::None
665            }
666            UiKeyEventKind::Release => {
667                let was_held = self.held_dirs.contains_key(&dir);
668                let in_chord_grace = self
669                    .chord_formed_at
670                    .is_some_and(|t| t.elapsed() < CHORD_RELEASE_GRACE);
671                // Single-key: stop immediately. Chord just formed: ignore the
672                // common fake Release of the first key when the second is pressed.
673                if was_held && !in_chord_grace {
674                    self.release_dir(dir);
675                    if self.held_dirs.len() < 2 {
676                        self.chord_formed_at = None;
677                    }
678                    self.remember_movement();
679                    if self.current() == MovementInput::Stop {
680                        return InputAction::StopMovement;
681                    }
682                }
683                InputAction::None
684            }
685        }
686    }
687
688    /// Drop keys whose last press/repeat is older than `idle`.
689    /// Call once per movement tick. Never permanently latches chords.
690    pub fn expire_idle(&mut self, idle: Duration) {
691        self.held_dirs.retain(|_, at| at.elapsed() < idle);
692        self.vertical_held.retain(|_, at| at.elapsed() < idle);
693        if self.held_dirs.len() < 2 {
694            self.chord_formed_at = None;
695        }
696    }
697}
698
699#[cfg(test)]
700mod tests {
701    use super::*;
702    use crate::input::{UiKeyCode, UiKeyEventKind, UiKeyModifiers};
703
704    fn key(code: UiKeyCode, kind: UiKeyEventKind) -> UiKeyEvent {
705        UiKeyEvent {
706            code,
707            modifiers: UiKeyModifiers::default(),
708            kind,
709        }
710    }
711
712    #[test]
713    fn combat_keys_on_left_hand() {
714        let mut state = MovementState::default();
715        assert_eq!(
716            state.apply_ui_key(
717                key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
718                ActiveOverlay::None,
719                false
720            ),
721            InputAction::Dodge
722        );
723        assert_eq!(
724            state.apply_ui_key(
725                key(UiKeyCode::Char('q'), UiKeyEventKind::Press),
726                ActiveOverlay::None,
727                false
728            ),
729            InputAction::ToggleBlock
730        );
731        assert_eq!(
732            state.apply_ui_key(
733                UiKeyEvent {
734                    code: UiKeyCode::Char(' '),
735                    modifiers: UiKeyModifiers {
736                        shift: true,
737                        control: false,
738                        alt: false
739                    },
740                    kind: UiKeyEventKind::Press
741                },
742                ActiveOverlay::None,
743                false
744            ),
745            InputAction::Lunge
746        );
747        // e is no longer a dedicated diagonal
748        assert_eq!(
749            state.apply_ui_key(
750                key(UiKeyCode::Char('e'), UiKeyEventKind::Press),
751                ActiveOverlay::None,
752                false
753            ),
754            InputAction::None
755        );
756        assert_eq!(state.current(), MovementInput::Stop);
757    }
758
759    #[test]
760    fn world_and_utility_row_bindings() {
761        let mut state = MovementState::default();
762        assert_eq!(
763            state.apply_ui_key(
764                key(UiKeyCode::Char('f'), UiKeyEventKind::Press),
765                ActiveOverlay::None,
766                false
767            ),
768            InputAction::UseWorld
769        );
770        assert_eq!(
771            state.apply_ui_key(
772                key(UiKeyCode::Char('n'), UiKeyEventKind::Press),
773                ActiveOverlay::None,
774                false
775            ),
776            InputAction::Craft
777        );
778        assert_eq!(
779            state.apply_ui_key(
780                key(UiKeyCode::Char(','), UiKeyEventKind::Press),
781                ActiveOverlay::None,
782                false
783            ),
784            InputAction::ToggleKeychain
785        );
786        assert_eq!(
787            state.apply_ui_key(
788                key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
789                ActiveOverlay::None,
790                false
791            ),
792            InputAction::ToggleHelp
793        );
794        assert_eq!(
795            state.apply_ui_key(
796                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
797                ActiveOverlay::None,
798                false
799            ),
800            InputAction::CastHotbar { slot: 1 }
801        );
802    }
803
804    #[test]
805    fn map_target_repeat_nudges_cursor() {
806        let mut state = MovementState::default();
807        assert_eq!(
808            state.apply_ui_key(
809                key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
810                ActiveOverlay::None,
811                true
812            ),
813            InputAction::MapTargetNudge { dx: 1, dy: 0 }
814        );
815        assert_eq!(
816            state.apply_ui_key(
817                key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
818                ActiveOverlay::None,
819                true
820            ),
821            InputAction::MapTargetNudge { dx: 1, dy: 0 }
822        );
823        assert_eq!(state.current(), MovementInput::Stop);
824    }
825
826    #[test]
827    fn release_stops_movement() {
828        let mut state = MovementState::default();
829        state.apply_ui_key(
830            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
831            ActiveOverlay::None,
832            false,
833        );
834        assert_eq!(state.current(), MovementInput::Forward);
835        assert_eq!(
836            state.apply_ui_key(
837                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
838                ActiveOverlay::None,
839                false,
840            ),
841            InputAction::StopMovement
842        );
843        assert_eq!(state.current(), MovementInput::Stop);
844    }
845
846    #[test]
847    fn idle_timeout_stops_movement() {
848        let mut state = MovementState::default();
849        state.apply_ui_key(
850            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
851            ActiveOverlay::None,
852            false,
853        );
854        assert_eq!(state.current(), MovementInput::Forward);
855        state.expire_idle(Duration::from_millis(0));
856        assert_eq!(state.current(), MovementInput::Stop);
857    }
858
859    #[test]
860    fn shift_uppercase_w_moves_forward() {
861        let mut state = MovementState::default();
862        let mut key = key(UiKeyCode::Char('W'), UiKeyEventKind::Press);
863        key.modifiers = UiKeyModifiers {
864            shift: true,
865            control: false,
866            alt: false,
867        };
868        state.apply_ui_key(key, ActiveOverlay::None, false);
869        assert_eq!(state.current(), MovementInput::Forward);
870        assert!(state.sprinting());
871    }
872
873    #[test]
874    fn diagonal_w_and_d() {
875        let mut state = MovementState::default();
876        state.apply_ui_key(
877            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
878            ActiveOverlay::None,
879            false,
880        );
881        state.apply_ui_key(
882            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
883            ActiveOverlay::None,
884            false,
885        );
886        assert_eq!(state.current(), MovementInput::ForwardRight);
887        let (f, s) = state.current().components();
888        assert!(f > 0.0 && s > 0.0);
889    }
890
891    #[test]
892    fn single_d_after_expired_chord_is_right_only() {
893        // Regression: ghost keys from a latched chord must not resurrect as diagonal.
894        let mut state = MovementState::default();
895        state.apply_ui_key(
896            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
897            ActiveOverlay::None,
898            false,
899        );
900        state.apply_ui_key(
901            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
902            ActiveOverlay::None,
903            false,
904        );
905        assert_eq!(state.current(), MovementInput::ForwardRight);
906        state.expire_idle(Duration::from_millis(0));
907        assert_eq!(state.current(), MovementInput::Stop);
908        state.apply_ui_key(
909            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
910            ActiveOverlay::None,
911            false,
912        );
913        assert_eq!(state.current(), MovementInput::Right);
914    }
915
916    #[test]
917    fn release_of_one_key_keeps_other_axis() {
918        let mut state = MovementState::default();
919        state.apply_ui_key(
920            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
921            ActiveOverlay::None,
922            false,
923        );
924        state.apply_ui_key(
925            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
926            ActiveOverlay::None,
927            false,
928        );
929        // After chord grace, releasing W leaves D alone.
930        state.chord_formed_at = Some(Instant::now() - Duration::from_millis(500));
931        assert_eq!(
932            state.apply_ui_key(
933                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
934                ActiveOverlay::None,
935                false,
936            ),
937            InputAction::None
938        );
939        assert_eq!(state.current(), MovementInput::Right);
940    }
941
942    #[test]
943    fn chord_grace_keeps_diagonal_despite_spurious_release() {
944        let mut state = MovementState::default();
945        state.apply_ui_key(
946            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
947            ActiveOverlay::None,
948            false,
949        );
950        state.apply_ui_key(
951            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
952            ActiveOverlay::None,
953            false,
954        );
955        // Immediate fake Release of W (common when D is pressed).
956        assert_eq!(
957            state.apply_ui_key(
958                key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
959                ActiveOverlay::None,
960                false,
961            ),
962            InputAction::None
963        );
964        assert_eq!(state.current(), MovementInput::ForwardRight);
965        state.apply_ui_key(
966            key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
967            ActiveOverlay::None,
968            false,
969        );
970        assert_eq!(state.current(), MovementInput::ForwardRight);
971    }
972
973    #[test]
974    fn space_hard_stops() {
975        let mut state = MovementState::default();
976        state.apply_ui_key(
977            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
978            ActiveOverlay::None,
979            false,
980        );
981        state.apply_ui_key(
982            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
983            ActiveOverlay::None,
984            false,
985        );
986        assert_eq!(
987            state.apply_ui_key(
988                key(UiKeyCode::Char(' '), UiKeyEventKind::Press),
989                ActiveOverlay::None,
990                false
991            ),
992            InputAction::StopMovement
993        );
994        assert_eq!(state.current(), MovementInput::Stop);
995    }
996
997    #[test]
998    fn last_direction_remembered_after_release() {
999        let mut state = MovementState::default();
1000        state.apply_ui_key(
1001            key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1002            ActiveOverlay::None,
1003            false,
1004        );
1005        let _ = state.apply_ui_key(
1006            key(UiKeyCode::Char('s'), UiKeyEventKind::Release),
1007            ActiveOverlay::None,
1008            false,
1009        );
1010        assert_eq!(state.current(), MovementInput::Stop);
1011        let (f, _) = state.last_move_axes();
1012        assert!(f < 0.0);
1013    }
1014
1015    #[test]
1016    fn repeat_keeps_diagonal_pair() {
1017        let mut state = MovementState::default();
1018        state.apply_ui_key(
1019            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1020            ActiveOverlay::None,
1021            false,
1022        );
1023        state.apply_ui_key(
1024            key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1025            ActiveOverlay::None,
1026            false,
1027        );
1028        state.apply_ui_key(
1029            key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1030            ActiveOverlay::None,
1031            false,
1032        );
1033        assert_eq!(state.current(), MovementInput::ForwardRight);
1034    }
1035
1036    #[test]
1037    fn shift_held_sprints_without_modifier_on_repeat() {
1038        let mut state = MovementState::default();
1039        let shift_press = UiKeyEvent {
1040            code: UiKeyCode::ShiftLeft,
1041            modifiers: UiKeyModifiers::default(),
1042            kind: UiKeyEventKind::Press,
1043        };
1044        state.apply_ui_key(shift_press, ActiveOverlay::None, false);
1045        state.apply_ui_key(
1046            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1047            ActiveOverlay::None,
1048            false,
1049        );
1050        assert!(state.sprinting());
1051        state.apply_ui_key(
1052            key(UiKeyCode::Char('w'), UiKeyEventKind::Repeat),
1053            ActiveOverlay::None,
1054            false,
1055        );
1056        assert!(state.sprinting());
1057    }
1058
1059    #[test]
1060    fn diagonal_normalized() {
1061        let (f, s) = MovementInput::ForwardRight.components();
1062        let len = (f * f + s * s).sqrt();
1063        assert!((len - 1.0).abs() < 0.001);
1064    }
1065
1066    #[test]
1067    fn overlay_esc_closes() {
1068        let mut state = MovementState::default();
1069        assert_eq!(
1070            state.apply_ui_key(
1071                key(UiKeyCode::Esc, UiKeyEventKind::Press),
1072                ActiveOverlay::Loadout,
1073                false
1074            ),
1075            InputAction::CloseOverlay
1076        );
1077    }
1078
1079    #[test]
1080    fn stats_overlay_tab_cycles_sheet_not_combat_target() {
1081        let mut state = MovementState::default();
1082        assert_eq!(
1083            state.apply_ui_key(
1084                key(UiKeyCode::Tab, UiKeyEventKind::Press),
1085                ActiveOverlay::Stats,
1086                false
1087            ),
1088            InputAction::CycleCharacterSheetTab
1089        );
1090        assert_eq!(
1091            state.apply_ui_key(
1092                key(UiKeyCode::BackTab, UiKeyEventKind::Press),
1093                ActiveOverlay::Stats,
1094                false
1095            ),
1096            InputAction::CycleCharacterSheetTab
1097        );
1098        assert_eq!(
1099            state.apply_ui_key(
1100                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1101                ActiveOverlay::Stats,
1102                false
1103            ),
1104            InputAction::LedgerPeriodDigit('1')
1105        );
1106        assert_eq!(
1107            state.apply_ui_key(
1108                key(UiKeyCode::Char('i'), UiKeyEventKind::Press),
1109                ActiveOverlay::Stats,
1110                false
1111            ),
1112            InputAction::ToggleStats
1113        );
1114        // Combat Tab targeting must not win while the sheet is open.
1115        assert_ne!(
1116            state.apply_ui_key(
1117                key(UiKeyCode::Tab, UiKeyEventKind::Press),
1118                ActiveOverlay::Stats,
1119                false
1120            ),
1121            InputAction::CycleCombatTarget { reverse: false }
1122        );
1123    }
1124
1125    #[test]
1126    fn overlay_allows_toggle_keys() {
1127        let mut state = MovementState::default();
1128        assert_eq!(
1129            state.apply_ui_key(
1130                key(UiKeyCode::Char('l'), UiKeyEventKind::Press),
1131                ActiveOverlay::Loadout,
1132                false
1133            ),
1134            InputAction::ToggleLoadout
1135        );
1136        assert_eq!(
1137            state.apply_ui_key(
1138                key(UiKeyCode::Char('o'), UiKeyEventKind::Press),
1139                ActiveOverlay::RotationEditor(RotationEditorMode::List),
1140                false
1141            ),
1142            InputAction::ToggleRotationEditor
1143        );
1144    }
1145
1146    #[test]
1147    fn overlay_honors_key_release() {
1148        let mut state = MovementState::default();
1149        state.apply_ui_key(
1150            key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1151            ActiveOverlay::None,
1152            false,
1153        );
1154        assert_eq!(state.current(), MovementInput::Forward);
1155        state.apply_ui_key(
1156            key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1157            ActiveOverlay::Loadout,
1158            false,
1159        );
1160        assert_eq!(state.current(), MovementInput::Stop);
1161    }
1162
1163    #[test]
1164    fn overlay_assign_keys() {
1165        let mut state = MovementState::default();
1166        assert_eq!(
1167            state.apply_ui_key(
1168                key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1169                ActiveOverlay::Loadout,
1170                false
1171            ),
1172            InputAction::LoadoutAssignT1
1173        );
1174        assert_eq!(
1175            state.apply_ui_key(
1176                key(UiKeyCode::Char('2'), UiKeyEventKind::Press),
1177                ActiveOverlay::Loadout,
1178                false
1179            ),
1180            InputAction::LoadoutAssignT2
1181        );
1182    }
1183
1184    #[test]
1185    fn rotation_editor_slash_moves_ability_down() {
1186        let mut state = MovementState::default();
1187        assert_eq!(
1188            state.apply_ui_key(
1189                key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
1190                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1191                false
1192            ),
1193            InputAction::RotationEditorMoveAbilityDown
1194        );
1195    }
1196
1197    #[test]
1198    fn rotation_editor_bracket_keys_reorder() {
1199        let mut state = MovementState::default();
1200        assert_eq!(
1201            state.apply_ui_key(
1202                key(UiKeyCode::Char('['), UiKeyEventKind::Press),
1203                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1204                false
1205            ),
1206            InputAction::RotationEditorMoveAbilityUp
1207        );
1208        assert_eq!(
1209            state.apply_ui_key(
1210                key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1211                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1212                false
1213            ),
1214            InputAction::RotationEditorMoveAbilityDown
1215        );
1216    }
1217
1218    #[test]
1219    fn rotation_editor_s_saves() {
1220        let mut state = MovementState::default();
1221        assert_eq!(
1222            state.apply_ui_key(
1223                key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1224                ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1225                false
1226            ),
1227            InputAction::RotationEditorSave
1228        );
1229    }
1230}