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