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