1use ratatui::layout::Rect;
21
22use crate::game::state::GameState;
23use crate::sim::{Action, BuyQty};
24use crate::ui::{HelpAction, Mode};
25
26#[derive(Clone, Debug)]
30pub enum InputEvent {
31 KeyPress { code: KeyCode, mods: Modifiers },
34 MouseDown {
36 col: u16,
37 row: u16,
38 button: MouseButton,
39 mods: Modifiers,
40 },
41 MouseMoved { col: u16, row: u16 },
44 Wheel {
47 col: u16,
48 row: u16,
49 delta: WheelDelta,
50 },
51}
52
53#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum KeyCode {
57 Char(char),
58 Esc,
59 F(u8),
60}
61
62#[derive(Clone, Copy, Debug, PartialEq, Eq)]
67pub enum MouseButton {
68 Left,
69 Right,
70}
71
72#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
73pub struct Modifiers {
74 pub shift: bool,
75 pub alt: bool,
76 pub ctrl: bool,
77}
78
79#[derive(Clone, Copy, Debug, PartialEq, Eq)]
80pub enum WheelDelta {
81 Up,
82 Down,
83}
84
85pub struct UiState {
88 pub mode: Mode,
89 pub zoom_idx: usize,
90 pub running: bool,
91 pub last_mouse_pos: Option<(u16, u16)>,
92}
93
94impl UiState {
95 pub fn new() -> Self {
96 Self {
97 mode: Mode::Game,
98 zoom_idx: 0,
99 running: true,
100 last_mouse_pos: None,
101 }
102 }
103}
104
105impl Default for UiState {
106 fn default() -> Self {
107 Self::new()
108 }
109}
110
111pub struct InputContext<'a> {
115 pub fingerer_rows: &'a [(usize, Rect)],
116 pub upgrade_rows: &'a [(usize, Rect)],
117 pub help_hits: &'a [(HelpAction, Rect)],
118 pub biscuit_rect: Rect,
119 pub golden_rect: Rect,
120 pub play_area: Rect,
121 pub prestige_reset_rect: Rect,
122 pub debug: bool,
123 pub current: &'a GameState,
124}
125
126pub fn process_input_event(
132 ev: InputEvent,
133 ui: &mut UiState,
134 ctx: &InputContext,
135 out: &mut Vec<Action>,
136) {
137 match ev {
138 InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
139 InputEvent::MouseDown {
140 col,
141 row,
142 button,
143 mods,
144 } => {
145 ui.last_mouse_pos = Some((col, row));
146 if try_help_click(col, row, ui, ctx, out) {
151 return;
152 }
153 handle_click(col, row, button, mods, ui, ctx, out);
154 }
155 InputEvent::MouseMoved { col, row } => {
156 ui.last_mouse_pos = Some((col, row));
159 }
160 InputEvent::Wheel { col, row, delta } => {
161 if !in_play_area(col, row, ctx.play_area) {
166 return;
167 }
168 match delta {
169 WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
170 WheelDelta::Down => {
171 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
172 }
173 }
174 }
175 }
176}
177
178fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
183 if play_area.width == 0 || play_area.height == 0 {
184 return true;
185 }
186 col >= play_area.x
187 && col < play_area.x + play_area.width
188 && row >= play_area.y
189 && row < play_area.y + play_area.height
190}
191
192fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
193 rect.width > 0
194 && rect.height > 0
195 && col >= rect.x
196 && col < rect.x + rect.width
197 && row >= rect.y
198 && row < rect.y + rect.height
199}
200
201fn click_buy_qty(mods: Modifiers) -> BuyQty {
202 if mods.alt || mods.ctrl {
203 BuyQty::Max
204 } else if mods.shift {
205 BuyQty::Ten
206 } else {
207 BuyQty::One
208 }
209}
210
211fn try_help_click(
215 col: u16,
216 row: u16,
217 ui: &mut UiState,
218 ctx: &InputContext,
219 out: &mut Vec<Action>,
220) -> bool {
221 if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
225 out.push(Action::PrestigeReset);
226 ui.mode = Mode::Game;
227 return true;
228 }
229 for &(action, rect) in ctx.help_hits {
230 if !rect_contains(rect, col, row) {
231 continue;
232 }
233 match action {
234 HelpAction::OpenMode(target) => {
235 ui.mode = if ui.mode == target {
238 Mode::Game
239 } else {
240 target
241 };
242 }
243 HelpAction::GrabGolden => {
244 if ctx.current.golden.is_some() {
245 out.push(Action::CatchGolden);
246 }
247 }
248 HelpAction::PrestigeReset => {
249 if ctx.current.prestige_available() > 0 {
250 out.push(Action::PrestigeReset);
251 ui.mode = Mode::Game;
252 }
253 }
254 HelpAction::Quit => {
255 ui.running = false;
256 }
257 }
258 return true;
259 }
260 false
261}
262
263fn handle_click(
264 col: u16,
265 row: u16,
266 button: MouseButton,
267 mods: Modifiers,
268 ui: &UiState,
269 ctx: &InputContext,
270 out: &mut Vec<Action>,
271) {
272 if rect_contains(ctx.golden_rect, col, row) {
277 out.push(Action::CatchGolden);
278 return;
279 }
280 if rect_contains(ctx.biscuit_rect, col, row) {
284 if button == MouseButton::Left {
285 out.push(Action::Click { col, row });
286 }
287 return;
288 }
289 if ui.mode == Mode::Game {
294 for &(idx, r) in ctx.fingerer_rows {
295 if rect_contains(r, col, row) {
296 let qty = if button == MouseButton::Right {
297 BuyQty::Max
298 } else {
299 click_buy_qty(mods)
300 };
301 out.push(Action::BuyFingerer { idx, qty });
302 return;
303 }
304 }
305 }
306 if ui.mode == Mode::Upgrades {
309 for &(idx, r) in ctx.upgrade_rows {
310 if rect_contains(r, col, row) {
311 out.push(Action::BuyUpgrade(idx));
312 return;
313 }
314 }
315 }
316 if button != MouseButton::Left {
328 return;
329 }
330 if !rect_contains(ctx.play_area, col, row) {
331 return;
332 }
333 if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.current) {
334 return;
335 }
336 out.push(Action::Misclick { col, row });
337}
338
339fn handle_key(
340 code: KeyCode,
341 mods: Modifiers,
342 ui: &mut UiState,
343 ctx: &InputContext,
344 out: &mut Vec<Action>,
345) {
346 match code {
347 KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
352 KeyCode::Esc => match ui.mode {
357 Mode::Game => {}
358 _ => ui.mode = Mode::Game,
359 },
360 KeyCode::Char('s') | KeyCode::Char('S') => {
361 ui.mode = if matches!(ui.mode, Mode::Stats) {
362 Mode::Game
363 } else {
364 Mode::Stats
365 };
366 }
367 KeyCode::Char('a') | KeyCode::Char('A') => {
368 ui.mode = if matches!(ui.mode, Mode::Achievements) {
369 Mode::Game
370 } else {
371 Mode::Achievements
372 };
373 }
374 KeyCode::Char('u') | KeyCode::Char('U') => {
375 ui.mode = if matches!(ui.mode, Mode::Upgrades) {
376 Mode::Game
377 } else {
378 Mode::Upgrades
379 };
380 }
381 KeyCode::Char('g') | KeyCode::Char('G') if ctx.current.golden.is_some() => {
384 out.push(Action::CatchGolden);
385 }
386 KeyCode::F(8) if ctx.debug => {
392 out.push(Action::DevForceGolden(
393 crate::game::golden::GoldenVariant::Lucky,
394 ));
395 }
396 KeyCode::F(2) if ctx.debug => {
397 out.push(Action::DevForceGolden(
398 crate::game::golden::GoldenVariant::Frenzy,
399 ));
400 }
401 KeyCode::F(3) if ctx.debug => {
402 out.push(Action::DevForceGolden(
403 crate::game::golden::GoldenVariant::Buff,
404 ));
405 }
406 KeyCode::F(4) if ctx.debug => {
407 out.push(Action::DevAddCuques(1_000_000.0));
408 }
409 KeyCode::Char('p') | KeyCode::Char('P') => {
410 ui.mode = if matches!(ui.mode, Mode::Prestige) {
411 Mode::Game
412 } else {
413 Mode::Prestige
414 };
415 }
416 KeyCode::Char('r') | KeyCode::Char('R')
421 if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
422 {
423 out.push(Action::PrestigeReset);
424 ui.mode = Mode::Game;
425 }
426 KeyCode::Char('+') | KeyCode::Char('=') => {
427 ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
428 }
429 KeyCode::Char('-') | KeyCode::Char('_') => {
430 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
431 }
432 KeyCode::Char(' ') => {
435 out.push(Action::ClickCenter);
436 }
437 KeyCode::Char(c) => {
438 if let Some((slot, shifted_sym)) = digit_slot(c) {
439 let buy_10 = shifted_sym || mods.shift;
440 let buy_max = mods.alt || mods.ctrl;
441 match ui.mode {
442 Mode::Game => {
443 if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
444 let qty = if buy_max {
445 BuyQty::Max
446 } else if buy_10 {
447 BuyQty::Ten
448 } else {
449 BuyQty::One
450 };
451 out.push(Action::BuyFingerer { idx: fid, qty });
452 }
453 }
454 Mode::Upgrades => {
455 if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
456 out.push(Action::BuyUpgrade(u_idx));
457 }
458 }
459 _ => {}
460 }
461 }
462 }
463 _ => {}
464 }
465}
466
467fn digit_slot(c: char) -> Option<(usize, bool)> {
468 match c {
469 '1' => Some((0, false)),
470 '2' => Some((1, false)),
471 '3' => Some((2, false)),
472 '4' => Some((3, false)),
473 '5' => Some((4, false)),
474 '6' => Some((5, false)),
475 '7' => Some((6, false)),
476 '8' => Some((7, false)),
477 '9' => Some((8, false)),
478 '0' => Some((9, false)),
479 '!' => Some((0, true)),
480 '@' => Some((1, true)),
481 '#' => Some((2, true)),
482 '$' => Some((3, true)),
483 '%' => Some((4, true)),
484 '^' => Some((5, true)),
485 '&' => Some((6, true)),
486 '*' => Some((7, true)),
487 '(' => Some((8, true)),
488 ')' => Some((9, true)),
489 _ => None,
490 }
491}
492
493#[cfg(test)]
494mod tests {
495 use super::*;
505 use crate::game::golden::{self, GoldenVariant};
506 use crate::sim::{Action, BuyQty};
507 use ratatui::layout::Rect;
508 use std::mem::discriminant;
509
510 fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
511 Rect::new(x, y, w, h)
512 }
513
514 #[allow(clippy::too_many_arguments)]
518 fn ctx<'a>(
519 biscuit: Rect,
520 golden_rect: Rect,
521 play_area: Rect,
522 prestige_reset_rect: Rect,
523 fingerer_rows: &'a [(usize, Rect)],
524 upgrade_rows: &'a [(usize, Rect)],
525 help_hits: &'a [(HelpAction, Rect)],
526 debug: bool,
527 current: &'a GameState,
528 ) -> InputContext<'a> {
529 InputContext {
530 fingerer_rows,
531 upgrade_rows,
532 help_hits,
533 biscuit_rect: biscuit,
534 golden_rect,
535 play_area,
536 prestige_reset_rect,
537 debug,
538 current,
539 }
540 }
541
542 fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
543 ctx(
544 Rect::default(),
545 Rect::default(),
546 Rect::default(),
547 Rect::default(),
548 &[],
549 &[],
550 &[],
551 false,
552 state,
553 )
554 }
555
556 fn state_with_golden() -> GameState {
559 let mut g = golden::spawn_in(rect(10, 10, 20, 10));
560 g.variant = GoldenVariant::Lucky;
561 GameState {
562 golden: Some(g),
563 ..GameState::default()
564 }
565 }
566
567 fn state_with_prestige() -> GameState {
570 GameState {
573 lifetime_cuques: 4_000_000_000.0,
574 ..GameState::default()
575 }
576 }
577
578 fn key(code: KeyCode) -> InputEvent {
579 InputEvent::KeyPress {
580 code,
581 mods: Modifiers::default(),
582 }
583 }
584
585 fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
586 InputEvent::KeyPress {
587 code,
588 mods: Modifiers { shift, alt, ctrl },
589 }
590 }
591
592 fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
593 InputEvent::MouseDown {
594 col,
595 row,
596 button,
597 mods,
598 }
599 }
600
601 #[test]
604 fn q_key_flips_running_off() {
605 let s = GameState::default();
606 let mut ui = UiState::new();
607 let mut out = Vec::new();
608 process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
609 assert!(!ui.running);
610 assert!(out.is_empty());
611 }
612
613 #[test]
614 fn esc_from_game_is_noop() {
615 let s = GameState::default();
617 let mut ui = UiState::new();
618 let mut out = Vec::new();
619 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
620 assert!(ui.running);
621 assert_eq!(ui.mode, Mode::Game);
622 assert!(out.is_empty());
623 }
624
625 #[test]
626 fn esc_from_stats_returns_to_game() {
627 let s = GameState::default();
628 let mut ui = UiState::new();
629 ui.mode = Mode::Stats;
630 let mut out = Vec::new();
631 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
632 assert_eq!(ui.mode, Mode::Game);
633 assert!(out.is_empty());
634 }
635
636 #[test]
637 fn s_key_toggles_stats() {
638 let s = GameState::default();
639 let mut ui = UiState::new();
640 let mut out = Vec::new();
641 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
642 assert_eq!(ui.mode, Mode::Stats);
643 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
644 assert_eq!(ui.mode, Mode::Game);
645 }
646
647 #[test]
648 fn space_emits_click_center() {
649 let s = GameState::default();
650 let mut ui = UiState::new();
651 let mut out = Vec::new();
652 process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
653 assert_eq!(out.len(), 1);
654 assert!(matches!(out[0], Action::ClickCenter));
655 }
656
657 #[test]
658 fn g_with_no_golden_is_silent() {
659 let s = GameState::default();
660 let mut ui = UiState::new();
661 let mut out = Vec::new();
662 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
663 assert!(out.is_empty());
664 }
665
666 #[test]
667 fn g_with_golden_emits_catch() {
668 let s = state_with_golden();
669 let mut ui = UiState::new();
670 let mut out = Vec::new();
671 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
672 assert!(matches!(out.as_slice(), [Action::CatchGolden]));
673 }
674
675 #[test]
676 fn fkeys_gated_by_debug() {
677 let s = GameState::default();
678 let mut ui = UiState::new();
679 let mut out = Vec::new();
681 let c = empty_ctx(&s);
682 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
683 process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
684 process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
685 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
686 assert!(out.is_empty(), "F-keys must be silent when debug=false");
687 }
688
689 #[test]
690 fn fkeys_active_when_debug() {
691 let s = GameState::default();
692 let mut ui = UiState::new();
693 let c = ctx(
694 Rect::default(),
695 Rect::default(),
696 Rect::default(),
697 Rect::default(),
698 &[],
699 &[],
700 &[],
701 true, &s,
703 );
704 let mut out = Vec::new();
705 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
706 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
707 assert!(matches!(
708 out[0],
709 Action::DevForceGolden(GoldenVariant::Lucky)
710 ));
711 assert!(matches!(out[1], Action::DevAddCuques(_)));
712 }
713
714 fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
717 ctx(
718 Rect::default(),
719 Rect::default(),
720 Rect::default(),
721 Rect::default(),
722 rows,
723 &[],
724 &[],
725 false,
726 state,
727 )
728 }
729
730 #[test]
731 fn digit_1_buys_one() {
732 let s = GameState::default();
733 let mut ui = UiState::new();
734 let rows = [(0_usize, rect(0, 0, 1, 1))];
735 let mut out = Vec::new();
736 process_input_event(
737 key(KeyCode::Char('1')),
738 &mut ui,
739 &fingerer_row_ctx(&s, &rows),
740 &mut out,
741 );
742 assert!(matches!(
743 out.as_slice(),
744 [Action::BuyFingerer {
745 idx: 0,
746 qty: BuyQty::One,
747 }]
748 ));
749 }
750
751 #[test]
752 fn shifted_digit_symbol_buys_ten() {
753 let s = GameState::default();
756 let mut ui = UiState::new();
757 let rows = [(0_usize, rect(0, 0, 1, 1))];
758 let mut out = Vec::new();
759 process_input_event(
760 key(KeyCode::Char('!')),
761 &mut ui,
762 &fingerer_row_ctx(&s, &rows),
763 &mut out,
764 );
765 assert!(matches!(
766 out.as_slice(),
767 [Action::BuyFingerer {
768 idx: 0,
769 qty: BuyQty::Ten,
770 }]
771 ));
772 }
773
774 #[test]
775 fn shift_modifier_on_digit_buys_ten() {
776 let s = GameState::default();
778 let mut ui = UiState::new();
779 let rows = [(0_usize, rect(0, 0, 1, 1))];
780 let mut out = Vec::new();
781 process_input_event(
782 key_with(KeyCode::Char('1'), true, false, false),
783 &mut ui,
784 &fingerer_row_ctx(&s, &rows),
785 &mut out,
786 );
787 assert!(matches!(
788 out.as_slice(),
789 [Action::BuyFingerer {
790 qty: BuyQty::Ten,
791 ..
792 }]
793 ));
794 }
795
796 #[test]
797 fn alt_or_ctrl_modifier_on_digit_buys_max() {
798 let s = GameState::default();
799 let rows = [(0_usize, rect(0, 0, 1, 1))];
800 for (alt, ctrl) in [(true, false), (false, true)] {
801 let mut ui = UiState::new();
802 let mut out = Vec::new();
803 process_input_event(
804 key_with(KeyCode::Char('1'), false, alt, ctrl),
805 &mut ui,
806 &fingerer_row_ctx(&s, &rows),
807 &mut out,
808 );
809 assert!(
810 matches!(
811 out.as_slice(),
812 [Action::BuyFingerer {
813 qty: BuyQty::Max,
814 ..
815 }]
816 ),
817 "alt={alt} ctrl={ctrl} should buy max",
818 );
819 }
820 }
821
822 #[test]
823 fn digit_with_no_visible_row_is_silent() {
824 let s = GameState::default();
827 let mut ui = UiState::new();
828 let mut out = Vec::new();
829 process_input_event(
830 key(KeyCode::Char('1')),
831 &mut ui,
832 &fingerer_row_ctx(&s, &[]),
833 &mut out,
834 );
835 assert!(out.is_empty());
836 }
837
838 #[test]
841 fn left_click_on_biscuit_emits_click() {
842 let s = GameState::default();
843 let mut ui = UiState::new();
844 let c = ctx(
845 rect(10, 5, 30, 20),
846 Rect::default(),
847 rect(0, 0, 100, 30),
848 Rect::default(),
849 &[],
850 &[],
851 &[],
852 false,
853 &s,
854 );
855 let mut out = Vec::new();
856 process_input_event(
857 mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
858 &mut ui,
859 &c,
860 &mut out,
861 );
862 assert!(
863 matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
864 "got {:?}",
865 out
866 );
867 }
868
869 #[test]
870 fn right_click_on_biscuit_is_noop() {
871 let s = GameState::default();
874 let mut ui = UiState::new();
875 let c = ctx(
876 rect(10, 5, 30, 20),
877 Rect::default(),
878 rect(0, 0, 100, 30),
879 Rect::default(),
880 &[],
881 &[],
882 &[],
883 false,
884 &s,
885 );
886 let mut out = Vec::new();
887 process_input_event(
888 mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
889 &mut ui,
890 &c,
891 &mut out,
892 );
893 assert!(out.is_empty(), "got {:?}", out);
894 }
895
896 #[test]
897 fn left_click_on_golden_emits_catch() {
898 let s = state_with_golden();
899 let mut ui = UiState::new();
900 let c = ctx(
901 Rect::default(),
902 rect(50, 12, 4, 2),
903 rect(0, 0, 100, 30),
904 Rect::default(),
905 &[],
906 &[],
907 &[],
908 false,
909 &s,
910 );
911 let mut out = Vec::new();
912 process_input_event(
913 mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
914 &mut ui,
915 &c,
916 &mut out,
917 );
918 assert!(matches!(out.as_slice(), [Action::CatchGolden]));
919 }
920
921 #[test]
922 fn right_click_on_golden_also_catches() {
923 let s = state_with_golden();
925 let mut ui = UiState::new();
926 let c = ctx(
927 Rect::default(),
928 rect(50, 12, 4, 2),
929 rect(0, 0, 100, 30),
930 Rect::default(),
931 &[],
932 &[],
933 &[],
934 false,
935 &s,
936 );
937 let mut out = Vec::new();
938 process_input_event(
939 mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
940 &mut ui,
941 &c,
942 &mut out,
943 );
944 assert!(matches!(out.as_slice(), [Action::CatchGolden]));
945 }
946
947 #[test]
948 fn left_click_on_fingerer_row_buys_one() {
949 let s = GameState::default();
950 let mut ui = UiState::new();
951 let rows = [(2_usize, rect(100, 5, 38, 3))];
952 let c = ctx(
953 Rect::default(),
954 Rect::default(),
955 Rect::default(),
956 Rect::default(),
957 &rows,
958 &[],
959 &[],
960 false,
961 &s,
962 );
963 let mut out = Vec::new();
964 process_input_event(
965 mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
966 &mut ui,
967 &c,
968 &mut out,
969 );
970 assert!(matches!(
971 out.as_slice(),
972 [Action::BuyFingerer {
973 idx: 2,
974 qty: BuyQty::One,
975 }]
976 ));
977 }
978
979 #[test]
980 fn right_click_on_fingerer_row_buys_max() {
981 let s = GameState::default();
983 let mut ui = UiState::new();
984 let rows = [(2_usize, rect(100, 5, 38, 3))];
985 let c = ctx(
986 Rect::default(),
987 Rect::default(),
988 Rect::default(),
989 Rect::default(),
990 &rows,
991 &[],
992 &[],
993 false,
994 &s,
995 );
996 let mut out = Vec::new();
997 process_input_event(
998 mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
999 &mut ui,
1000 &c,
1001 &mut out,
1002 );
1003 assert!(matches!(
1004 out.as_slice(),
1005 [Action::BuyFingerer {
1006 qty: BuyQty::Max,
1007 ..
1008 }]
1009 ));
1010 }
1011
1012 #[test]
1013 fn shift_left_click_on_fingerer_row_buys_ten() {
1014 let s = GameState::default();
1015 let mut ui = UiState::new();
1016 let rows = [(2_usize, rect(100, 5, 38, 3))];
1017 let c = ctx(
1018 Rect::default(),
1019 Rect::default(),
1020 Rect::default(),
1021 Rect::default(),
1022 &rows,
1023 &[],
1024 &[],
1025 false,
1026 &s,
1027 );
1028 let mut out = Vec::new();
1029 let mods = Modifiers {
1030 shift: true,
1031 ..Modifiers::default()
1032 };
1033 process_input_event(
1034 mouse_down(110, 6, MouseButton::Left, mods),
1035 &mut ui,
1036 &c,
1037 &mut out,
1038 );
1039 assert!(matches!(
1040 out.as_slice(),
1041 [Action::BuyFingerer {
1042 qty: BuyQty::Ten,
1043 ..
1044 }]
1045 ));
1046 }
1047
1048 #[test]
1051 fn dead_zone_left_click_inside_play_area_emits_misclick() {
1052 let s = GameState::default();
1054 let mut ui = UiState::new();
1055 let c = ctx(
1056 rect(40, 10, 20, 10), Rect::default(),
1058 rect(0, 0, 100, 30), Rect::default(),
1060 &[],
1061 &[],
1062 &[],
1063 false,
1064 &s,
1065 );
1066 let mut out = Vec::new();
1067 process_input_event(
1068 mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1069 &mut ui,
1070 &c,
1071 &mut out,
1072 );
1073 assert!(
1074 matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1075 "got {:?}",
1076 out
1077 );
1078 }
1079
1080 #[test]
1081 fn click_outside_play_area_does_not_misclick() {
1082 let s = GameState::default();
1085 let mut ui = UiState::new();
1086 let c = ctx(
1087 rect(40, 10, 20, 10),
1088 Rect::default(),
1089 rect(0, 0, 100, 30), Rect::default(),
1091 &[],
1092 &[],
1093 &[],
1094 false,
1095 &s,
1096 );
1097 let mut out = Vec::new();
1098 process_input_event(
1099 mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1100 &mut ui,
1101 &c,
1102 &mut out,
1103 );
1104 assert!(out.is_empty(), "got {:?}", out);
1105 }
1106
1107 #[test]
1108 fn right_click_in_dead_zone_is_silent() {
1109 let s = GameState::default();
1111 let mut ui = UiState::new();
1112 let c = ctx(
1113 rect(40, 10, 20, 10),
1114 Rect::default(),
1115 rect(0, 0, 100, 30),
1116 Rect::default(),
1117 &[],
1118 &[],
1119 &[],
1120 false,
1121 &s,
1122 );
1123 let mut out = Vec::new();
1124 process_input_event(
1125 mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1126 &mut ui,
1127 &c,
1128 &mut out,
1129 );
1130 assert!(out.is_empty());
1131 }
1132
1133 #[test]
1136 fn click_quit_hint_flips_running() {
1137 let s = GameState::default();
1138 let mut ui = UiState::new();
1139 let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1140 let c = ctx(
1141 Rect::default(),
1142 Rect::default(),
1143 rect(0, 0, 100, 30),
1144 Rect::default(),
1145 &[],
1146 &[],
1147 &hits,
1148 false,
1149 &s,
1150 );
1151 let mut out = Vec::new();
1152 process_input_event(
1153 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1154 &mut ui,
1155 &c,
1156 &mut out,
1157 );
1158 assert!(!ui.running);
1159 assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1160 }
1161
1162 #[test]
1163 fn click_open_mode_hint_toggles_mode() {
1164 let s = GameState::default();
1165 let mut ui = UiState::new();
1166 let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1167 let c = ctx(
1168 Rect::default(),
1169 Rect::default(),
1170 rect(0, 0, 100, 30),
1171 Rect::default(),
1172 &[],
1173 &[],
1174 &hits,
1175 false,
1176 &s,
1177 );
1178 let mut out = Vec::new();
1179 process_input_event(
1180 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1181 &mut ui,
1182 &c,
1183 &mut out,
1184 );
1185 assert_eq!(ui.mode, Mode::Stats);
1186 }
1187
1188 #[test]
1191 fn prestige_reset_rect_unavailable_does_not_reset() {
1192 let s = GameState::default(); let mut ui = UiState::new();
1198 let c = ctx(
1199 Rect::default(),
1200 Rect::default(),
1201 rect(0, 0, 100, 30),
1202 rect(40, 15, 30, 1), &[],
1204 &[],
1205 &[],
1206 false,
1207 &s,
1208 );
1209 let mut out = Vec::new();
1210 process_input_event(
1211 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1212 &mut ui,
1213 &c,
1214 &mut out,
1215 );
1216 assert!(
1217 !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1218 "no PrestigeReset when unavailable; got {:?}",
1219 out
1220 );
1221 assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1222 }
1223
1224 #[test]
1225 fn prestige_reset_rect_available_emits_action() {
1226 let s = state_with_prestige();
1227 let mut ui = UiState::new();
1228 ui.mode = Mode::Prestige;
1229 let c = ctx(
1230 Rect::default(),
1231 Rect::default(),
1232 rect(0, 0, 100, 30),
1233 rect(40, 15, 30, 1),
1234 &[],
1235 &[],
1236 &[],
1237 false,
1238 &s,
1239 );
1240 let mut out = Vec::new();
1241 process_input_event(
1242 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1243 &mut ui,
1244 &c,
1245 &mut out,
1246 );
1247 assert_eq!(out.len(), 1);
1248 assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1249 assert_eq!(
1250 ui.mode,
1251 Mode::Game,
1252 "panel auto-closes after prestige confirm",
1253 );
1254 }
1255
1256 #[test]
1259 fn wheel_down_inside_play_area_increases_zoom_idx() {
1260 let s = GameState::default();
1261 let mut ui = UiState::new();
1262 let c = ctx(
1263 Rect::default(),
1264 Rect::default(),
1265 rect(0, 0, 100, 30),
1266 Rect::default(),
1267 &[],
1268 &[],
1269 &[],
1270 false,
1271 &s,
1272 );
1273 let mut out = Vec::new();
1274 process_input_event(
1275 InputEvent::Wheel {
1276 col: 50,
1277 row: 15,
1278 delta: WheelDelta::Down,
1279 },
1280 &mut ui,
1281 &c,
1282 &mut out,
1283 );
1284 assert_eq!(ui.zoom_idx, 1);
1285 }
1286
1287 #[test]
1288 fn wheel_outside_play_area_does_not_zoom() {
1289 let s = GameState::default();
1291 let mut ui = UiState::new();
1292 let c = ctx(
1293 Rect::default(),
1294 Rect::default(),
1295 rect(0, 0, 100, 30),
1296 Rect::default(),
1297 &[],
1298 &[],
1299 &[],
1300 false,
1301 &s,
1302 );
1303 let mut out = Vec::new();
1304 process_input_event(
1305 InputEvent::Wheel {
1306 col: 120,
1307 row: 10,
1308 delta: WheelDelta::Down,
1309 },
1310 &mut ui,
1311 &c,
1312 &mut out,
1313 );
1314 assert_eq!(ui.zoom_idx, 0);
1315 }
1316
1317 #[test]
1318 fn wheel_up_saturates_at_zero() {
1319 let s = GameState::default();
1320 let mut ui = UiState::new();
1321 ui.zoom_idx = 0;
1322 let c = ctx(
1323 Rect::default(),
1324 Rect::default(),
1325 rect(0, 0, 100, 30),
1326 Rect::default(),
1327 &[],
1328 &[],
1329 &[],
1330 false,
1331 &s,
1332 );
1333 let mut out = Vec::new();
1334 process_input_event(
1335 InputEvent::Wheel {
1336 col: 50,
1337 row: 15,
1338 delta: WheelDelta::Up,
1339 },
1340 &mut ui,
1341 &c,
1342 &mut out,
1343 );
1344 assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1345 }
1346
1347 #[test]
1348 fn wheel_down_caps_at_last_level() {
1349 let s = GameState::default();
1350 let mut ui = UiState::new();
1351 let last = crate::ui::biscuit::level_count() - 1;
1352 ui.zoom_idx = last;
1353 let c = ctx(
1354 Rect::default(),
1355 Rect::default(),
1356 rect(0, 0, 100, 30),
1357 Rect::default(),
1358 &[],
1359 &[],
1360 &[],
1361 false,
1362 &s,
1363 );
1364 let mut out = Vec::new();
1365 process_input_event(
1366 InputEvent::Wheel {
1367 col: 50,
1368 row: 15,
1369 delta: WheelDelta::Down,
1370 },
1371 &mut ui,
1372 &c,
1373 &mut out,
1374 );
1375 assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1376 }
1377
1378 #[test]
1381 fn mouse_moved_updates_last_position() {
1382 let s = GameState::default();
1383 let mut ui = UiState::new();
1384 let mut out = Vec::new();
1385 process_input_event(
1386 InputEvent::MouseMoved { col: 42, row: 7 },
1387 &mut ui,
1388 &empty_ctx(&s),
1389 &mut out,
1390 );
1391 assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1392 assert!(out.is_empty());
1393 }
1394
1395 #[test]
1396 fn mouse_down_updates_last_position_before_dispatch() {
1397 let s = GameState::default();
1401 let mut ui = UiState::new();
1402 let c = ctx(
1403 rect(40, 10, 20, 10),
1404 Rect::default(),
1405 rect(0, 0, 100, 30),
1406 Rect::default(),
1407 &[],
1408 &[],
1409 &[],
1410 false,
1411 &s,
1412 );
1413 let mut out = Vec::new();
1414 process_input_event(
1415 mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1416 &mut ui,
1417 &c,
1418 &mut out,
1419 );
1420 assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1421 }
1422}