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 green_coin_rect: Rect,
124 pub play_area: Rect,
125 pub prestige_reset_rect: Rect,
126 pub debug: bool,
127 pub current: &'a GameState,
128}
129
130impl<'a> InputContext<'a> {
131 pub fn from_layout(
141 layout: &'a crate::ui::DrawOutput,
142 current: &'a GameState,
143 debug: bool,
144 ) -> Self {
145 InputContext {
146 fingerer_rows: &layout.fingerer_rows,
147 upgrade_rows: &layout.upgrade_rows,
148 help_hits: &layout.help_hits,
149 biscuit_rect: layout.biscuit_rect,
150 golden_rect: layout.golden_rect,
151 green_coin_rect: layout.green_coin_rect,
152 play_area: layout.play_area,
153 prestige_reset_rect: layout.prestige_reset_rect,
154 debug,
155 current,
156 }
157 }
158}
159
160pub fn process_input_event(
166 ev: InputEvent,
167 ui: &mut UiState,
168 ctx: &InputContext,
169 out: &mut Vec<Action>,
170) {
171 match ev {
172 InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
173 InputEvent::MouseDown {
174 col,
175 row,
176 button,
177 mods,
178 } => {
179 ui.last_mouse_pos = Some((col, row));
180 if try_help_click(col, row, ui, ctx, out) {
185 return;
186 }
187 handle_click(col, row, button, mods, ui, ctx, out);
188 }
189 InputEvent::MouseMoved { col, row } => {
190 ui.last_mouse_pos = Some((col, row));
193 }
194 InputEvent::Wheel { col, row, delta } => {
195 if !in_play_area(col, row, ctx.play_area) {
200 return;
201 }
202 match delta {
203 WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
204 WheelDelta::Down => {
205 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
206 }
207 }
208 }
209 }
210}
211
212fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
217 if play_area.width == 0 || play_area.height == 0 {
218 return true;
219 }
220 col >= play_area.x
221 && col < play_area.x + play_area.width
222 && row >= play_area.y
223 && row < play_area.y + play_area.height
224}
225
226fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
227 rect.width > 0
228 && rect.height > 0
229 && col >= rect.x
230 && col < rect.x + rect.width
231 && row >= rect.y
232 && row < rect.y + rect.height
233}
234
235fn click_buy_qty(mods: Modifiers) -> BuyQty {
236 if mods.alt || mods.ctrl {
237 BuyQty::Max
238 } else if mods.shift {
239 BuyQty::Ten
240 } else {
241 BuyQty::One
242 }
243}
244
245fn try_help_click(
249 col: u16,
250 row: u16,
251 ui: &mut UiState,
252 ctx: &InputContext,
253 out: &mut Vec<Action>,
254) -> bool {
255 if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
259 out.push(Action::PrestigeReset);
260 ui.mode = Mode::Game;
261 return true;
262 }
263 for &(action, rect) in ctx.help_hits {
264 if !rect_contains(rect, col, row) {
265 continue;
266 }
267 match action {
268 HelpAction::OpenMode(target) => {
269 ui.mode = if ui.mode == target {
272 Mode::Game
273 } else {
274 target
275 };
276 }
277 HelpAction::GrabGolden => {
278 if ctx.current.golden.is_some() {
279 out.push(Action::CatchGolden);
280 }
281 }
282 HelpAction::PrestigeReset => {
283 if ctx.current.prestige_available() > 0 {
284 out.push(Action::PrestigeReset);
285 ui.mode = Mode::Game;
286 }
287 }
288 HelpAction::Quit => {
289 ui.running = false;
290 }
291 }
292 return true;
293 }
294 false
295}
296
297fn handle_click(
298 col: u16,
299 row: u16,
300 button: MouseButton,
301 mods: Modifiers,
302 ui: &UiState,
303 ctx: &InputContext,
304 out: &mut Vec<Action>,
305) {
306 if rect_contains(ctx.golden_rect, col, row) || rect_contains(ctx.green_coin_rect, col, row) {
311 out.push(Action::CatchGolden);
312 return;
313 }
314 if rect_contains(ctx.biscuit_rect, col, row) {
318 if button == MouseButton::Left {
319 out.push(Action::Click { col, row });
320 }
321 return;
322 }
323 if ui.mode == Mode::Game {
328 for &(idx, r) in ctx.fingerer_rows {
329 if rect_contains(r, col, row) {
330 let qty = if button == MouseButton::Right {
331 BuyQty::Max
332 } else {
333 click_buy_qty(mods)
334 };
335 out.push(Action::BuyFingerer { idx, qty });
336 return;
337 }
338 }
339 }
340 if ui.mode == Mode::Upgrades {
343 for &(idx, r) in ctx.upgrade_rows {
344 if rect_contains(r, col, row) {
345 out.push(Action::BuyUpgrade(idx));
346 return;
347 }
348 }
349 }
350 if button != MouseButton::Left {
362 return;
363 }
364 if !rect_contains(ctx.play_area, col, row) {
365 return;
366 }
367 if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.current) {
368 return;
369 }
370 out.push(Action::Misclick { col, row });
371}
372
373fn handle_key(
374 code: KeyCode,
375 mods: Modifiers,
376 ui: &mut UiState,
377 ctx: &InputContext,
378 out: &mut Vec<Action>,
379) {
380 match code {
381 KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
386 KeyCode::Esc => match ui.mode {
391 Mode::Game => {}
392 _ => ui.mode = Mode::Game,
393 },
394 KeyCode::Char('s') | KeyCode::Char('S') => {
395 ui.mode = if matches!(ui.mode, Mode::Stats) {
396 Mode::Game
397 } else {
398 Mode::Stats
399 };
400 }
401 KeyCode::Char('a') | KeyCode::Char('A') => {
402 ui.mode = if matches!(ui.mode, Mode::Achievements) {
403 Mode::Game
404 } else {
405 Mode::Achievements
406 };
407 }
408 KeyCode::Char('u') | KeyCode::Char('U') => {
409 ui.mode = if matches!(ui.mode, Mode::Upgrades) {
410 Mode::Game
411 } else {
412 Mode::Upgrades
413 };
414 }
415 KeyCode::Char('g') | KeyCode::Char('G')
418 if ctx.current.golden.is_some() || ctx.current.green_coin.is_some() =>
419 {
420 out.push(Action::CatchGolden);
421 }
422 KeyCode::F(8) if ctx.debug => {
428 out.push(Action::DevForceGolden(
429 crate::game::golden::GoldenVariant::Lucky,
430 ));
431 }
432 KeyCode::F(2) if ctx.debug => {
433 out.push(Action::DevForceGolden(
434 crate::game::golden::GoldenVariant::Frenzy,
435 ));
436 }
437 KeyCode::F(3) if ctx.debug => {
438 out.push(Action::DevForceGolden(
439 crate::game::golden::GoldenVariant::Buff,
440 ));
441 }
442 KeyCode::F(4) if ctx.debug => {
443 out.push(Action::DevAddCuques(1_000_000.0));
444 }
445 KeyCode::F(5) if ctx.debug => {
446 out.push(Action::DevSpawnGreenCoin);
447 }
448 KeyCode::Char('p') | KeyCode::Char('P') => {
449 ui.mode = if matches!(ui.mode, Mode::Prestige) {
450 Mode::Game
451 } else {
452 Mode::Prestige
453 };
454 }
455 KeyCode::Char('r') | KeyCode::Char('R')
460 if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
461 {
462 out.push(Action::PrestigeReset);
463 ui.mode = Mode::Game;
464 }
465 KeyCode::Char('+') | KeyCode::Char('=') => {
466 ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
467 }
468 KeyCode::Char('-') | KeyCode::Char('_') => {
469 ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
470 }
471 KeyCode::Char(' ') => {
474 out.push(Action::ClickCenter);
475 }
476 KeyCode::Char(c) => {
477 if let Some((slot, shifted_sym)) = digit_slot(c) {
478 let buy_10 = shifted_sym || mods.shift;
479 let buy_max = mods.alt || mods.ctrl;
480 match ui.mode {
481 Mode::Game => {
482 if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
483 let qty = if buy_max {
484 BuyQty::Max
485 } else if buy_10 {
486 BuyQty::Ten
487 } else {
488 BuyQty::One
489 };
490 out.push(Action::BuyFingerer { idx: fid, qty });
491 }
492 }
493 Mode::Upgrades => {
494 if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
495 out.push(Action::BuyUpgrade(u_idx));
496 }
497 }
498 _ => {}
499 }
500 }
501 }
502 _ => {}
503 }
504}
505
506fn digit_slot(c: char) -> Option<(usize, bool)> {
507 match c {
508 '1' => Some((0, false)),
509 '2' => Some((1, false)),
510 '3' => Some((2, false)),
511 '4' => Some((3, false)),
512 '5' => Some((4, false)),
513 '6' => Some((5, false)),
514 '7' => Some((6, false)),
515 '8' => Some((7, false)),
516 '9' => Some((8, false)),
517 '0' => Some((9, false)),
518 '!' => Some((0, true)),
519 '@' => Some((1, true)),
520 '#' => Some((2, true)),
521 '$' => Some((3, true)),
522 '%' => Some((4, true)),
523 '^' => Some((5, true)),
524 '&' => Some((6, true)),
525 '*' => Some((7, true)),
526 '(' => Some((8, true)),
527 ')' => Some((9, true)),
528 _ => None,
529 }
530}
531
532#[cfg(test)]
533mod tests {
534 use super::*;
544 use crate::game::golden::{self, GoldenVariant};
545 use crate::sim::{Action, BuyQty};
546 use ratatui::layout::Rect;
547 use std::mem::discriminant;
548
549 fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
550 Rect::new(x, y, w, h)
551 }
552
553 #[allow(clippy::too_many_arguments)]
557 fn ctx<'a>(
558 biscuit: Rect,
559 golden_rect: Rect,
560 play_area: Rect,
561 prestige_reset_rect: Rect,
562 fingerer_rows: &'a [(usize, Rect)],
563 upgrade_rows: &'a [(usize, Rect)],
564 help_hits: &'a [(HelpAction, Rect)],
565 debug: bool,
566 current: &'a GameState,
567 ) -> InputContext<'a> {
568 InputContext {
569 fingerer_rows,
570 upgrade_rows,
571 help_hits,
572 biscuit_rect: biscuit,
573 golden_rect,
574 green_coin_rect: Rect::default(),
575 play_area,
576 prestige_reset_rect,
577 debug,
578 current,
579 }
580 }
581
582 fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
583 ctx(
584 Rect::default(),
585 Rect::default(),
586 Rect::default(),
587 Rect::default(),
588 &[],
589 &[],
590 &[],
591 false,
592 state,
593 )
594 }
595
596 fn state_with_golden() -> GameState {
599 let mut g = golden::spawn_in(rect(10, 10, 20, 10));
600 g.variant = GoldenVariant::Lucky;
601 GameState {
602 golden: Some(g),
603 ..GameState::default()
604 }
605 }
606
607 fn state_with_prestige() -> GameState {
610 GameState {
613 lifetime_cuques: 4_000_000_000.0,
614 ..GameState::default()
615 }
616 }
617
618 fn key(code: KeyCode) -> InputEvent {
619 InputEvent::KeyPress {
620 code,
621 mods: Modifiers::default(),
622 }
623 }
624
625 fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
626 InputEvent::KeyPress {
627 code,
628 mods: Modifiers { shift, alt, ctrl },
629 }
630 }
631
632 fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
633 InputEvent::MouseDown {
634 col,
635 row,
636 button,
637 mods,
638 }
639 }
640
641 #[test]
644 fn q_key_flips_running_off() {
645 let s = GameState::default();
646 let mut ui = UiState::new();
647 let mut out = Vec::new();
648 process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
649 assert!(!ui.running);
650 assert!(out.is_empty());
651 }
652
653 #[test]
654 fn esc_from_game_is_noop() {
655 let s = GameState::default();
657 let mut ui = UiState::new();
658 let mut out = Vec::new();
659 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
660 assert!(ui.running);
661 assert_eq!(ui.mode, Mode::Game);
662 assert!(out.is_empty());
663 }
664
665 #[test]
666 fn esc_from_stats_returns_to_game() {
667 let s = GameState::default();
668 let mut ui = UiState::new();
669 ui.mode = Mode::Stats;
670 let mut out = Vec::new();
671 process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
672 assert_eq!(ui.mode, Mode::Game);
673 assert!(out.is_empty());
674 }
675
676 #[test]
677 fn s_key_toggles_stats() {
678 let s = GameState::default();
679 let mut ui = UiState::new();
680 let mut out = Vec::new();
681 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
682 assert_eq!(ui.mode, Mode::Stats);
683 process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
684 assert_eq!(ui.mode, Mode::Game);
685 }
686
687 #[test]
688 fn space_emits_click_center() {
689 let s = GameState::default();
690 let mut ui = UiState::new();
691 let mut out = Vec::new();
692 process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
693 assert_eq!(out.len(), 1);
694 assert!(matches!(out[0], Action::ClickCenter));
695 }
696
697 #[test]
698 fn g_with_no_golden_is_silent() {
699 let s = GameState::default();
700 let mut ui = UiState::new();
701 let mut out = Vec::new();
702 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
703 assert!(out.is_empty());
704 }
705
706 #[test]
707 fn g_with_golden_emits_catch() {
708 let s = state_with_golden();
709 let mut ui = UiState::new();
710 let mut out = Vec::new();
711 process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
712 assert!(matches!(out.as_slice(), [Action::CatchGolden]));
713 }
714
715 #[test]
716 fn fkeys_gated_by_debug() {
717 let s = GameState::default();
718 let mut ui = UiState::new();
719 let mut out = Vec::new();
721 let c = empty_ctx(&s);
722 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
723 process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
724 process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
725 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
726 assert!(out.is_empty(), "F-keys must be silent when debug=false");
727 }
728
729 #[test]
730 fn fkeys_active_when_debug() {
731 let s = GameState::default();
732 let mut ui = UiState::new();
733 let c = ctx(
734 Rect::default(),
735 Rect::default(),
736 Rect::default(),
737 Rect::default(),
738 &[],
739 &[],
740 &[],
741 true, &s,
743 );
744 let mut out = Vec::new();
745 process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
746 process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
747 assert!(matches!(
748 out[0],
749 Action::DevForceGolden(GoldenVariant::Lucky)
750 ));
751 assert!(matches!(out[1], Action::DevAddCuques(_)));
752 }
753
754 fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
757 ctx(
758 Rect::default(),
759 Rect::default(),
760 Rect::default(),
761 Rect::default(),
762 rows,
763 &[],
764 &[],
765 false,
766 state,
767 )
768 }
769
770 #[test]
771 fn digit_1_buys_one() {
772 let s = GameState::default();
773 let mut ui = UiState::new();
774 let rows = [(0_usize, rect(0, 0, 1, 1))];
775 let mut out = Vec::new();
776 process_input_event(
777 key(KeyCode::Char('1')),
778 &mut ui,
779 &fingerer_row_ctx(&s, &rows),
780 &mut out,
781 );
782 assert!(matches!(
783 out.as_slice(),
784 [Action::BuyFingerer {
785 idx: 0,
786 qty: BuyQty::One,
787 }]
788 ));
789 }
790
791 #[test]
792 fn shifted_digit_symbol_buys_ten() {
793 let s = GameState::default();
796 let mut ui = UiState::new();
797 let rows = [(0_usize, rect(0, 0, 1, 1))];
798 let mut out = Vec::new();
799 process_input_event(
800 key(KeyCode::Char('!')),
801 &mut ui,
802 &fingerer_row_ctx(&s, &rows),
803 &mut out,
804 );
805 assert!(matches!(
806 out.as_slice(),
807 [Action::BuyFingerer {
808 idx: 0,
809 qty: BuyQty::Ten,
810 }]
811 ));
812 }
813
814 #[test]
815 fn shift_modifier_on_digit_buys_ten() {
816 let s = GameState::default();
818 let mut ui = UiState::new();
819 let rows = [(0_usize, rect(0, 0, 1, 1))];
820 let mut out = Vec::new();
821 process_input_event(
822 key_with(KeyCode::Char('1'), true, false, false),
823 &mut ui,
824 &fingerer_row_ctx(&s, &rows),
825 &mut out,
826 );
827 assert!(matches!(
828 out.as_slice(),
829 [Action::BuyFingerer {
830 qty: BuyQty::Ten,
831 ..
832 }]
833 ));
834 }
835
836 #[test]
837 fn alt_or_ctrl_modifier_on_digit_buys_max() {
838 let s = GameState::default();
839 let rows = [(0_usize, rect(0, 0, 1, 1))];
840 for (alt, ctrl) in [(true, false), (false, true)] {
841 let mut ui = UiState::new();
842 let mut out = Vec::new();
843 process_input_event(
844 key_with(KeyCode::Char('1'), false, alt, ctrl),
845 &mut ui,
846 &fingerer_row_ctx(&s, &rows),
847 &mut out,
848 );
849 assert!(
850 matches!(
851 out.as_slice(),
852 [Action::BuyFingerer {
853 qty: BuyQty::Max,
854 ..
855 }]
856 ),
857 "alt={alt} ctrl={ctrl} should buy max",
858 );
859 }
860 }
861
862 #[test]
863 fn digit_with_no_visible_row_is_silent() {
864 let s = GameState::default();
867 let mut ui = UiState::new();
868 let mut out = Vec::new();
869 process_input_event(
870 key(KeyCode::Char('1')),
871 &mut ui,
872 &fingerer_row_ctx(&s, &[]),
873 &mut out,
874 );
875 assert!(out.is_empty());
876 }
877
878 #[test]
881 fn left_click_on_biscuit_emits_click() {
882 let s = GameState::default();
883 let mut ui = UiState::new();
884 let c = ctx(
885 rect(10, 5, 30, 20),
886 Rect::default(),
887 rect(0, 0, 100, 30),
888 Rect::default(),
889 &[],
890 &[],
891 &[],
892 false,
893 &s,
894 );
895 let mut out = Vec::new();
896 process_input_event(
897 mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
898 &mut ui,
899 &c,
900 &mut out,
901 );
902 assert!(
903 matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
904 "got {:?}",
905 out
906 );
907 }
908
909 #[test]
910 fn right_click_on_biscuit_is_noop() {
911 let s = GameState::default();
914 let mut ui = UiState::new();
915 let c = ctx(
916 rect(10, 5, 30, 20),
917 Rect::default(),
918 rect(0, 0, 100, 30),
919 Rect::default(),
920 &[],
921 &[],
922 &[],
923 false,
924 &s,
925 );
926 let mut out = Vec::new();
927 process_input_event(
928 mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
929 &mut ui,
930 &c,
931 &mut out,
932 );
933 assert!(out.is_empty(), "got {:?}", out);
934 }
935
936 #[test]
937 fn left_click_on_golden_emits_catch() {
938 let s = state_with_golden();
939 let mut ui = UiState::new();
940 let c = ctx(
941 Rect::default(),
942 rect(50, 12, 4, 2),
943 rect(0, 0, 100, 30),
944 Rect::default(),
945 &[],
946 &[],
947 &[],
948 false,
949 &s,
950 );
951 let mut out = Vec::new();
952 process_input_event(
953 mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
954 &mut ui,
955 &c,
956 &mut out,
957 );
958 assert!(matches!(out.as_slice(), [Action::CatchGolden]));
959 }
960
961 #[test]
962 fn right_click_on_golden_also_catches() {
963 let s = state_with_golden();
965 let mut ui = UiState::new();
966 let c = ctx(
967 Rect::default(),
968 rect(50, 12, 4, 2),
969 rect(0, 0, 100, 30),
970 Rect::default(),
971 &[],
972 &[],
973 &[],
974 false,
975 &s,
976 );
977 let mut out = Vec::new();
978 process_input_event(
979 mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
980 &mut ui,
981 &c,
982 &mut out,
983 );
984 assert!(matches!(out.as_slice(), [Action::CatchGolden]));
985 }
986
987 #[test]
988 fn left_click_on_fingerer_row_buys_one() {
989 let s = GameState::default();
990 let mut ui = UiState::new();
991 let rows = [(2_usize, rect(100, 5, 38, 3))];
992 let c = ctx(
993 Rect::default(),
994 Rect::default(),
995 Rect::default(),
996 Rect::default(),
997 &rows,
998 &[],
999 &[],
1000 false,
1001 &s,
1002 );
1003 let mut out = Vec::new();
1004 process_input_event(
1005 mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
1006 &mut ui,
1007 &c,
1008 &mut out,
1009 );
1010 assert!(matches!(
1011 out.as_slice(),
1012 [Action::BuyFingerer {
1013 idx: 2,
1014 qty: BuyQty::One,
1015 }]
1016 ));
1017 }
1018
1019 #[test]
1020 fn right_click_on_fingerer_row_buys_max() {
1021 let s = GameState::default();
1023 let mut ui = UiState::new();
1024 let rows = [(2_usize, rect(100, 5, 38, 3))];
1025 let c = ctx(
1026 Rect::default(),
1027 Rect::default(),
1028 Rect::default(),
1029 Rect::default(),
1030 &rows,
1031 &[],
1032 &[],
1033 false,
1034 &s,
1035 );
1036 let mut out = Vec::new();
1037 process_input_event(
1038 mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
1039 &mut ui,
1040 &c,
1041 &mut out,
1042 );
1043 assert!(matches!(
1044 out.as_slice(),
1045 [Action::BuyFingerer {
1046 qty: BuyQty::Max,
1047 ..
1048 }]
1049 ));
1050 }
1051
1052 #[test]
1053 fn shift_left_click_on_fingerer_row_buys_ten() {
1054 let s = GameState::default();
1055 let mut ui = UiState::new();
1056 let rows = [(2_usize, rect(100, 5, 38, 3))];
1057 let c = ctx(
1058 Rect::default(),
1059 Rect::default(),
1060 Rect::default(),
1061 Rect::default(),
1062 &rows,
1063 &[],
1064 &[],
1065 false,
1066 &s,
1067 );
1068 let mut out = Vec::new();
1069 let mods = Modifiers {
1070 shift: true,
1071 ..Modifiers::default()
1072 };
1073 process_input_event(
1074 mouse_down(110, 6, MouseButton::Left, mods),
1075 &mut ui,
1076 &c,
1077 &mut out,
1078 );
1079 assert!(matches!(
1080 out.as_slice(),
1081 [Action::BuyFingerer {
1082 qty: BuyQty::Ten,
1083 ..
1084 }]
1085 ));
1086 }
1087
1088 #[test]
1091 fn dead_zone_left_click_inside_play_area_emits_misclick() {
1092 let s = GameState::default();
1094 let mut ui = UiState::new();
1095 let c = ctx(
1096 rect(40, 10, 20, 10), Rect::default(),
1098 rect(0, 0, 100, 30), Rect::default(),
1100 &[],
1101 &[],
1102 &[],
1103 false,
1104 &s,
1105 );
1106 let mut out = Vec::new();
1107 process_input_event(
1108 mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1109 &mut ui,
1110 &c,
1111 &mut out,
1112 );
1113 assert!(
1114 matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1115 "got {:?}",
1116 out
1117 );
1118 }
1119
1120 #[test]
1121 fn click_outside_play_area_does_not_misclick() {
1122 let s = GameState::default();
1125 let mut ui = UiState::new();
1126 let c = ctx(
1127 rect(40, 10, 20, 10),
1128 Rect::default(),
1129 rect(0, 0, 100, 30), Rect::default(),
1131 &[],
1132 &[],
1133 &[],
1134 false,
1135 &s,
1136 );
1137 let mut out = Vec::new();
1138 process_input_event(
1139 mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1140 &mut ui,
1141 &c,
1142 &mut out,
1143 );
1144 assert!(out.is_empty(), "got {:?}", out);
1145 }
1146
1147 #[test]
1148 fn right_click_in_dead_zone_is_silent() {
1149 let s = GameState::default();
1151 let mut ui = UiState::new();
1152 let c = ctx(
1153 rect(40, 10, 20, 10),
1154 Rect::default(),
1155 rect(0, 0, 100, 30),
1156 Rect::default(),
1157 &[],
1158 &[],
1159 &[],
1160 false,
1161 &s,
1162 );
1163 let mut out = Vec::new();
1164 process_input_event(
1165 mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1166 &mut ui,
1167 &c,
1168 &mut out,
1169 );
1170 assert!(out.is_empty());
1171 }
1172
1173 #[test]
1176 fn click_quit_hint_flips_running() {
1177 let s = GameState::default();
1178 let mut ui = UiState::new();
1179 let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1180 let c = ctx(
1181 Rect::default(),
1182 Rect::default(),
1183 rect(0, 0, 100, 30),
1184 Rect::default(),
1185 &[],
1186 &[],
1187 &hits,
1188 false,
1189 &s,
1190 );
1191 let mut out = Vec::new();
1192 process_input_event(
1193 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1194 &mut ui,
1195 &c,
1196 &mut out,
1197 );
1198 assert!(!ui.running);
1199 assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1200 }
1201
1202 #[test]
1203 fn click_open_mode_hint_toggles_mode() {
1204 let s = GameState::default();
1205 let mut ui = UiState::new();
1206 let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1207 let c = ctx(
1208 Rect::default(),
1209 Rect::default(),
1210 rect(0, 0, 100, 30),
1211 Rect::default(),
1212 &[],
1213 &[],
1214 &hits,
1215 false,
1216 &s,
1217 );
1218 let mut out = Vec::new();
1219 process_input_event(
1220 mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1221 &mut ui,
1222 &c,
1223 &mut out,
1224 );
1225 assert_eq!(ui.mode, Mode::Stats);
1226 }
1227
1228 #[test]
1231 fn prestige_reset_rect_unavailable_does_not_reset() {
1232 let s = GameState::default(); let mut ui = UiState::new();
1238 let c = ctx(
1239 Rect::default(),
1240 Rect::default(),
1241 rect(0, 0, 100, 30),
1242 rect(40, 15, 30, 1), &[],
1244 &[],
1245 &[],
1246 false,
1247 &s,
1248 );
1249 let mut out = Vec::new();
1250 process_input_event(
1251 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1252 &mut ui,
1253 &c,
1254 &mut out,
1255 );
1256 assert!(
1257 !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1258 "no PrestigeReset when unavailable; got {:?}",
1259 out
1260 );
1261 assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1262 }
1263
1264 #[test]
1265 fn prestige_reset_rect_available_emits_action() {
1266 let s = state_with_prestige();
1267 let mut ui = UiState::new();
1268 ui.mode = Mode::Prestige;
1269 let c = ctx(
1270 Rect::default(),
1271 Rect::default(),
1272 rect(0, 0, 100, 30),
1273 rect(40, 15, 30, 1),
1274 &[],
1275 &[],
1276 &[],
1277 false,
1278 &s,
1279 );
1280 let mut out = Vec::new();
1281 process_input_event(
1282 mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1283 &mut ui,
1284 &c,
1285 &mut out,
1286 );
1287 assert_eq!(out.len(), 1);
1288 assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1289 assert_eq!(
1290 ui.mode,
1291 Mode::Game,
1292 "panel auto-closes after prestige confirm",
1293 );
1294 }
1295
1296 #[test]
1299 fn wheel_down_inside_play_area_increases_zoom_idx() {
1300 let s = GameState::default();
1301 let mut ui = UiState::new();
1302 let c = ctx(
1303 Rect::default(),
1304 Rect::default(),
1305 rect(0, 0, 100, 30),
1306 Rect::default(),
1307 &[],
1308 &[],
1309 &[],
1310 false,
1311 &s,
1312 );
1313 let mut out = Vec::new();
1314 process_input_event(
1315 InputEvent::Wheel {
1316 col: 50,
1317 row: 15,
1318 delta: WheelDelta::Down,
1319 },
1320 &mut ui,
1321 &c,
1322 &mut out,
1323 );
1324 assert_eq!(ui.zoom_idx, 1);
1325 }
1326
1327 #[test]
1328 fn wheel_outside_play_area_does_not_zoom() {
1329 let s = GameState::default();
1331 let mut ui = UiState::new();
1332 let c = ctx(
1333 Rect::default(),
1334 Rect::default(),
1335 rect(0, 0, 100, 30),
1336 Rect::default(),
1337 &[],
1338 &[],
1339 &[],
1340 false,
1341 &s,
1342 );
1343 let mut out = Vec::new();
1344 process_input_event(
1345 InputEvent::Wheel {
1346 col: 120,
1347 row: 10,
1348 delta: WheelDelta::Down,
1349 },
1350 &mut ui,
1351 &c,
1352 &mut out,
1353 );
1354 assert_eq!(ui.zoom_idx, 0);
1355 }
1356
1357 #[test]
1358 fn wheel_up_saturates_at_zero() {
1359 let s = GameState::default();
1360 let mut ui = UiState::new();
1361 ui.zoom_idx = 0;
1362 let c = ctx(
1363 Rect::default(),
1364 Rect::default(),
1365 rect(0, 0, 100, 30),
1366 Rect::default(),
1367 &[],
1368 &[],
1369 &[],
1370 false,
1371 &s,
1372 );
1373 let mut out = Vec::new();
1374 process_input_event(
1375 InputEvent::Wheel {
1376 col: 50,
1377 row: 15,
1378 delta: WheelDelta::Up,
1379 },
1380 &mut ui,
1381 &c,
1382 &mut out,
1383 );
1384 assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1385 }
1386
1387 #[test]
1388 fn wheel_down_caps_at_last_level() {
1389 let s = GameState::default();
1390 let mut ui = UiState::new();
1391 let last = crate::ui::biscuit::level_count() - 1;
1392 ui.zoom_idx = last;
1393 let c = ctx(
1394 Rect::default(),
1395 Rect::default(),
1396 rect(0, 0, 100, 30),
1397 Rect::default(),
1398 &[],
1399 &[],
1400 &[],
1401 false,
1402 &s,
1403 );
1404 let mut out = Vec::new();
1405 process_input_event(
1406 InputEvent::Wheel {
1407 col: 50,
1408 row: 15,
1409 delta: WheelDelta::Down,
1410 },
1411 &mut ui,
1412 &c,
1413 &mut out,
1414 );
1415 assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1416 }
1417
1418 #[test]
1421 fn mouse_moved_updates_last_position() {
1422 let s = GameState::default();
1423 let mut ui = UiState::new();
1424 let mut out = Vec::new();
1425 process_input_event(
1426 InputEvent::MouseMoved { col: 42, row: 7 },
1427 &mut ui,
1428 &empty_ctx(&s),
1429 &mut out,
1430 );
1431 assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1432 assert!(out.is_empty());
1433 }
1434
1435 #[test]
1436 fn mouse_down_updates_last_position_before_dispatch() {
1437 let s = GameState::default();
1441 let mut ui = UiState::new();
1442 let c = ctx(
1443 rect(40, 10, 20, 10),
1444 Rect::default(),
1445 rect(0, 0, 100, 30),
1446 Rect::default(),
1447 &[],
1448 &[],
1449 &[],
1450 false,
1451 &s,
1452 );
1453 let mut out = Vec::new();
1454 process_input_event(
1455 mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1456 &mut ui,
1457 &c,
1458 &mut out,
1459 );
1460 assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1461 }
1462}