Skip to main content

cuqueclicker_lib/
input.rs

1//! Platform-agnostic input router.
2//!
3//! Defines a small [`InputEvent`] vocabulary that's a strict superset of
4//! what we need to translate from any input source (crossterm on native,
5//! ratzilla on web). The router consumes one [`InputEvent`] and produces
6//! zero or more [`Action`]s into a caller-owned `Vec<Action>` buffer; it
7//! also mutates the [`UiState`] that doesn't belong on the sim side
8//! (`mode`, `zoom_idx`, `running`, `last_mouse_pos`).
9//!
10//! Adapters live next to their event source — `app.rs::translate_crossterm`
11//! produces `InputEvent` from `crossterm::event::Event`, and (when the
12//! wasm port lands) a sibling adapter does the same for `ratzilla::event`.
13//! Both feed the same router so behavior parity is enforced by sharing
14//! code, not by duplicating it.
15//!
16//! Geometry (`fingerer_rows`, `upgrade_rows`, `help_hits`, etc.) is passed
17//! in via [`InputContext`] — the renderer recomputes these every frame
18//! and the click handler hit-tests against the latest set.
19
20use ratatui::layout::Rect;
21
22use crate::game::state::GameState;
23use crate::sim::{Action, BuyQty};
24use crate::ui::{HelpAction, Mode};
25
26/// Platform-neutral input vocabulary. Crossterm's `Event::{Key,Mouse,Resize,…}`
27/// and ratzilla's `KeyEvent`/`MouseEvent`/`WheelEvent` both narrow into this
28/// — anything we don't need (focus, paste, resize) is dropped at the adapter.
29#[derive(Clone, Debug)]
30pub enum InputEvent {
31    /// A key was pressed. `code` is the resolved key (with shifted symbols
32    /// already mapped to their character form, e.g. Shift+1 → `!`).
33    KeyPress { code: KeyCode, mods: Modifiers },
34    /// A mouse button went down at terminal cell `(col, row)`.
35    MouseDown {
36        col: u16,
37        row: u16,
38        button: MouseButton,
39        mods: Modifiers,
40    },
41    /// The mouse moved over `(col, row)` — used for hover highlighting.
42    /// Drag events normalize to this too; the router doesn't care which.
43    MouseMoved { col: u16, row: u16 },
44    /// Scroll wheel scrolled. `(col, row)` is the cursor cell at the time
45    /// of the wheel tick — used to gate zoom to the play-area.
46    Wheel {
47        col: u16,
48        row: u16,
49        delta: WheelDelta,
50    },
51}
52
53/// Subset of key codes the game actually consumes. Anything else from the
54/// underlying terminal event is dropped at the adapter.
55#[derive(Clone, Copy, Debug, PartialEq, Eq)]
56pub enum KeyCode {
57    Char(char),
58    Esc,
59    F(u8),
60}
61
62/// Subset of mouse buttons the game cares about. Middle-click is dropped
63/// at every adapter (it had no game effect on native and we don't intend
64/// one on web either); add a variant here if a future input source wants
65/// it routed.
66#[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
85/// State that lives on the input/render side of the boundary, not on the
86/// sim. Persistence-wise: not serialized, recreated fresh on each launch.
87pub 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
111/// Per-frame geometry the click router hit-tests against. All `Rect`s come
112/// from the latest `ui::draw` output; `current` is the latest published
113/// snapshot. Borrowed for the duration of one event dispatch.
114pub 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    /// Screen position of the biscuit's focal cell. See
120    /// [`crate::ui::DrawOutput::biscuit_focal`]. Used by `hands::occupied_at`
121    /// to keep its hit-test math in sync with the visual orbit.
122    pub biscuit_focal: (u16, u16),
123    /// One rect per Golden variant slot (Lucky/Frenzy/Buff), indexed by
124    /// `GoldenVariant as usize`. Each is hit-tested independently so
125    /// clicking one variant catches only that variant.
126    pub golden_rects: [Rect; 3],
127    /// Hit-test rect for the on-screen Green Coin marker. Zero-rect when
128    /// no coin is visible; click-routes through `Action::CatchGreenCoin`.
129    pub green_coin_rect: Rect,
130    pub play_area: Rect,
131    pub prestige_reset_rect: Rect,
132    pub debug: bool,
133    pub current: &'a GameState,
134}
135
136impl<'a> InputContext<'a> {
137    /// Build an input context by borrowing from a render
138    /// [`DrawOutput`](crate::ui::DrawOutput).
139    ///
140    /// The platform shells (`app.rs`, `wasm_app.rs`) call this so adding
141    /// a new clickable region only touches `DrawOutput` + `InputContext` +
142    /// this projection — never the platform code. Without this single
143    /// projection point, native and wasm each kept their own field-by-field
144    /// copy of the layout snapshot, and a new field meant updating both;
145    /// the wasm build broke once when only the native copy was updated.
146    pub fn from_layout(
147        layout: &'a crate::ui::DrawOutput,
148        current: &'a GameState,
149        debug: bool,
150    ) -> Self {
151        InputContext {
152            fingerer_rows: &layout.fingerer_rows,
153            upgrade_rows: &layout.upgrade_rows,
154            help_hits: &layout.help_hits,
155            biscuit_rect: layout.biscuit_rect,
156            biscuit_focal: layout.biscuit_focal,
157            golden_rects: layout.golden_rects,
158            green_coin_rect: layout.green_coin_rect,
159            play_area: layout.play_area,
160            prestige_reset_rect: layout.prestige_reset_rect,
161            debug,
162            current,
163        }
164    }
165}
166
167/// Process one [`InputEvent`]. Mutates [`UiState`]; appends produced actions
168/// to `out`. Pure data — does no I/O. The router *reads* `GameState` (via
169/// `ctx.current` for `prestige_available()` / `golden.is_some()` and via
170/// `ui::hands::occupied_at` for misclick gating) but never mutates it; all
171/// mutation flows through the produced [`Action`]s and `apply_action`.
172pub fn process_input_event(
173    ev: InputEvent,
174    ui: &mut UiState,
175    ctx: &InputContext,
176    out: &mut Vec<Action>,
177) {
178    match ev {
179        InputEvent::KeyPress { code, mods } => handle_key(code, mods, ui, ctx, out),
180        InputEvent::MouseDown {
181            col,
182            row,
183            button,
184            mods,
185        } => {
186            ui.last_mouse_pos = Some((col, row));
187            // M1+M2: try help-bar / prestige-reset hits first. These give
188            // the mouse-only player parity with `[u]/[p]/[s]/[a]/[g]/[q]/[r]`
189            // shortcuts. Consumed hits short-circuit the rest of the click
190            // pipeline so we don't also fire a misclick particle.
191            if try_help_click(col, row, ui, ctx, out) {
192                return;
193            }
194            handle_click(col, row, button, mods, ui, ctx, out);
195        }
196        InputEvent::MouseMoved { col, row } => {
197            // K5: hover highlighting; renderer reads `last_mouse_pos`.
198            // Drag events from the underlying terminal collapse to this.
199            ui.last_mouse_pos = Some((col, row));
200        }
201        InputEvent::Wheel { col, row, delta } => {
202            // Scroll only zooms inside the play area (the whole left column
203            // where the biscuit lives, including the void around a small
204            // biscuit at low zoom). Cold frames (no rect yet) conservatively
205            // allow zoom so the very first scroll after launch isn't dropped.
206            if !in_play_area(col, row, ctx.play_area) {
207                return;
208            }
209            match delta {
210                WheelDelta::Up => ui.zoom_idx = ui.zoom_idx.saturating_sub(1),
211                WheelDelta::Down => {
212                    ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
213                }
214            }
215        }
216    }
217}
218
219/// True when the scroll happened anywhere inside the play-area rect — the
220/// whole left column the biscuit lives in (HUD-and-help-excluded). Cold
221/// frames (no rect yet) conservatively allow zoom so the very first scroll
222/// after launch isn't dropped.
223fn in_play_area(col: u16, row: u16, play_area: Rect) -> bool {
224    if play_area.width == 0 || play_area.height == 0 {
225        return true;
226    }
227    col >= play_area.x
228        && col < play_area.x + play_area.width
229        && row >= play_area.y
230        && row < play_area.y + play_area.height
231}
232
233fn rect_contains(rect: Rect, col: u16, row: u16) -> bool {
234    rect.width > 0
235        && rect.height > 0
236        && col >= rect.x
237        && col < rect.x + rect.width
238        && row >= rect.y
239        && row < rect.y + rect.height
240}
241
242/// Push the catch action for whichever on-screen powerup has the fewest
243/// life ticks remaining (most-urgent across Lucky / Frenzy / Buff /
244/// Green Coin). No-op if nothing is on screen. Shared by the keyboard
245/// `g` handler and the help-bar `[g]` click.
246fn push_grab_most_urgent(ctx: &InputContext, out: &mut Vec<Action>) {
247    use crate::game::golden::GoldenVariant;
248    let mut best: Option<(u32, Action)> = None;
249    for variant in GoldenVariant::ALL {
250        if let Some(g) = ctx.current.goldens[variant as usize].as_ref() {
251            let action = Action::CatchGolden(variant);
252            best = match best {
253                Some((life, _)) if life <= g.life_ticks => best,
254                _ => Some((g.life_ticks, action)),
255            };
256        }
257    }
258    if let Some(c) = ctx.current.green_coin.as_ref() {
259        let action = Action::CatchGreenCoin;
260        best = match best {
261            Some((life, _)) if life <= c.life_ticks => best,
262            _ => Some((c.life_ticks, action)),
263        };
264    }
265    if let Some((_, a)) = best {
266        out.push(a);
267    }
268}
269
270fn click_buy_qty(mods: Modifiers) -> BuyQty {
271    if mods.alt || mods.ctrl {
272        BuyQty::Max
273    } else if mods.shift {
274        BuyQty::Ten
275    } else {
276        BuyQty::One
277    }
278}
279
280/// Try to consume a click on a help-bar hint or the prestige-reset confirm
281/// line. Returns true when the click was handled — caller short-circuits
282/// the rest of the pipeline (no biscuit/row/misclick path).
283fn try_help_click(
284    col: u16,
285    row: u16,
286    ui: &mut UiState,
287    ctx: &InputContext,
288    out: &mut Vec<Action>,
289) -> bool {
290    // Prestige-reset confirm: in-panel button. Match BEFORE help-bar so
291    // the confirm "wins" if the help bar happens to overlap it (it
292    // shouldn't, but defensive).
293    if rect_contains(ctx.prestige_reset_rect, col, row) && ctx.current.prestige_available() > 0 {
294        out.push(Action::PrestigeReset);
295        ui.mode = Mode::Game;
296        return true;
297    }
298    for &(action, rect) in ctx.help_hits {
299        if !rect_contains(rect, col, row) {
300            continue;
301        }
302        match action {
303            HelpAction::OpenMode(target) => {
304                // Same toggle semantics the keyboard uses: tapping the
305                // hint for the active mode returns to Game.
306                ui.mode = if ui.mode == target {
307                    Mode::Game
308                } else {
309                    target
310                };
311            }
312            HelpAction::GrabGolden => {
313                // Help-bar `[g]` click — grab the most-urgent powerup
314                // currently on screen, identical to the keyboard 'g'.
315                push_grab_most_urgent(ctx, out);
316            }
317            HelpAction::PrestigeReset => {
318                if ctx.current.prestige_available() > 0 {
319                    out.push(Action::PrestigeReset);
320                    ui.mode = Mode::Game;
321                }
322            }
323            HelpAction::Quit => {
324                ui.running = false;
325            }
326        }
327        return true;
328    }
329    false
330}
331
332fn handle_click(
333    col: u16,
334    row: u16,
335    button: MouseButton,
336    mods: Modifiers,
337    ui: &UiState,
338    ctx: &InputContext,
339    out: &mut Vec<Action>,
340) {
341    // Golden cuques are catchable from ANY panel — match the keyboard 'g'
342    // behavior, which has no mode guard. The marker still renders on the
343    // biscuit while a non-Game panel is open. Right-click on a golden
344    // also catches.
345    // Each powerup marker is its own click target — clicking on the
346    // golden does NOT also vacuum up an adjacent green coin or sibling
347    // golden variant. Lucky/Frenzy/Buff/GreenCoin can all coexist on
348    // screen; each one catches only itself.
349    for variant in crate::game::golden::GoldenVariant::ALL {
350        if rect_contains(ctx.golden_rects[variant as usize], col, row) {
351            out.push(Action::CatchGolden(variant));
352            return;
353        }
354    }
355    if rect_contains(ctx.green_coin_rect, col, row) {
356        out.push(Action::CatchGreenCoin);
357        return;
358    }
359    // Clicking the biscuit itself is also mode-agnostic. Right-click on
360    // the biscuit is a no-op so a player can't accidentally finger the
361    // cuque with the wrong button.
362    if rect_contains(ctx.biscuit_rect, col, row) {
363        if button == MouseButton::Left {
364            out.push(Action::Click { col, row });
365        }
366        return;
367    }
368    // Mouse-buy fingerers from the sidebar in Game mode. Modifiers control
369    // quantity (plain = 1, Shift = 10, Alt/Ctrl = max), matching the
370    // digit-key shortcuts. Right-click is the always-Max affordance
371    // regardless of modifiers.
372    if ui.mode == Mode::Game {
373        for &(idx, r) in ctx.fingerer_rows {
374            if rect_contains(r, col, row) {
375                let qty = if button == MouseButton::Right {
376                    BuyQty::Max
377                } else {
378                    click_buy_qty(mods)
379                };
380                out.push(Action::BuyFingerer { idx, qty });
381                return;
382            }
383        }
384    }
385    // Mouse-buy upgrades from the Upgrades panel. Modifiers ignored — each
386    // upgrade is a one-shot purchase. Right-click also buys.
387    if ui.mode == Mode::Upgrades {
388        for &(idx, r) in ctx.upgrade_rows {
389            if rect_contains(r, col, row) {
390                out.push(Action::BuyUpgrade(idx));
391                return;
392            }
393        }
394    }
395    // J10: nothing actionable under the click. Acknowledge it visually with
396    // a brief "·" so the dead-zone (e.g. the air around a 25%-zoom biscuit)
397    // doesn't feel inert. Skip when:
398    //   - the click was right-button (right-click without a target is a
399    //     true no-op);
400    //   - the click landed on an orbital hand glyph — those are decoration,
401    //     not click targets, but they're visually present, so a misclick
402    //     "·" replacing part of `[]` / `:*` / `>>` reads as flicker.
403    //   - M3: the click landed OUTSIDE the play area (HUD title, sidebar,
404    //     debug pane, help bar). Inert UI chrome shouldn't get a "·"
405    //     overpainted into it.
406    if button != MouseButton::Left {
407        return;
408    }
409    if !rect_contains(ctx.play_area, col, row) {
410        return;
411    }
412    if crate::ui::hands::occupied_at(col, row, ctx.biscuit_rect, ctx.biscuit_focal, ctx.current) {
413        return;
414    }
415    out.push(Action::Misclick { col, row });
416}
417
418fn handle_key(
419    code: KeyCode,
420    mods: Modifiers,
421    ui: &mut UiState,
422    ctx: &InputContext,
423    out: &mut Vec<Action>,
424) {
425    match code {
426        // Gated on the platform's `can_quit` capability so a stray `q`
427        // press in the browser doesn't silently flip `ui.running` (which
428        // a future feature might key off of even though the rAF loop
429        // doesn't read it today).
430        KeyCode::Char('q') if crate::platform::CAPABILITIES.can_quit => ui.running = false,
431        // J12: Esc dismisses panels back to Game mode but is a NO-OP from
432        // Game itself. Quit is `q` only — Esc-to-quit was an aggressive
433        // default that surprised playtesters who reflex-pressed it to
434        // "deselect" with no panel open.
435        KeyCode::Esc => match ui.mode {
436            Mode::Game => {}
437            _ => ui.mode = Mode::Game,
438        },
439        KeyCode::Char('s') | KeyCode::Char('S') => {
440            ui.mode = if matches!(ui.mode, Mode::Stats) {
441                Mode::Game
442            } else {
443                Mode::Stats
444            };
445        }
446        KeyCode::Char('a') | KeyCode::Char('A') => {
447            ui.mode = if matches!(ui.mode, Mode::Achievements) {
448                Mode::Game
449            } else {
450                Mode::Achievements
451            };
452        }
453        KeyCode::Char('u') | KeyCode::Char('U') => {
454            ui.mode = if matches!(ui.mode, Mode::Upgrades) {
455                Mode::Game
456            } else {
457                Mode::Upgrades
458            };
459        }
460        // [g] catches the most-urgent powerup (lowest remaining life
461        // ticks) across all four slots — Lucky, Frenzy, Buff, and
462        // Green Coin. A second [g] press grabs the next-most-urgent.
463        // Lets the player race against expiry without the keyboard
464        // accidentally vacuuming up siblings.
465        KeyCode::Char('g') | KeyCode::Char('G') => {
466            push_grab_most_urgent(ctx, out);
467        }
468        // Debug/testing: gated by `debug`. See src/ui/debug_pane.rs for the
469        // advertised key list. F8 (not F1) is Lucky because Chrome / Edge /
470        // Safari hijack F1 for browser Help and never forward the keydown
471        // to the wasm page; F8 has no default browser binding outside of
472        // an open DevTools instance.
473        KeyCode::F(8) if ctx.debug => {
474            out.push(Action::DevForceGolden(
475                crate::game::golden::GoldenVariant::Lucky,
476            ));
477        }
478        KeyCode::F(2) if ctx.debug => {
479            out.push(Action::DevForceGolden(
480                crate::game::golden::GoldenVariant::Frenzy,
481            ));
482        }
483        KeyCode::F(3) if ctx.debug => {
484            out.push(Action::DevForceGolden(
485                crate::game::golden::GoldenVariant::Buff,
486            ));
487        }
488        KeyCode::F(4) if ctx.debug => {
489            out.push(Action::DevAddCuques(1_000_000.0));
490        }
491        KeyCode::F(5) if ctx.debug => {
492            out.push(Action::DevSpawnGreenCoin);
493        }
494        KeyCode::Char('p') | KeyCode::Char('P') => {
495            ui.mode = if matches!(ui.mode, Mode::Prestige) {
496                Mode::Game
497            } else {
498                Mode::Prestige
499            };
500        }
501        // Prestige confirm: check the snapshot for available prestige before
502        // firing. Optimistically close the panel — if the sim rejects the
503        // reset (raced against a simultaneous lifetime-cuque drop) nothing
504        // bad happens.
505        KeyCode::Char('r') | KeyCode::Char('R')
506            if ui.mode == Mode::Prestige && ctx.current.prestige_available() > 0 =>
507        {
508            out.push(Action::PrestigeReset);
509            ui.mode = Mode::Game;
510        }
511        KeyCode::Char('+') | KeyCode::Char('=') => {
512            ui.zoom_idx = ui.zoom_idx.saturating_sub(1);
513        }
514        KeyCode::Char('-') | KeyCode::Char('_') => {
515            ui.zoom_idx = (ui.zoom_idx + 1).min(crate::ui::biscuit::level_count() - 1);
516        }
517        // Space ALWAYS fingers the cuque, regardless of which panel is open
518        // — same contract as left-click on the biscuit.
519        KeyCode::Char(' ') => {
520            out.push(Action::ClickCenter);
521        }
522        KeyCode::Char(c) => {
523            if let Some((slot, shifted_sym)) = digit_slot(c) {
524                let buy_10 = shifted_sym || mods.shift;
525                let buy_max = mods.alt || mods.ctrl;
526                match ui.mode {
527                    Mode::Game => {
528                        if let Some(&(fid, _)) = ctx.fingerer_rows.get(slot) {
529                            let qty = if buy_max {
530                                BuyQty::Max
531                            } else if buy_10 {
532                                BuyQty::Ten
533                            } else {
534                                BuyQty::One
535                            };
536                            out.push(Action::BuyFingerer { idx: fid, qty });
537                        }
538                    }
539                    Mode::Upgrades => {
540                        if let Some(&(u_idx, _)) = ctx.upgrade_rows.get(slot) {
541                            out.push(Action::BuyUpgrade(u_idx));
542                        }
543                    }
544                    _ => {}
545                }
546            }
547        }
548        _ => {}
549    }
550}
551
552fn digit_slot(c: char) -> Option<(usize, bool)> {
553    match c {
554        '1' => Some((0, false)),
555        '2' => Some((1, false)),
556        '3' => Some((2, false)),
557        '4' => Some((3, false)),
558        '5' => Some((4, false)),
559        '6' => Some((5, false)),
560        '7' => Some((6, false)),
561        '8' => Some((7, false)),
562        '9' => Some((8, false)),
563        '0' => Some((9, false)),
564        '!' => Some((0, true)),
565        '@' => Some((1, true)),
566        '#' => Some((2, true)),
567        '$' => Some((3, true)),
568        '%' => Some((4, true)),
569        '^' => Some((5, true)),
570        '&' => Some((6, true)),
571        '*' => Some((7, true)),
572        '(' => Some((8, true)),
573        ')' => Some((9, true)),
574        _ => None,
575    }
576}
577
578#[cfg(test)]
579mod tests {
580    //! Router tests. The whole point of pulling `process_input_event` into a
581    //! platform-neutral module is that we can exercise it with no terminal,
582    //! no event loop, and no threading. Each test feeds a synthetic event
583    //! and asserts on the produced `Vec<Action>` + `UiState` deltas.
584    //!
585    //! Constructing an `InputContext` requires stubs for the per-frame rects
586    //! and a borrowed `GameState`; helpers below collapse the boilerplate.
587    //! `state_with_golden()` and `state_with_prestige()` mutate just enough
588    //! of the default to exercise the gates the router cares about.
589    use super::*;
590    use crate::game::golden::{self, GoldenVariant};
591    use crate::sim::{Action, BuyQty};
592    use ratatui::layout::Rect;
593    use std::mem::discriminant;
594
595    fn rect(x: u16, y: u16, w: u16, h: u16) -> Rect {
596        Rect::new(x, y, w, h)
597    }
598
599    /// Builds an `InputContext` over caller-provided rect/row/state slices.
600    /// The lifetimes line up because everything is borrowed from locals
601    /// the test itself owns.
602    #[allow(clippy::too_many_arguments)]
603    fn ctx<'a>(
604        biscuit: Rect,
605        golden_rect: Rect,
606        play_area: Rect,
607        prestige_reset_rect: Rect,
608        fingerer_rows: &'a [(usize, Rect)],
609        upgrade_rows: &'a [(usize, Rect)],
610        help_hits: &'a [(HelpAction, Rect)],
611        debug: bool,
612        current: &'a GameState,
613    ) -> InputContext<'a> {
614        // Helper places `golden_rect` in the Lucky slot; tests targeting
615        // a different variant can mutate `golden_rects` after construction.
616        let mut golden_rects = [Rect::default(); 3];
617        golden_rects[GoldenVariant::Lucky as usize] = golden_rect;
618        InputContext {
619            fingerer_rows,
620            upgrade_rows,
621            help_hits,
622            biscuit_rect: biscuit,
623            biscuit_focal: (0, 0),
624            golden_rects,
625            green_coin_rect: Rect::default(),
626            play_area,
627            prestige_reset_rect,
628            debug,
629            current,
630        }
631    }
632
633    fn empty_ctx<'a>(state: &'a GameState) -> InputContext<'a> {
634        ctx(
635            Rect::default(),
636            Rect::default(),
637            Rect::default(),
638            Rect::default(),
639            &[],
640            &[],
641            &[],
642            false,
643            state,
644        )
645    }
646
647    /// State with a Lucky golden in its dedicated slot, so [g] /
648    /// golden-rect clicks have something to catch.
649    fn state_with_golden() -> GameState {
650        let mut g = golden::spawn_in(rect(10, 10, 20, 10));
651        g.variant = GoldenVariant::Lucky;
652        let mut s = GameState::default();
653        s.goldens[GoldenVariant::Lucky as usize] = Some(g);
654        s
655    }
656
657    /// State with enough lifetime cuques to make `prestige_available()` > 0,
658    /// so the prestige-reset confirm rect is "live".
659    fn state_with_prestige() -> GameState {
660        // prestige_available() square-roots `lifetime_cuques / 1e9` and
661        // floors. 4e9 → 2 prestige tokens.
662        GameState {
663            lifetime_cuques: 4_000_000_000.0,
664            ..GameState::default()
665        }
666    }
667
668    fn key(code: KeyCode) -> InputEvent {
669        InputEvent::KeyPress {
670            code,
671            mods: Modifiers::default(),
672        }
673    }
674
675    fn key_with(code: KeyCode, shift: bool, alt: bool, ctrl: bool) -> InputEvent {
676        InputEvent::KeyPress {
677            code,
678            mods: Modifiers { shift, alt, ctrl },
679        }
680    }
681
682    fn mouse_down(col: u16, row: u16, button: MouseButton, mods: Modifiers) -> InputEvent {
683        InputEvent::MouseDown {
684            col,
685            row,
686            button,
687            mods,
688        }
689    }
690
691    // -- Key handling ------------------------------------------------------
692
693    #[test]
694    fn q_key_flips_running_off() {
695        let s = GameState::default();
696        let mut ui = UiState::new();
697        let mut out = Vec::new();
698        process_input_event(key(KeyCode::Char('q')), &mut ui, &empty_ctx(&s), &mut out);
699        assert!(!ui.running);
700        assert!(out.is_empty());
701    }
702
703    #[test]
704    fn esc_from_game_is_noop() {
705        // J12: Esc from Game must not quit and must not change mode.
706        let s = GameState::default();
707        let mut ui = UiState::new();
708        let mut out = Vec::new();
709        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
710        assert!(ui.running);
711        assert_eq!(ui.mode, Mode::Game);
712        assert!(out.is_empty());
713    }
714
715    #[test]
716    fn esc_from_stats_returns_to_game() {
717        let s = GameState::default();
718        let mut ui = UiState::new();
719        ui.mode = Mode::Stats;
720        let mut out = Vec::new();
721        process_input_event(key(KeyCode::Esc), &mut ui, &empty_ctx(&s), &mut out);
722        assert_eq!(ui.mode, Mode::Game);
723        assert!(out.is_empty());
724    }
725
726    #[test]
727    fn s_key_toggles_stats() {
728        let s = GameState::default();
729        let mut ui = UiState::new();
730        let mut out = Vec::new();
731        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
732        assert_eq!(ui.mode, Mode::Stats);
733        process_input_event(key(KeyCode::Char('s')), &mut ui, &empty_ctx(&s), &mut out);
734        assert_eq!(ui.mode, Mode::Game);
735    }
736
737    #[test]
738    fn space_emits_click_center() {
739        let s = GameState::default();
740        let mut ui = UiState::new();
741        let mut out = Vec::new();
742        process_input_event(key(KeyCode::Char(' ')), &mut ui, &empty_ctx(&s), &mut out);
743        assert_eq!(out.len(), 1);
744        assert!(matches!(out[0], Action::ClickCenter));
745    }
746
747    #[test]
748    fn g_with_no_golden_is_silent() {
749        let s = GameState::default();
750        let mut ui = UiState::new();
751        let mut out = Vec::new();
752        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
753        assert!(out.is_empty());
754    }
755
756    #[test]
757    fn g_with_golden_emits_catch() {
758        let s = state_with_golden();
759        let mut ui = UiState::new();
760        let mut out = Vec::new();
761        process_input_event(key(KeyCode::Char('g')), &mut ui, &empty_ctx(&s), &mut out);
762        assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
763    }
764
765    #[test]
766    fn fkeys_gated_by_debug() {
767        let s = GameState::default();
768        let mut ui = UiState::new();
769        // debug=false → all F-keys silent.
770        let mut out = Vec::new();
771        let c = empty_ctx(&s);
772        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
773        process_input_event(key(KeyCode::F(2)), &mut ui, &c, &mut out);
774        process_input_event(key(KeyCode::F(3)), &mut ui, &c, &mut out);
775        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
776        assert!(out.is_empty(), "F-keys must be silent when debug=false");
777    }
778
779    #[test]
780    fn fkeys_active_when_debug() {
781        let s = GameState::default();
782        let mut ui = UiState::new();
783        let c = ctx(
784            Rect::default(),
785            Rect::default(),
786            Rect::default(),
787            Rect::default(),
788            &[],
789            &[],
790            &[],
791            true, // debug ON
792            &s,
793        );
794        let mut out = Vec::new();
795        process_input_event(key(KeyCode::F(8)), &mut ui, &c, &mut out);
796        process_input_event(key(KeyCode::F(4)), &mut ui, &c, &mut out);
797        assert!(matches!(
798            out[0],
799            Action::DevForceGolden(GoldenVariant::Lucky)
800        ));
801        assert!(matches!(out[1], Action::DevAddCuques(_)));
802    }
803
804    // -- Digit shortcuts: modifier→BuyQty ----------------------------------
805
806    fn fingerer_row_ctx<'a>(state: &'a GameState, rows: &'a [(usize, Rect)]) -> InputContext<'a> {
807        ctx(
808            Rect::default(),
809            Rect::default(),
810            Rect::default(),
811            Rect::default(),
812            rows,
813            &[],
814            &[],
815            false,
816            state,
817        )
818    }
819
820    #[test]
821    fn digit_1_buys_one() {
822        let s = GameState::default();
823        let mut ui = UiState::new();
824        let rows = [(0_usize, rect(0, 0, 1, 1))];
825        let mut out = Vec::new();
826        process_input_event(
827            key(KeyCode::Char('1')),
828            &mut ui,
829            &fingerer_row_ctx(&s, &rows),
830            &mut out,
831        );
832        assert!(matches!(
833            out.as_slice(),
834            [Action::BuyFingerer {
835                idx: 0,
836                qty: BuyQty::One,
837            }]
838        ));
839    }
840
841    #[test]
842    fn shifted_digit_symbol_buys_ten() {
843        // Terminals emit '!' for Shift+1 without a SHIFT modifier on macOS.
844        // The router must recognize '!' as the Shift-1 alias.
845        let s = GameState::default();
846        let mut ui = UiState::new();
847        let rows = [(0_usize, rect(0, 0, 1, 1))];
848        let mut out = Vec::new();
849        process_input_event(
850            key(KeyCode::Char('!')),
851            &mut ui,
852            &fingerer_row_ctx(&s, &rows),
853            &mut out,
854        );
855        assert!(matches!(
856            out.as_slice(),
857            [Action::BuyFingerer {
858                idx: 0,
859                qty: BuyQty::Ten,
860            }]
861        ));
862    }
863
864    #[test]
865    fn shift_modifier_on_digit_buys_ten() {
866        // Some keymaps emit '1' WITH the SHIFT modifier — also valid for ×10.
867        let s = GameState::default();
868        let mut ui = UiState::new();
869        let rows = [(0_usize, rect(0, 0, 1, 1))];
870        let mut out = Vec::new();
871        process_input_event(
872            key_with(KeyCode::Char('1'), true, false, false),
873            &mut ui,
874            &fingerer_row_ctx(&s, &rows),
875            &mut out,
876        );
877        assert!(matches!(
878            out.as_slice(),
879            [Action::BuyFingerer {
880                qty: BuyQty::Ten,
881                ..
882            }]
883        ));
884    }
885
886    #[test]
887    fn alt_or_ctrl_modifier_on_digit_buys_max() {
888        let s = GameState::default();
889        let rows = [(0_usize, rect(0, 0, 1, 1))];
890        for (alt, ctrl) in [(true, false), (false, true)] {
891            let mut ui = UiState::new();
892            let mut out = Vec::new();
893            process_input_event(
894                key_with(KeyCode::Char('1'), false, alt, ctrl),
895                &mut ui,
896                &fingerer_row_ctx(&s, &rows),
897                &mut out,
898            );
899            assert!(
900                matches!(
901                    out.as_slice(),
902                    [Action::BuyFingerer {
903                        qty: BuyQty::Max,
904                        ..
905                    }]
906                ),
907                "alt={alt} ctrl={ctrl} should buy max",
908            );
909        }
910    }
911
912    #[test]
913    fn digit_with_no_visible_row_is_silent() {
914        // Game mode but no fingerer_rows yet (cold frame): pressing 1 must
915        // not panic or emit an action.
916        let s = GameState::default();
917        let mut ui = UiState::new();
918        let mut out = Vec::new();
919        process_input_event(
920            key(KeyCode::Char('1')),
921            &mut ui,
922            &fingerer_row_ctx(&s, &[]),
923            &mut out,
924        );
925        assert!(out.is_empty());
926    }
927
928    // -- Mouse button semantics --------------------------------------------
929
930    #[test]
931    fn left_click_on_biscuit_emits_click() {
932        let s = GameState::default();
933        let mut ui = UiState::new();
934        let c = ctx(
935            rect(10, 5, 30, 20),
936            Rect::default(),
937            rect(0, 0, 100, 30),
938            Rect::default(),
939            &[],
940            &[],
941            &[],
942            false,
943            &s,
944        );
945        let mut out = Vec::new();
946        process_input_event(
947            mouse_down(20, 10, MouseButton::Left, Modifiers::default()),
948            &mut ui,
949            &c,
950            &mut out,
951        );
952        assert!(
953            matches!(out.as_slice(), [Action::Click { col: 20, row: 10 }]),
954            "got {:?}",
955            out
956        );
957    }
958
959    #[test]
960    fn right_click_on_biscuit_is_noop() {
961        // Right-click on the biscuit must not finger the cuque (avoids
962        // accidental clicks). Specifically: no Click action, no misclick.
963        let s = GameState::default();
964        let mut ui = UiState::new();
965        let c = ctx(
966            rect(10, 5, 30, 20),
967            Rect::default(),
968            rect(0, 0, 100, 30),
969            Rect::default(),
970            &[],
971            &[],
972            &[],
973            false,
974            &s,
975        );
976        let mut out = Vec::new();
977        process_input_event(
978            mouse_down(20, 10, MouseButton::Right, Modifiers::default()),
979            &mut ui,
980            &c,
981            &mut out,
982        );
983        assert!(out.is_empty(), "got {:?}", out);
984    }
985
986    #[test]
987    fn left_click_on_golden_emits_catch() {
988        let s = state_with_golden();
989        let mut ui = UiState::new();
990        let c = ctx(
991            Rect::default(),
992            rect(50, 12, 4, 2),
993            rect(0, 0, 100, 30),
994            Rect::default(),
995            &[],
996            &[],
997            &[],
998            false,
999            &s,
1000        );
1001        let mut out = Vec::new();
1002        process_input_event(
1003            mouse_down(51, 13, MouseButton::Left, Modifiers::default()),
1004            &mut ui,
1005            &c,
1006            &mut out,
1007        );
1008        assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
1009    }
1010
1011    #[test]
1012    fn left_click_on_green_coin_emits_green_catch_only() {
1013        // Regression: clicking the green-coin marker used to emit
1014        // `Action::CatchGolden`, which would catch BOTH a Golden and a
1015        // Green Coin if both were on screen. They're separate now.
1016        let mut s = state_with_golden();
1017        s.green_coin = Some(crate::game::green_coin::GreenCoin {
1018            frac_x: 0.5,
1019            frac_y: 0.5,
1020            life_ticks: 100,
1021        });
1022        let mut ui = UiState::new();
1023        let mut c = ctx(
1024            Rect::default(),
1025            rect(50, 12, 4, 2),
1026            rect(0, 0, 100, 30),
1027            Rect::default(),
1028            &[],
1029            &[],
1030            &[],
1031            false,
1032            &s,
1033        );
1034        c.green_coin_rect = rect(70, 12, 5, 3);
1035        let mut out = Vec::new();
1036        process_input_event(
1037            mouse_down(72, 13, MouseButton::Left, Modifiers::default()),
1038            &mut ui,
1039            &c,
1040            &mut out,
1041        );
1042        assert!(matches!(out.as_slice(), [Action::CatchGreenCoin]));
1043    }
1044
1045    #[test]
1046    fn g_with_both_powerups_picks_lower_life_ticks() {
1047        // [g] should grab the most-urgent powerup first when both are on
1048        // screen, then a follow-up [g] press grabs the leftover. Tie
1049        // (g_life == c_life) goes to the regular Golden as a tiebreak.
1050        let mut s = state_with_golden();
1051        // Pin life_ticks so we control the ordering.
1052        if let Some(g) = s.goldens[GoldenVariant::Lucky as usize].as_mut() {
1053            g.life_ticks = 50;
1054        }
1055        s.green_coin = Some(crate::game::green_coin::GreenCoin {
1056            frac_x: 0.5,
1057            frac_y: 0.5,
1058            life_ticks: 200,
1059        });
1060        let c = empty_ctx(&s);
1061        let mut ui = UiState::new();
1062        let mut out = Vec::new();
1063        process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1064        // Golden has 50 ticks left, green has 200 — Golden is more urgent.
1065        assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
1066
1067        // Flip the lifetimes — green is now the urgent one.
1068        let mut s = state_with_golden();
1069        if let Some(g) = s.goldens[GoldenVariant::Lucky as usize].as_mut() {
1070            g.life_ticks = 200;
1071        }
1072        s.green_coin = Some(crate::game::green_coin::GreenCoin {
1073            frac_x: 0.5,
1074            frac_y: 0.5,
1075            life_ticks: 50,
1076        });
1077        let c = empty_ctx(&s);
1078        let mut out = Vec::new();
1079        process_input_event(key(KeyCode::Char('g')), &mut ui, &c, &mut out);
1080        assert!(matches!(out.as_slice(), [Action::CatchGreenCoin]));
1081    }
1082
1083    #[test]
1084    fn right_click_on_golden_also_catches() {
1085        // The marker is small and reflex right-clicks shouldn't waste it.
1086        let s = state_with_golden();
1087        let mut ui = UiState::new();
1088        let c = ctx(
1089            Rect::default(),
1090            rect(50, 12, 4, 2),
1091            rect(0, 0, 100, 30),
1092            Rect::default(),
1093            &[],
1094            &[],
1095            &[],
1096            false,
1097            &s,
1098        );
1099        let mut out = Vec::new();
1100        process_input_event(
1101            mouse_down(51, 13, MouseButton::Right, Modifiers::default()),
1102            &mut ui,
1103            &c,
1104            &mut out,
1105        );
1106        assert!(matches!(out.as_slice(), [Action::CatchGolden(_)]));
1107    }
1108
1109    #[test]
1110    fn left_click_on_fingerer_row_buys_one() {
1111        let s = GameState::default();
1112        let mut ui = UiState::new();
1113        let rows = [(2_usize, rect(100, 5, 38, 3))];
1114        let c = ctx(
1115            Rect::default(),
1116            Rect::default(),
1117            Rect::default(),
1118            Rect::default(),
1119            &rows,
1120            &[],
1121            &[],
1122            false,
1123            &s,
1124        );
1125        let mut out = Vec::new();
1126        process_input_event(
1127            mouse_down(110, 6, MouseButton::Left, Modifiers::default()),
1128            &mut ui,
1129            &c,
1130            &mut out,
1131        );
1132        assert!(matches!(
1133            out.as_slice(),
1134            [Action::BuyFingerer {
1135                idx: 2,
1136                qty: BuyQty::One,
1137            }]
1138        ));
1139    }
1140
1141    #[test]
1142    fn right_click_on_fingerer_row_buys_max() {
1143        // J15: right-click is the always-Max affordance (modifiers ignored).
1144        let s = GameState::default();
1145        let mut ui = UiState::new();
1146        let rows = [(2_usize, rect(100, 5, 38, 3))];
1147        let c = ctx(
1148            Rect::default(),
1149            Rect::default(),
1150            Rect::default(),
1151            Rect::default(),
1152            &rows,
1153            &[],
1154            &[],
1155            false,
1156            &s,
1157        );
1158        let mut out = Vec::new();
1159        process_input_event(
1160            mouse_down(110, 6, MouseButton::Right, Modifiers::default()),
1161            &mut ui,
1162            &c,
1163            &mut out,
1164        );
1165        assert!(matches!(
1166            out.as_slice(),
1167            [Action::BuyFingerer {
1168                qty: BuyQty::Max,
1169                ..
1170            }]
1171        ));
1172    }
1173
1174    #[test]
1175    fn shift_left_click_on_fingerer_row_buys_ten() {
1176        let s = GameState::default();
1177        let mut ui = UiState::new();
1178        let rows = [(2_usize, rect(100, 5, 38, 3))];
1179        let c = ctx(
1180            Rect::default(),
1181            Rect::default(),
1182            Rect::default(),
1183            Rect::default(),
1184            &rows,
1185            &[],
1186            &[],
1187            false,
1188            &s,
1189        );
1190        let mut out = Vec::new();
1191        let mods = Modifiers {
1192            shift: true,
1193            ..Modifiers::default()
1194        };
1195        process_input_event(
1196            mouse_down(110, 6, MouseButton::Left, mods),
1197            &mut ui,
1198            &c,
1199            &mut out,
1200        );
1201        assert!(matches!(
1202            out.as_slice(),
1203            [Action::BuyFingerer {
1204                qty: BuyQty::Ten,
1205                ..
1206            }]
1207        ));
1208    }
1209
1210    // -- Misclick gating ---------------------------------------------------
1211
1212    #[test]
1213    fn dead_zone_left_click_inside_play_area_emits_misclick() {
1214        // Click in the empty space of the play area (not biscuit, not row).
1215        let s = GameState::default();
1216        let mut ui = UiState::new();
1217        let c = ctx(
1218            rect(40, 10, 20, 10), // biscuit far from click
1219            Rect::default(),
1220            rect(0, 0, 100, 30), // play_area covers the click
1221            Rect::default(),
1222            &[],
1223            &[],
1224            &[],
1225            false,
1226            &s,
1227        );
1228        let mut out = Vec::new();
1229        process_input_event(
1230            mouse_down(5, 5, MouseButton::Left, Modifiers::default()),
1231            &mut ui,
1232            &c,
1233            &mut out,
1234        );
1235        assert!(
1236            matches!(out.as_slice(), [Action::Misclick { col: 5, row: 5 }]),
1237            "got {:?}",
1238            out
1239        );
1240    }
1241
1242    #[test]
1243    fn click_outside_play_area_does_not_misclick() {
1244        // M3: clicks on inert UI chrome (HUD title, sidebar, debug pane)
1245        // must NOT spawn a misclick particle.
1246        let s = GameState::default();
1247        let mut ui = UiState::new();
1248        let c = ctx(
1249            rect(40, 10, 20, 10),
1250            Rect::default(),
1251            rect(0, 0, 100, 30), // play area capped at col 100
1252            Rect::default(),
1253            &[],
1254            &[],
1255            &[],
1256            false,
1257            &s,
1258        );
1259        let mut out = Vec::new();
1260        process_input_event(
1261            mouse_down(120, 5, MouseButton::Left, Modifiers::default()),
1262            &mut ui,
1263            &c,
1264            &mut out,
1265        );
1266        assert!(out.is_empty(), "got {:?}", out);
1267    }
1268
1269    #[test]
1270    fn right_click_in_dead_zone_is_silent() {
1271        // Right-click on nothing actionable is a true no-op (no misclick ack).
1272        let s = GameState::default();
1273        let mut ui = UiState::new();
1274        let c = ctx(
1275            rect(40, 10, 20, 10),
1276            Rect::default(),
1277            rect(0, 0, 100, 30),
1278            Rect::default(),
1279            &[],
1280            &[],
1281            &[],
1282            false,
1283            &s,
1284        );
1285        let mut out = Vec::new();
1286        process_input_event(
1287            mouse_down(5, 5, MouseButton::Right, Modifiers::default()),
1288            &mut ui,
1289            &c,
1290            &mut out,
1291        );
1292        assert!(out.is_empty());
1293    }
1294
1295    // -- Help-bar clickable hints ------------------------------------------
1296
1297    #[test]
1298    fn click_quit_hint_flips_running() {
1299        let s = GameState::default();
1300        let mut ui = UiState::new();
1301        let hits = [(HelpAction::Quit, rect(50, 29, 8, 1))];
1302        let c = ctx(
1303            Rect::default(),
1304            Rect::default(),
1305            rect(0, 0, 100, 30),
1306            Rect::default(),
1307            &[],
1308            &[],
1309            &hits,
1310            false,
1311            &s,
1312        );
1313        let mut out = Vec::new();
1314        process_input_event(
1315            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1316            &mut ui,
1317            &c,
1318            &mut out,
1319        );
1320        assert!(!ui.running);
1321        assert!(out.is_empty(), "Quit is UI-only, no Action emitted");
1322    }
1323
1324    #[test]
1325    fn click_open_mode_hint_toggles_mode() {
1326        let s = GameState::default();
1327        let mut ui = UiState::new();
1328        let hits = [(HelpAction::OpenMode(Mode::Stats), rect(50, 29, 8, 1))];
1329        let c = ctx(
1330            Rect::default(),
1331            Rect::default(),
1332            rect(0, 0, 100, 30),
1333            Rect::default(),
1334            &[],
1335            &[],
1336            &hits,
1337            false,
1338            &s,
1339        );
1340        let mut out = Vec::new();
1341        process_input_event(
1342            mouse_down(53, 29, MouseButton::Left, Modifiers::default()),
1343            &mut ui,
1344            &c,
1345            &mut out,
1346        );
1347        assert_eq!(ui.mode, Mode::Stats);
1348    }
1349
1350    // -- Prestige reset rect -----------------------------------------------
1351
1352    #[test]
1353    fn prestige_reset_rect_unavailable_does_not_reset() {
1354        // With prestige_available() == 0, clicking the confirm rect must
1355        // NOT produce a PrestigeReset action. The click can still fall
1356        // through to a Misclick if it's in the play area (that's by
1357        // design — it's a dead-zone click) but never to a reset.
1358        let s = GameState::default(); // prestige_available() = 0
1359        let mut ui = UiState::new();
1360        let c = ctx(
1361            Rect::default(),
1362            Rect::default(),
1363            rect(0, 0, 100, 30),
1364            rect(40, 15, 30, 1), // prestige_reset_rect at this position
1365            &[],
1366            &[],
1367            &[],
1368            false,
1369            &s,
1370        );
1371        let mut out = Vec::new();
1372        process_input_event(
1373            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1374            &mut ui,
1375            &c,
1376            &mut out,
1377        );
1378        assert!(
1379            !out.iter().any(|a| matches!(a, Action::PrestigeReset)),
1380            "no PrestigeReset when unavailable; got {:?}",
1381            out
1382        );
1383        assert_eq!(ui.mode, Mode::Game, "mode unchanged from default Game");
1384    }
1385
1386    #[test]
1387    fn prestige_reset_rect_available_emits_action() {
1388        let s = state_with_prestige();
1389        let mut ui = UiState::new();
1390        ui.mode = Mode::Prestige;
1391        let c = ctx(
1392            Rect::default(),
1393            Rect::default(),
1394            rect(0, 0, 100, 30),
1395            rect(40, 15, 30, 1),
1396            &[],
1397            &[],
1398            &[],
1399            false,
1400            &s,
1401        );
1402        let mut out = Vec::new();
1403        process_input_event(
1404            mouse_down(50, 15, MouseButton::Left, Modifiers::default()),
1405            &mut ui,
1406            &c,
1407            &mut out,
1408        );
1409        assert_eq!(out.len(), 1);
1410        assert_eq!(discriminant(&out[0]), discriminant(&Action::PrestigeReset),);
1411        assert_eq!(
1412            ui.mode,
1413            Mode::Game,
1414            "panel auto-closes after prestige confirm",
1415        );
1416    }
1417
1418    // -- Wheel zoom --------------------------------------------------------
1419
1420    #[test]
1421    fn wheel_down_inside_play_area_increases_zoom_idx() {
1422        let s = GameState::default();
1423        let mut ui = UiState::new();
1424        let c = ctx(
1425            Rect::default(),
1426            Rect::default(),
1427            rect(0, 0, 100, 30),
1428            Rect::default(),
1429            &[],
1430            &[],
1431            &[],
1432            false,
1433            &s,
1434        );
1435        let mut out = Vec::new();
1436        process_input_event(
1437            InputEvent::Wheel {
1438                col: 50,
1439                row: 15,
1440                delta: WheelDelta::Down,
1441            },
1442            &mut ui,
1443            &c,
1444            &mut out,
1445        );
1446        assert_eq!(ui.zoom_idx, 1);
1447    }
1448
1449    #[test]
1450    fn wheel_outside_play_area_does_not_zoom() {
1451        // Wheel events on the right-hand sidebar must not zoom the biscuit.
1452        let s = GameState::default();
1453        let mut ui = UiState::new();
1454        let c = ctx(
1455            Rect::default(),
1456            Rect::default(),
1457            rect(0, 0, 100, 30),
1458            Rect::default(),
1459            &[],
1460            &[],
1461            &[],
1462            false,
1463            &s,
1464        );
1465        let mut out = Vec::new();
1466        process_input_event(
1467            InputEvent::Wheel {
1468                col: 120,
1469                row: 10,
1470                delta: WheelDelta::Down,
1471            },
1472            &mut ui,
1473            &c,
1474            &mut out,
1475        );
1476        assert_eq!(ui.zoom_idx, 0);
1477    }
1478
1479    #[test]
1480    fn wheel_up_saturates_at_zero() {
1481        let s = GameState::default();
1482        let mut ui = UiState::new();
1483        ui.zoom_idx = 0;
1484        let c = ctx(
1485            Rect::default(),
1486            Rect::default(),
1487            rect(0, 0, 100, 30),
1488            Rect::default(),
1489            &[],
1490            &[],
1491            &[],
1492            false,
1493            &s,
1494        );
1495        let mut out = Vec::new();
1496        process_input_event(
1497            InputEvent::Wheel {
1498                col: 50,
1499                row: 15,
1500                delta: WheelDelta::Up,
1501            },
1502            &mut ui,
1503            &c,
1504            &mut out,
1505        );
1506        assert_eq!(ui.zoom_idx, 0, "saturating_sub at 0 stays 0");
1507    }
1508
1509    #[test]
1510    fn wheel_down_caps_at_last_level() {
1511        let s = GameState::default();
1512        let mut ui = UiState::new();
1513        let last = crate::ui::biscuit::level_count() - 1;
1514        ui.zoom_idx = last;
1515        let c = ctx(
1516            Rect::default(),
1517            Rect::default(),
1518            rect(0, 0, 100, 30),
1519            Rect::default(),
1520            &[],
1521            &[],
1522            &[],
1523            false,
1524            &s,
1525        );
1526        let mut out = Vec::new();
1527        process_input_event(
1528            InputEvent::Wheel {
1529                col: 50,
1530                row: 15,
1531                delta: WheelDelta::Down,
1532            },
1533            &mut ui,
1534            &c,
1535            &mut out,
1536        );
1537        assert_eq!(ui.zoom_idx, last, "min() cap at last level");
1538    }
1539
1540    // -- Mouse position tracking -------------------------------------------
1541
1542    #[test]
1543    fn mouse_moved_updates_last_position() {
1544        let s = GameState::default();
1545        let mut ui = UiState::new();
1546        let mut out = Vec::new();
1547        process_input_event(
1548            InputEvent::MouseMoved { col: 42, row: 7 },
1549            &mut ui,
1550            &empty_ctx(&s),
1551            &mut out,
1552        );
1553        assert_eq!(ui.last_mouse_pos, Some((42, 7)));
1554        assert!(out.is_empty());
1555    }
1556
1557    #[test]
1558    fn mouse_down_updates_last_position_before_dispatch() {
1559        // The hover renderer reads `last_mouse_pos` next frame; a click
1560        // should leave the cursor "where it landed" so the row beneath the
1561        // click is highlighted.
1562        let s = GameState::default();
1563        let mut ui = UiState::new();
1564        let c = ctx(
1565            rect(40, 10, 20, 10),
1566            Rect::default(),
1567            rect(0, 0, 100, 30),
1568            Rect::default(),
1569            &[],
1570            &[],
1571            &[],
1572            false,
1573            &s,
1574        );
1575        let mut out = Vec::new();
1576        process_input_event(
1577            mouse_down(7, 7, MouseButton::Left, Modifiers::default()),
1578            &mut ui,
1579            &c,
1580            &mut out,
1581        );
1582        assert_eq!(ui.last_mouse_pos, Some((7, 7)));
1583    }
1584}