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