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