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