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