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