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(320);
23const CHORD_RELEASE_GRACE: Duration = Duration::from_millis(220);
26const SPRINT_SHIFT_REFRESH: Duration = Duration::from_millis(700);
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
29enum DirectionKey {
30 Up,
31 Down,
32 Left,
33 Right,
34}
35
36#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
37enum VerticalKey {
38 Up,
39 Down,
40}
41
42fn direction_from_key(code: UiKeyCode) -> Option<DirectionKey> {
43 match code {
44 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
45 'w' => Some(DirectionKey::Up),
46 's' => Some(DirectionKey::Down),
47 'a' => Some(DirectionKey::Left),
48 'd' => Some(DirectionKey::Right),
49 _ => None,
50 },
51 UiKeyCode::Up => Some(DirectionKey::Up),
52 UiKeyCode::Down => Some(DirectionKey::Down),
53 UiKeyCode::Left => Some(DirectionKey::Left),
54 UiKeyCode::Right => Some(DirectionKey::Right),
55 _ => None,
56 }
57}
58
59fn direction_axes(dir: DirectionKey) -> (f32, f32) {
60 match dir {
61 DirectionKey::Up => (1.0, 0.0),
62 DirectionKey::Down => (-1.0, 0.0),
63 DirectionKey::Left => (0.0, -1.0),
64 DirectionKey::Right => (0.0, 1.0),
65 }
66}
67
68fn is_shift_key(code: UiKeyCode) -> bool {
69 matches!(code, UiKeyCode::ShiftLeft | UiKeyCode::ShiftRight)
70}
71
72#[derive(Debug, Default)]
73pub struct MapTargetState {
74 pub active: bool,
75 pub cursor_x: f32,
76 pub cursor_y: f32,
77}
78
79impl MapTargetState {
80 pub fn activate_at(&mut self, x: f32, y: f32) {
81 self.active = true;
82 self.cursor_x = x;
83 self.cursor_y = y;
84 }
85
86 pub fn deactivate(&mut self) {
87 self.active = false;
88 }
89
90 pub fn nudge(&mut self, dx: i32, dy: i32, max_x: f32, max_y: f32) {
91 self.cursor_x = (self.cursor_x + dx as f32).clamp(0.5, max_x - 0.5);
92 self.cursor_y = (self.cursor_y + dy as f32).clamp(0.5, max_y - 0.5);
93 }
94}
95
96#[derive(Debug)]
101pub struct MovementState {
102 held_dirs: HashMap<DirectionKey, Instant>,
103 vertical_held: HashMap<VerticalKey, Instant>,
104 shift_held: bool,
105 sprint_until: Instant,
106 sprint_toggle: bool,
107 last_forward: f32,
108 last_strafe: f32,
109 chord_formed_at: Option<Instant>,
111 pub keys: ClientKeyBindings,
112}
113
114impl Default for MovementState {
115 fn default() -> Self {
116 Self {
117 held_dirs: HashMap::new(),
118 vertical_held: HashMap::new(),
119 shift_held: false,
120 sprint_until: Instant::now(),
121 sprint_toggle: false,
122 last_forward: 0.0,
123 last_strafe: 0.0,
124 chord_formed_at: None,
125 keys: ClientKeyBindings::default(),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum MovementInput {
132 Stop,
133 Forward,
134 Back,
135 Left,
136 Right,
137 ForwardLeft,
138 ForwardRight,
139 BackLeft,
140 BackRight,
141}
142
143impl MovementInput {
144 pub fn components(self) -> (f32, f32) {
145 let (mut forward, mut strafe): (f32, f32) = match self {
146 Self::Stop => (0.0, 0.0),
147 Self::Forward => (1.0, 0.0),
148 Self::Back => (-1.0, 0.0),
149 Self::Left => (0.0, -1.0),
150 Self::Right => (0.0, 1.0),
151 Self::ForwardLeft => (1.0, -1.0),
152 Self::ForwardRight => (1.0, 1.0),
153 Self::BackLeft => (-1.0, -1.0),
154 Self::BackRight => (-1.0, 1.0),
155 };
156
157 let len = (forward * forward + strafe * strafe).sqrt();
158 if len > 1.0 {
159 forward /= len;
160 strafe /= len;
161 }
162 (forward, strafe)
163 }
164
165 pub fn label(self) -> &'static str {
166 match self {
167 Self::Stop => "stop",
168 Self::Forward => "up",
169 Self::Back => "down",
170 Self::Left => "left",
171 Self::Right => "right",
172 Self::ForwardLeft => "up-left",
173 Self::ForwardRight => "up-right",
174 Self::BackLeft => "down-left",
175 Self::BackRight => "down-right",
176 }
177 }
178}
179
180#[derive(Debug, Clone, Copy, PartialEq)]
181pub enum InputAction {
182 Quit,
183 Harvest,
184 Pickup,
185 Craft,
186 Interact,
187 TestDamage,
188 CycleCombatTarget {
189 reverse: bool,
190 },
191 CycleCombatTargetT2 {
192 reverse: bool,
193 },
194 AdvanceRotationT1,
195 AdvanceRotationT2,
196 ToggleAutoT1,
197 ToggleAutoT2,
198 ToggleLoadout,
199 ToggleRotationEditor,
200 LoadoutAssignT1,
201 LoadoutAssignT2,
202 LoadoutMenuUp,
203 LoadoutMenuDown,
204 RotationEditorListUp,
205 RotationEditorListDown,
206 RotationEditorEdit,
207 RotationEditorNew,
208 RotationEditorDelete,
209 RotationEditorBack,
210 RotationEditorAddAbility,
211 RotationEditorRemoveAbility,
212 RotationEditorMoveAbilityUp,
213 RotationEditorMoveAbilityDown,
214 RotationEditorAbilityUp,
215 RotationEditorAbilityDown,
216 RotationEditorPickerUp,
217 RotationEditorPickerDown,
218 RotationEditorPickAbility,
219 RotationEditorRename,
220 RotationEditorConfirmLabel,
221 RotationEditorLabelBackspace,
222 RotationEditorLabelChar(char),
223 RotationEditorSave,
224 CloseOverlay,
225 ClearCombatTarget,
226 ToggleStats,
227 ToggleEquip,
228 CycleCharacterSheetTab,
229 LedgerPeriodDigit(char),
230 ToggleInventory,
231 ToggleKeychain,
232 ToggleQuestMenu,
233 QuestMenuUp,
234 QuestMenuDown,
235 QuestWithdraw,
236 ToggleWorkersMenu,
237 ToggleHelp,
238 CycleHudView,
239 StartChat {
240 whisper: bool,
241 },
242 SubmitChat,
243 CancelChat,
244 Dodge,
245 Lunge,
246 DirectionalJump {
248 forward: f32,
249 strafe: f32,
250 },
251 ToggleBlock,
252 ToggleSprintMode,
253 ToggleMapTarget,
254 ConfirmMapTarget,
255 CancelMapTarget,
256 MapTargetNudge {
257 dx: i32,
258 dy: i32,
259 },
260 CancelAutoNav,
261 StopMovement,
263 UseWorld,
265 CastHotbar {
266 slot: u8,
267 },
268 ClearCombatTargetT2,
269 None,
270}
271
272fn rotation_editor_action(key: UiKeyEvent, mode: RotationEditorMode) -> Option<InputAction> {
273 let shift = key.modifiers.shift;
274 Some(match mode {
275 RotationEditorMode::List => match key.code {
276 UiKeyCode::Esc => InputAction::CloseOverlay,
277 UiKeyCode::Up => InputAction::RotationEditorListUp,
278 UiKeyCode::Down => InputAction::RotationEditorListDown,
279 UiKeyCode::Enter => InputAction::RotationEditorEdit,
280 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
281 'n' => InputAction::RotationEditorNew,
282 'd' => InputAction::RotationEditorDelete,
283 _ => return None,
284 },
285 _ => return None,
286 },
287 RotationEditorMode::EditSequence => match key.code {
288 UiKeyCode::Up if shift => InputAction::RotationEditorMoveAbilityUp,
289 UiKeyCode::Down if shift => InputAction::RotationEditorMoveAbilityDown,
290 UiKeyCode::Esc => InputAction::RotationEditorBack,
291 UiKeyCode::Char('[') | UiKeyCode::Char(';') => InputAction::RotationEditorMoveAbilityUp,
292 UiKeyCode::Char(']') | UiKeyCode::Char('/') | UiKeyCode::Char('\\') => {
293 InputAction::RotationEditorMoveAbilityDown
294 }
295 UiKeyCode::Up => InputAction::RotationEditorAbilityUp,
296 UiKeyCode::Down => InputAction::RotationEditorAbilityDown,
297 UiKeyCode::Delete => InputAction::RotationEditorRemoveAbility,
298 UiKeyCode::Enter => InputAction::RotationEditorSave,
299 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
300 'a' => InputAction::RotationEditorAddAbility,
301 'x' => InputAction::RotationEditorRemoveAbility,
302 'r' => InputAction::RotationEditorRename,
303 's' => InputAction::RotationEditorSave,
304 _ => return None,
305 },
306 _ => return None,
307 },
308 RotationEditorMode::PickAbility => match key.code {
309 UiKeyCode::Esc => InputAction::RotationEditorBack,
310 UiKeyCode::Up => InputAction::RotationEditorPickerUp,
311 UiKeyCode::Down => InputAction::RotationEditorPickerDown,
312 UiKeyCode::Enter => InputAction::RotationEditorPickAbility,
313 _ => return None,
314 },
315 RotationEditorMode::EditLabel => match key.code {
316 UiKeyCode::Esc => InputAction::RotationEditorBack,
317 UiKeyCode::Enter => InputAction::RotationEditorConfirmLabel,
318 UiKeyCode::Backspace => InputAction::RotationEditorLabelBackspace,
319 UiKeyCode::Char(c) if !key.modifiers.control => InputAction::RotationEditorLabelChar(c),
320 _ => return None,
321 },
322 })
323}
324
325impl MovementState {
326 pub fn with_keys(keys: ClientKeyBindings) -> Self {
327 Self {
328 keys,
329 ..Default::default()
330 }
331 }
332
333 pub fn idle_timeout(&self) -> Duration {
334 MOVEMENT_IDLE_TIMEOUT
335 }
336
337 fn touch_dir(&mut self, dir: DirectionKey) {
341 let now = Instant::now();
342 let idle = self.idle_timeout();
343 let joining_chord = !self.held_dirs.contains_key(&dir)
344 && self.held_dirs.values().any(|at| at.elapsed() < idle);
345 if joining_chord {
346 self.chord_formed_at = Some(now);
347 }
348 self.held_dirs.insert(dir, now);
349 for (other, at) in self.held_dirs.iter_mut() {
350 if *other != dir && at.elapsed() < idle {
351 *at = now;
352 }
353 }
354 }
355
356 fn release_dir(&mut self, dir: DirectionKey) {
357 self.held_dirs.remove(&dir);
358 }
359
360 fn refresh_sprint(&mut self, key: &UiKeyEvent) {
361 let shift = key.modifiers.shift
362 || matches!(
363 key.code,
364 UiKeyCode::Char(c)
365 if c.is_ascii_uppercase() && direction_from_key(key.code).is_some()
366 );
367 if shift {
368 self.sprint_until = Instant::now() + SPRINT_SHIFT_REFRESH;
369 }
370 }
371
372 fn dir_active(&self, dir: DirectionKey) -> bool {
373 let idle = self.idle_timeout();
374 self.held_dirs
375 .get(&dir)
376 .is_some_and(|at| at.elapsed() < idle)
377 }
378
379 fn vertical_active(&self, key: VerticalKey) -> bool {
380 let idle = self.idle_timeout();
381 self.vertical_held
382 .get(&key)
383 .is_some_and(|at| at.elapsed() < idle)
384 }
385
386 pub fn reset(&mut self) {
387 self.held_dirs.clear();
388 self.vertical_held.clear();
389 self.shift_held = false;
390 self.sprint_until = Instant::now();
391 self.chord_formed_at = None;
392 }
393
394 pub fn sprint_mode(&self) -> bool {
395 self.sprint_toggle
396 }
397
398 pub fn toggle_sprint_mode(&mut self) {
399 self.sprint_toggle = !self.sprint_toggle;
400 }
401
402 pub fn vertical_axis(&self) -> f32 {
403 let up = self.vertical_active(VerticalKey::Up);
404 let down = self.vertical_active(VerticalKey::Down);
405 match (up, down) {
406 (true, false) => 1.0,
407 (false, true) => -1.0,
408 _ => 0.0,
409 }
410 }
411
412 pub fn sprinting(&self) -> bool {
413 self.sprint_toggle || self.shift_held || Instant::now() < self.sprint_until
414 }
415
416 pub fn last_move_axes(&self) -> (f32, f32) {
417 (self.last_forward, self.last_strafe)
418 }
419
420 fn remember_movement(&mut self) {
421 let (forward, strafe) = self.current().components();
422 if forward.abs() > f32::EPSILON || strafe.abs() > f32::EPSILON {
423 self.last_forward = forward;
424 self.last_strafe = strafe;
425 }
426 }
427
428 pub fn current(&self) -> MovementInput {
429 let up = self.dir_active(DirectionKey::Up);
430 let down = self.dir_active(DirectionKey::Down);
431 let left = self.dir_active(DirectionKey::Left);
432 let right = self.dir_active(DirectionKey::Right);
433
434 let forward = match (up, down) {
436 (true, false) => 1,
437 (false, true) => -1,
438 _ => 0,
439 };
440 let strafe = match (left, right) {
441 (true, false) => -1,
442 (false, true) => 1,
443 _ => 0,
444 };
445 match (forward, strafe) {
446 (1, 0) => MovementInput::Forward,
447 (-1, 0) => MovementInput::Back,
448 (0, -1) => MovementInput::Left,
449 (0, 1) => MovementInput::Right,
450 (1, -1) => MovementInput::ForwardLeft,
451 (1, 1) => MovementInput::ForwardRight,
452 (-1, -1) => MovementInput::BackLeft,
453 (-1, 1) => MovementInput::BackRight,
454 _ => MovementInput::Stop,
455 }
456 }
457
458 pub fn apply_ui_key(
459 &mut self,
460 key: UiKeyEvent,
461 overlay: ActiveOverlay,
462 map_target_active: bool,
463 ) -> InputAction {
464 if key.kind == UiKeyEventKind::Press
465 && key.modifiers.control
466 && matches!(key.code, UiKeyCode::Char('q') | UiKeyCode::Char('c'))
467 {
468 return InputAction::Quit;
469 }
470
471 if overlay != ActiveOverlay::None {
472 if key.kind == UiKeyEventKind::Release {
474 if let Some(dir) = direction_from_key(key.code) {
475 self.release_dir(dir);
476 self.remember_movement();
477 }
478 match key.code {
479 UiKeyCode::Char('u') => {
480 self.vertical_held.remove(&VerticalKey::Up);
481 }
482 UiKeyCode::Char('j') => {
483 self.vertical_held.remove(&VerticalKey::Down);
484 }
485 _ => {}
486 }
487 return InputAction::None;
488 }
489 if key.kind == UiKeyEventKind::Press {
490 let action = match overlay {
491 ActiveOverlay::Loadout => match key.code {
492 UiKeyCode::Esc => Some(InputAction::CloseOverlay),
493 UiKeyCode::Up => Some(InputAction::LoadoutMenuUp),
494 UiKeyCode::Down => Some(InputAction::LoadoutMenuDown),
495 UiKeyCode::Char('1') => Some(InputAction::LoadoutAssignT1),
496 UiKeyCode::Char('2') => Some(InputAction::LoadoutAssignT2),
497 _ => None,
498 },
499 ActiveOverlay::RotationEditor(mode) => rotation_editor_action(key, mode),
500 ActiveOverlay::Stats => match key.code {
501 UiKeyCode::Esc => Some(InputAction::ToggleStats),
502 UiKeyCode::Tab | UiKeyCode::BackTab => {
503 Some(InputAction::CycleCharacterSheetTab)
504 }
505 UiKeyCode::Char('i') | UiKeyCode::Char('I') => {
506 Some(InputAction::ToggleStats)
507 }
508 UiKeyCode::Char(c) if matches!(c, '1' | '2' | '3' | '4') => {
509 Some(InputAction::LedgerPeriodDigit(c))
510 }
511 _ => None,
512 },
513 ActiveOverlay::None => None,
514 };
515 if let Some(action) = action {
516 return action;
517 }
518 if let Some(action) = combat_action_for_key(&key, &self.keys) {
519 return match action {
520 CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
521 CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
522 _ => InputAction::None,
523 };
524 }
525 }
526 return InputAction::None;
527 }
528
529 if map_target_active {
531 if matches!(key.kind, UiKeyEventKind::Press | UiKeyEventKind::Repeat) {
532 return match key.code {
533 UiKeyCode::Esc => InputAction::CancelMapTarget,
534 UiKeyCode::Enter => InputAction::ConfirmMapTarget,
535 UiKeyCode::Char('m') => InputAction::CancelMapTarget,
536 UiKeyCode::Char(' ') => InputAction::StopMovement,
537 UiKeyCode::Char('w') | UiKeyCode::Up => {
538 InputAction::MapTargetNudge { dx: 0, dy: 1 }
539 }
540 UiKeyCode::Char('s') | UiKeyCode::Down => {
541 InputAction::MapTargetNudge { dx: 0, dy: -1 }
542 }
543 UiKeyCode::Char('a') | UiKeyCode::Left => {
544 InputAction::MapTargetNudge { dx: -1, dy: 0 }
545 }
546 UiKeyCode::Char('d') | UiKeyCode::Right => {
547 InputAction::MapTargetNudge { dx: 1, dy: 0 }
548 }
549 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
550 'w' => InputAction::MapTargetNudge { dx: 0, dy: 1 },
551 's' => InputAction::MapTargetNudge { dx: 0, dy: -1 },
552 'a' => InputAction::MapTargetNudge { dx: -1, dy: 0 },
553 'd' => InputAction::MapTargetNudge { dx: 1, dy: 0 },
554 _ => InputAction::None,
555 },
556 _ => InputAction::None,
557 };
558 }
559 return InputAction::None;
560 }
561
562 if key.kind == UiKeyEventKind::Press {
563 if let Some(action) = combat_action_for_key(&key, &self.keys) {
565 return match action {
566 CombatKeyAction::CycleTargetT1 { reverse } => {
567 InputAction::CycleCombatTarget { reverse }
568 }
569 CombatKeyAction::CycleTargetT2 { reverse } => {
570 InputAction::CycleCombatTargetT2 { reverse }
571 }
572 CombatKeyAction::ToggleAutoT1 => InputAction::ToggleAutoT1,
573 CombatKeyAction::ToggleAutoT2 => InputAction::ToggleAutoT2,
574 CombatKeyAction::ToggleLoadout => InputAction::ToggleLoadout,
575 CombatKeyAction::ToggleRotationEditor => InputAction::ToggleRotationEditor,
576 CombatKeyAction::Dodge => InputAction::Dodge,
577 CombatKeyAction::Lunge => InputAction::Lunge,
578 CombatKeyAction::ToggleBlock => InputAction::ToggleBlock,
579 CombatKeyAction::ClearTargetT1 => InputAction::ClearCombatTarget,
580 CombatKeyAction::ClearTargetT2 => InputAction::ClearCombatTargetT2,
581 CombatKeyAction::Hotbar(slot) => InputAction::CastHotbar { slot },
582 };
583 }
584
585 match key.code {
586 UiKeyCode::Tab => return InputAction::CycleCharacterSheetTab,
587 UiKeyCode::Char(c) => match c.to_ascii_lowercase() {
588 'f' => return InputAction::UseWorld,
589 'n' if !key.modifiers.control => return InputAction::Craft,
590 ',' => return InputAction::ToggleKeychain,
591 'i' => return InputAction::ToggleStats,
592 'p' if !key.modifiers.control => return InputAction::ToggleEquip,
593 '1' | '2' | '3' | '4' => {
594 return InputAction::LedgerPeriodDigit(c.to_ascii_lowercase())
595 }
596 'b' if !key.modifiers.control => return InputAction::ToggleInventory,
597 'v' if !key.modifiers.control => return InputAction::ToggleQuestMenu,
598 'h' if !key.modifiers.control => return InputAction::ToggleWorkersMenu,
599 '?' | '/' => return InputAction::ToggleHelp,
600 '.' => return InputAction::CycleHudView,
601 '-' => return InputAction::TestDamage,
602 't' => return InputAction::StartChat { whisper: false },
603 'g' => return InputAction::StartChat { whisper: true },
604 'x' => return InputAction::ToggleSprintMode,
605 'm' => return InputAction::ToggleMapTarget,
606 ' ' => {
607 self.reset();
608 return InputAction::StopMovement;
609 }
610 _ => {}
611 },
612 UiKeyCode::Enter => return InputAction::SubmitChat,
613 _ => {}
614 }
615 }
616
617 if is_shift_key(key.code) {
618 match key.kind {
619 UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
620 self.shift_held = true;
621 self.sprint_until = Instant::now() + Duration::from_secs(30);
622 }
623 UiKeyEventKind::Release => {
624 self.shift_held = false;
625 if !self.sprint_toggle {
626 self.sprint_until = Instant::now();
627 }
628 }
629 }
630 return InputAction::None;
631 }
632
633 let vertical = match key.code {
634 UiKeyCode::Char('u') => Some(VerticalKey::Up),
635 UiKeyCode::Char('j') => Some(VerticalKey::Down),
636 _ => None,
637 };
638 if let Some(v) = vertical {
639 match key.kind {
640 UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
641 self.vertical_held.insert(v, Instant::now());
642 }
643 UiKeyEventKind::Release => {
644 self.vertical_held.remove(&v);
645 }
646 }
647 return InputAction::None;
648 }
649
650 let Some(dir) = direction_from_key(key.code) else {
651 return InputAction::None;
652 };
653
654 if key.modifiers.alt && key.kind == UiKeyEventKind::Press {
655 let (forward, strafe) = direction_axes(dir);
656 return InputAction::DirectionalJump { forward, strafe };
657 }
658
659 match key.kind {
660 UiKeyEventKind::Press | UiKeyEventKind::Repeat => {
661 self.touch_dir(dir);
662 self.refresh_sprint(&key);
663 self.remember_movement();
664 InputAction::None
665 }
666 UiKeyEventKind::Release => {
667 let was_held = self.held_dirs.contains_key(&dir);
668 let in_chord_grace = self
669 .chord_formed_at
670 .is_some_and(|t| t.elapsed() < CHORD_RELEASE_GRACE);
671 if was_held && !in_chord_grace {
674 self.release_dir(dir);
675 if self.held_dirs.len() < 2 {
676 self.chord_formed_at = None;
677 }
678 self.remember_movement();
679 if self.current() == MovementInput::Stop {
680 return InputAction::StopMovement;
681 }
682 }
683 InputAction::None
684 }
685 }
686 }
687
688 pub fn expire_idle(&mut self, idle: Duration) {
691 self.held_dirs.retain(|_, at| at.elapsed() < idle);
692 self.vertical_held.retain(|_, at| at.elapsed() < idle);
693 if self.held_dirs.len() < 2 {
694 self.chord_formed_at = None;
695 }
696 }
697}
698
699#[cfg(test)]
700mod tests {
701 use super::*;
702 use crate::input::{UiKeyCode, UiKeyEventKind, UiKeyModifiers};
703
704 fn key(code: UiKeyCode, kind: UiKeyEventKind) -> UiKeyEvent {
705 UiKeyEvent {
706 code,
707 modifiers: UiKeyModifiers::default(),
708 kind,
709 }
710 }
711
712 #[test]
713 fn combat_keys_on_left_hand() {
714 let mut state = MovementState::default();
715 assert_eq!(
716 state.apply_ui_key(
717 key(UiKeyCode::Char('c'), UiKeyEventKind::Press),
718 ActiveOverlay::None,
719 false
720 ),
721 InputAction::Dodge
722 );
723 assert_eq!(
724 state.apply_ui_key(
725 key(UiKeyCode::Char('q'), UiKeyEventKind::Press),
726 ActiveOverlay::None,
727 false
728 ),
729 InputAction::ToggleBlock
730 );
731 assert_eq!(
732 state.apply_ui_key(
733 UiKeyEvent {
734 code: UiKeyCode::Char(' '),
735 modifiers: UiKeyModifiers {
736 shift: true,
737 control: false,
738 alt: false
739 },
740 kind: UiKeyEventKind::Press
741 },
742 ActiveOverlay::None,
743 false
744 ),
745 InputAction::Lunge
746 );
747 assert_eq!(
749 state.apply_ui_key(
750 key(UiKeyCode::Char('e'), UiKeyEventKind::Press),
751 ActiveOverlay::None,
752 false
753 ),
754 InputAction::None
755 );
756 assert_eq!(state.current(), MovementInput::Stop);
757 }
758
759 #[test]
760 fn world_and_utility_row_bindings() {
761 let mut state = MovementState::default();
762 assert_eq!(
763 state.apply_ui_key(
764 key(UiKeyCode::Char('f'), UiKeyEventKind::Press),
765 ActiveOverlay::None,
766 false
767 ),
768 InputAction::UseWorld
769 );
770 assert_eq!(
771 state.apply_ui_key(
772 key(UiKeyCode::Char('n'), UiKeyEventKind::Press),
773 ActiveOverlay::None,
774 false
775 ),
776 InputAction::Craft
777 );
778 assert_eq!(
779 state.apply_ui_key(
780 key(UiKeyCode::Char(','), UiKeyEventKind::Press),
781 ActiveOverlay::None,
782 false
783 ),
784 InputAction::ToggleKeychain
785 );
786 assert_eq!(
787 state.apply_ui_key(
788 key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
789 ActiveOverlay::None,
790 false
791 ),
792 InputAction::ToggleHelp
793 );
794 assert_eq!(
795 state.apply_ui_key(
796 key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
797 ActiveOverlay::None,
798 false
799 ),
800 InputAction::CastHotbar { slot: 1 }
801 );
802 }
803
804 #[test]
805 fn map_target_repeat_nudges_cursor() {
806 let mut state = MovementState::default();
807 assert_eq!(
808 state.apply_ui_key(
809 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
810 ActiveOverlay::None,
811 true
812 ),
813 InputAction::MapTargetNudge { dx: 1, dy: 0 }
814 );
815 assert_eq!(
816 state.apply_ui_key(
817 key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
818 ActiveOverlay::None,
819 true
820 ),
821 InputAction::MapTargetNudge { dx: 1, dy: 0 }
822 );
823 assert_eq!(state.current(), MovementInput::Stop);
824 }
825
826 #[test]
827 fn release_stops_movement() {
828 let mut state = MovementState::default();
829 state.apply_ui_key(
830 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
831 ActiveOverlay::None,
832 false,
833 );
834 assert_eq!(state.current(), MovementInput::Forward);
835 assert_eq!(
836 state.apply_ui_key(
837 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
838 ActiveOverlay::None,
839 false,
840 ),
841 InputAction::StopMovement
842 );
843 assert_eq!(state.current(), MovementInput::Stop);
844 }
845
846 #[test]
847 fn idle_timeout_stops_movement() {
848 let mut state = MovementState::default();
849 state.apply_ui_key(
850 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
851 ActiveOverlay::None,
852 false,
853 );
854 assert_eq!(state.current(), MovementInput::Forward);
855 state.expire_idle(Duration::from_millis(0));
856 assert_eq!(state.current(), MovementInput::Stop);
857 }
858
859 #[test]
860 fn shift_uppercase_w_moves_forward() {
861 let mut state = MovementState::default();
862 let mut key = key(UiKeyCode::Char('W'), UiKeyEventKind::Press);
863 key.modifiers = UiKeyModifiers {
864 shift: true,
865 control: false,
866 alt: false,
867 };
868 state.apply_ui_key(key, ActiveOverlay::None, false);
869 assert_eq!(state.current(), MovementInput::Forward);
870 assert!(state.sprinting());
871 }
872
873 #[test]
874 fn diagonal_w_and_d() {
875 let mut state = MovementState::default();
876 state.apply_ui_key(
877 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
878 ActiveOverlay::None,
879 false,
880 );
881 state.apply_ui_key(
882 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
883 ActiveOverlay::None,
884 false,
885 );
886 assert_eq!(state.current(), MovementInput::ForwardRight);
887 let (f, s) = state.current().components();
888 assert!(f > 0.0 && s > 0.0);
889 }
890
891 #[test]
892 fn single_d_after_expired_chord_is_right_only() {
893 let mut state = MovementState::default();
895 state.apply_ui_key(
896 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
897 ActiveOverlay::None,
898 false,
899 );
900 state.apply_ui_key(
901 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
902 ActiveOverlay::None,
903 false,
904 );
905 assert_eq!(state.current(), MovementInput::ForwardRight);
906 state.expire_idle(Duration::from_millis(0));
907 assert_eq!(state.current(), MovementInput::Stop);
908 state.apply_ui_key(
909 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
910 ActiveOverlay::None,
911 false,
912 );
913 assert_eq!(state.current(), MovementInput::Right);
914 }
915
916 #[test]
917 fn release_of_one_key_keeps_other_axis() {
918 let mut state = MovementState::default();
919 state.apply_ui_key(
920 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
921 ActiveOverlay::None,
922 false,
923 );
924 state.apply_ui_key(
925 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
926 ActiveOverlay::None,
927 false,
928 );
929 state.chord_formed_at = Some(Instant::now() - Duration::from_millis(500));
931 assert_eq!(
932 state.apply_ui_key(
933 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
934 ActiveOverlay::None,
935 false,
936 ),
937 InputAction::None
938 );
939 assert_eq!(state.current(), MovementInput::Right);
940 }
941
942 #[test]
943 fn chord_grace_keeps_diagonal_despite_spurious_release() {
944 let mut state = MovementState::default();
945 state.apply_ui_key(
946 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
947 ActiveOverlay::None,
948 false,
949 );
950 state.apply_ui_key(
951 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
952 ActiveOverlay::None,
953 false,
954 );
955 assert_eq!(
957 state.apply_ui_key(
958 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
959 ActiveOverlay::None,
960 false,
961 ),
962 InputAction::None
963 );
964 assert_eq!(state.current(), MovementInput::ForwardRight);
965 state.apply_ui_key(
966 key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
967 ActiveOverlay::None,
968 false,
969 );
970 assert_eq!(state.current(), MovementInput::ForwardRight);
971 }
972
973 #[test]
974 fn space_hard_stops() {
975 let mut state = MovementState::default();
976 state.apply_ui_key(
977 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
978 ActiveOverlay::None,
979 false,
980 );
981 state.apply_ui_key(
982 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
983 ActiveOverlay::None,
984 false,
985 );
986 assert_eq!(
987 state.apply_ui_key(
988 key(UiKeyCode::Char(' '), UiKeyEventKind::Press),
989 ActiveOverlay::None,
990 false
991 ),
992 InputAction::StopMovement
993 );
994 assert_eq!(state.current(), MovementInput::Stop);
995 }
996
997 #[test]
998 fn last_direction_remembered_after_release() {
999 let mut state = MovementState::default();
1000 state.apply_ui_key(
1001 key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1002 ActiveOverlay::None,
1003 false,
1004 );
1005 let _ = state.apply_ui_key(
1006 key(UiKeyCode::Char('s'), UiKeyEventKind::Release),
1007 ActiveOverlay::None,
1008 false,
1009 );
1010 assert_eq!(state.current(), MovementInput::Stop);
1011 let (f, _) = state.last_move_axes();
1012 assert!(f < 0.0);
1013 }
1014
1015 #[test]
1016 fn repeat_keeps_diagonal_pair() {
1017 let mut state = MovementState::default();
1018 state.apply_ui_key(
1019 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1020 ActiveOverlay::None,
1021 false,
1022 );
1023 state.apply_ui_key(
1024 key(UiKeyCode::Char('d'), UiKeyEventKind::Press),
1025 ActiveOverlay::None,
1026 false,
1027 );
1028 state.apply_ui_key(
1029 key(UiKeyCode::Char('d'), UiKeyEventKind::Repeat),
1030 ActiveOverlay::None,
1031 false,
1032 );
1033 assert_eq!(state.current(), MovementInput::ForwardRight);
1034 }
1035
1036 #[test]
1037 fn shift_held_sprints_without_modifier_on_repeat() {
1038 let mut state = MovementState::default();
1039 let shift_press = UiKeyEvent {
1040 code: UiKeyCode::ShiftLeft,
1041 modifiers: UiKeyModifiers::default(),
1042 kind: UiKeyEventKind::Press,
1043 };
1044 state.apply_ui_key(shift_press, ActiveOverlay::None, false);
1045 state.apply_ui_key(
1046 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1047 ActiveOverlay::None,
1048 false,
1049 );
1050 assert!(state.sprinting());
1051 state.apply_ui_key(
1052 key(UiKeyCode::Char('w'), UiKeyEventKind::Repeat),
1053 ActiveOverlay::None,
1054 false,
1055 );
1056 assert!(state.sprinting());
1057 }
1058
1059 #[test]
1060 fn diagonal_normalized() {
1061 let (f, s) = MovementInput::ForwardRight.components();
1062 let len = (f * f + s * s).sqrt();
1063 assert!((len - 1.0).abs() < 0.001);
1064 }
1065
1066 #[test]
1067 fn overlay_esc_closes() {
1068 let mut state = MovementState::default();
1069 assert_eq!(
1070 state.apply_ui_key(
1071 key(UiKeyCode::Esc, UiKeyEventKind::Press),
1072 ActiveOverlay::Loadout,
1073 false
1074 ),
1075 InputAction::CloseOverlay
1076 );
1077 }
1078
1079 #[test]
1080 fn stats_overlay_tab_cycles_sheet_not_combat_target() {
1081 let mut state = MovementState::default();
1082 assert_eq!(
1083 state.apply_ui_key(
1084 key(UiKeyCode::Tab, UiKeyEventKind::Press),
1085 ActiveOverlay::Stats,
1086 false
1087 ),
1088 InputAction::CycleCharacterSheetTab
1089 );
1090 assert_eq!(
1091 state.apply_ui_key(
1092 key(UiKeyCode::BackTab, UiKeyEventKind::Press),
1093 ActiveOverlay::Stats,
1094 false
1095 ),
1096 InputAction::CycleCharacterSheetTab
1097 );
1098 assert_eq!(
1099 state.apply_ui_key(
1100 key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1101 ActiveOverlay::Stats,
1102 false
1103 ),
1104 InputAction::LedgerPeriodDigit('1')
1105 );
1106 assert_eq!(
1107 state.apply_ui_key(
1108 key(UiKeyCode::Char('i'), UiKeyEventKind::Press),
1109 ActiveOverlay::Stats,
1110 false
1111 ),
1112 InputAction::ToggleStats
1113 );
1114 assert_ne!(
1116 state.apply_ui_key(
1117 key(UiKeyCode::Tab, UiKeyEventKind::Press),
1118 ActiveOverlay::Stats,
1119 false
1120 ),
1121 InputAction::CycleCombatTarget { reverse: false }
1122 );
1123 }
1124
1125 #[test]
1126 fn overlay_allows_toggle_keys() {
1127 let mut state = MovementState::default();
1128 assert_eq!(
1129 state.apply_ui_key(
1130 key(UiKeyCode::Char('l'), UiKeyEventKind::Press),
1131 ActiveOverlay::Loadout,
1132 false
1133 ),
1134 InputAction::ToggleLoadout
1135 );
1136 assert_eq!(
1137 state.apply_ui_key(
1138 key(UiKeyCode::Char('o'), UiKeyEventKind::Press),
1139 ActiveOverlay::RotationEditor(RotationEditorMode::List),
1140 false
1141 ),
1142 InputAction::ToggleRotationEditor
1143 );
1144 }
1145
1146 #[test]
1147 fn overlay_honors_key_release() {
1148 let mut state = MovementState::default();
1149 state.apply_ui_key(
1150 key(UiKeyCode::Char('w'), UiKeyEventKind::Press),
1151 ActiveOverlay::None,
1152 false,
1153 );
1154 assert_eq!(state.current(), MovementInput::Forward);
1155 state.apply_ui_key(
1156 key(UiKeyCode::Char('w'), UiKeyEventKind::Release),
1157 ActiveOverlay::Loadout,
1158 false,
1159 );
1160 assert_eq!(state.current(), MovementInput::Stop);
1161 }
1162
1163 #[test]
1164 fn overlay_assign_keys() {
1165 let mut state = MovementState::default();
1166 assert_eq!(
1167 state.apply_ui_key(
1168 key(UiKeyCode::Char('1'), UiKeyEventKind::Press),
1169 ActiveOverlay::Loadout,
1170 false
1171 ),
1172 InputAction::LoadoutAssignT1
1173 );
1174 assert_eq!(
1175 state.apply_ui_key(
1176 key(UiKeyCode::Char('2'), UiKeyEventKind::Press),
1177 ActiveOverlay::Loadout,
1178 false
1179 ),
1180 InputAction::LoadoutAssignT2
1181 );
1182 }
1183
1184 #[test]
1185 fn rotation_editor_slash_moves_ability_down() {
1186 let mut state = MovementState::default();
1187 assert_eq!(
1188 state.apply_ui_key(
1189 key(UiKeyCode::Char('/'), UiKeyEventKind::Press),
1190 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1191 false
1192 ),
1193 InputAction::RotationEditorMoveAbilityDown
1194 );
1195 }
1196
1197 #[test]
1198 fn rotation_editor_bracket_keys_reorder() {
1199 let mut state = MovementState::default();
1200 assert_eq!(
1201 state.apply_ui_key(
1202 key(UiKeyCode::Char('['), UiKeyEventKind::Press),
1203 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1204 false
1205 ),
1206 InputAction::RotationEditorMoveAbilityUp
1207 );
1208 assert_eq!(
1209 state.apply_ui_key(
1210 key(UiKeyCode::Char(']'), UiKeyEventKind::Press),
1211 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1212 false
1213 ),
1214 InputAction::RotationEditorMoveAbilityDown
1215 );
1216 }
1217
1218 #[test]
1219 fn rotation_editor_s_saves() {
1220 let mut state = MovementState::default();
1221 assert_eq!(
1222 state.apply_ui_key(
1223 key(UiKeyCode::Char('s'), UiKeyEventKind::Press),
1224 ActiveOverlay::RotationEditor(RotationEditorMode::EditSequence),
1225 false
1226 ),
1227 InputAction::RotationEditorSave
1228 );
1229 }
1230}