cuqueclicker 1.2.1

A TUI idle clicker where you finger an ASCII ass instead of clicking a cookie.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
pub mod achievements;
pub mod biscuit;
pub mod border;
pub mod debug_pane;
pub mod effects;
pub mod hands;
pub mod prestige;
pub mod sidebar;
pub mod stats;
pub mod toast;
pub mod tree;

use ratatui::{prelude::*, widgets::*};

use crate::format;
use crate::game::state::{Buff, GameState, HUD_FLASH_TICKS, TICK_HZ};
use crate::i18n::t;

// Hardcoded as "0.0.0" in source; release.yml patches Cargo.toml before
// building so CARGO_PKG_VERSION reflects the real version in shipped
// binaries. A 0.0.0 build advertises itself as "(dev)" in the HUD.
const VERSION: &str = env!("CARGO_PKG_VERSION");

pub(crate) fn hud_title() -> String {
    if VERSION == "0.0.0" {
        // Dev builds include the git branch (or short SHA on detached HEAD)
        // so two instances built from different branches can be told apart
        // at a glance — useful for side-by-side comparison.
        match crate::build_info::GIT_BRANCH {
            Some(branch) => format!(" CuqueClicker v0.0.0 (dev, {branch}) "),
            None => " CuqueClicker v0.0.0 (dev) ".into(),
        }
    } else {
        format!(" CuqueClicker v{VERSION} ")
    }
}

#[derive(Copy, Clone, PartialEq, Eq, Debug)]
pub enum Mode {
    Game,
    Stats,
    Achievements,
    /// The infinite procedural upgrade tree — full-screen modal. Game ticks
    /// keep running underneath; powerup spawns pause.
    Tree,
    Prestige,
}

/// Which action a clickable button in the tree-modal info pane fires.
/// `Buy` / `Refund` both target the cursor's lot; the renderer publishes
/// at most one of these per frame (whichever the focused node currently
/// supports). Left-click on the rect emits the corresponding sim
/// action, giving touch / left-click-only players parity with the
/// right-click-on-node gesture.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum TreeButtonAction {
    Buy,
    Refund,
}

/// Click target for a help-bar hint or for the prestige-reset confirm
/// line. Mirrors the keyboard shortcuts so the mouse-first player has
/// equivalent reach to every action a key would fire.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum HelpAction {
    /// Open the named mode (or close it back to Game if already there).
    OpenMode(Mode),
    /// Catch whatever golden is on screen.
    GrabGolden,
    /// Quit the program.
    Quit,
    /// Tree modal: jump the cursor to the origin (the cuque anchor).
    TreeFocusOrigin,
    /// Tree modal: jump the cursor to the last bought node, or to the
    /// origin if the player hasn't bought anything yet this run.
    TreeFocusLastBought,
}

/// Per-frame layout snapshot produced by [`draw`]. Single source of truth
/// for every clickable region on screen + the play-area envelope.
///
/// The platform shells (`app.rs`, `wasm_app.rs`) **store this verbatim**
/// and call [`crate::input::InputContext::from_layout`] to project it
/// into the per-event input context. Adding a new clickable region only
/// touches this struct + `InputContext` + the projection — never the
/// platform code.
#[derive(Default)]
pub struct DrawOutput {
    pub biscuit_rect: Rect,
    /// Screen position of the biscuit's focal cell ("the asshole"). Each
    /// zoom level's art has the focal at a slightly different offset
    /// inside its bounding box (TINY: col 7 of width 16, FULL: col 31 of
    /// width 60, etc.), so the bbox center isn't the visual center. This
    /// drives `hands::draw`'s orbit center.
    pub biscuit_focal: (u16, u16),
    /// `(spawn_id, screen_rect)` for every on-screen powerup, in render
    /// order. Click hit-test and the `g` hotkey BOTH reference instances
    /// by `spawn_id` (not by Vec index) so a `swap_remove` on catch is
    /// safe even when multiple events hold layout snapshots between
    /// frames. Empty when no powerups are visible.
    pub powerup_rects: Vec<(u64, Rect)>,
    /// The whole left column where the biscuit + hands + particles live —
    /// i.e. "the box that displays the ass." Used by the input router so
    /// the scroll-wheel zoom fires anywhere in this region (including the
    /// vast empty space around a small biscuit at low zoom), and only the
    /// right-hand sidebar opts out of zoom.
    pub play_area: Rect,
    /// Per-tree-node clickable rects when the Tree mode is active. Each
    /// entry maps a node's lot coord to its on-screen box. The input
    /// router walks these to translate mouse coords into `Action::TreeBuy`
    /// / `Action::TreeFocus`. Empty when not in Tree mode.
    pub tree_node_rects: Vec<(crate::game::tree::coord::TreeCoord, Rect)>,
    /// Left-click target for the buy/refund text in the tree-modal info
    /// pane. `Some` only when the focused node is currently actionable
    /// (buyable + affordable, or owned + refundable). Lets touch /
    /// left-only players trigger the action without right-clicking.
    pub tree_action_button: Option<(TreeButtonAction, Rect, crate::game::tree::coord::TreeCoord)>,
    /// `(fingerer_idx, screen_row_rect)` for the Game-mode sidebar.
    pub fingerer_rows: Vec<(usize, Rect)>,
    /// (action, rect) for every clickable help-bar hint at the bottom of
    /// the play column. Mouse-first players use these to switch panels,
    /// catch goldens, prestige-reset, and quit — all of which used to be
    /// keyboard-only. Empty rects when the hint is non-actionable
    /// (e.g. `[Space/Click] finger` is informational, not a click target).
    pub help_hits: Vec<(HelpAction, Rect)>,
    /// Click rect for the `Press [r] to reset and claim` line in the
    /// Prestige panel — flips the player into the confirm-pending state.
    /// Default when not in Prestige mode, prestige unavailable, or
    /// already mid-confirmation.
    pub prestige_reset_rect: Rect,
    /// Click rect for the Yes / No buttons in the prestige-reset
    /// confirmation block. Both default unless `prestige_confirm_pending`
    /// is set on `UiState` and prestige is available.
    pub prestige_confirm_yes_rect: Rect,
    pub prestige_confirm_no_rect: Rect,
}

fn wrapped_height(text: &str, width: u16) -> u16 {
    if width == 0 {
        return text.lines().count().max(1) as u16;
    }
    let mut total: u16 = 0;
    for line in text.split('\n') {
        let mut row_len: u16 = 0;
        let mut rows: u16 = 1;
        for word in line.split_whitespace() {
            let wlen = word.chars().count() as u16;
            if row_len == 0 {
                row_len = wlen.min(width);
            } else if row_len + 1 + wlen <= width {
                row_len += 1 + wlen;
            } else {
                rows += 1;
                row_len = wlen.min(width);
            }
        }
        total = total.saturating_add(rows);
    }
    total.max(1)
}

fn draw_zoom_indicator(frame: &mut Frame, area: Rect, label: &str) {
    let text = format!("zoom {}", label);
    let w = text.chars().count() as u16;
    if area.width < w || area.height == 0 {
        return;
    }
    let col = area.x + area.width - w;
    let row = area.y + area.height - 1;
    let buf = frame.buffer_mut();
    buf.set_string(
        col,
        row,
        &text,
        Style::default().fg(Color::Rgb(120, 120, 120)),
    );
}

#[allow(clippy::too_many_arguments)]
pub fn draw(
    frame: &mut Frame,
    state: &GameState,
    mode: Mode,
    zoom_idx: usize,
    debug: bool,
    mouse_pos: Option<(u16, u16)>,
    tree_render: &mut crate::input::TreeRenderState,
    prestige_confirm_pending: bool,
) -> DrawOutput {
    let lang = t();
    let area = frame.area();
    let cols = Layout::horizontal([Constraint::Min(1), Constraint::Length(38)]).split(area);

    let help_text = match mode {
        Mode::Game => lang.help_game,
        Mode::Stats => lang.help_stats,
        Mode::Achievements => lang.help_ach,
        Mode::Tree => lang.help_tree,
        Mode::Prestige => lang.help_prestige,
    };
    let help_height = wrapped_height(help_text, cols[0].width).max(1);
    let left = Layout::vertical([
        Constraint::Length(3),
        Constraint::Min(1),
        Constraint::Length(help_height),
    ])
    .split(cols[0]);

    // J5 count-up: render the smoothed `displayed_*` values rather than the
    // raw current values. Big jumps (golden, max-buy, F4) ease in instead of
    // snapping. Tween itself runs in `state.tick()`.
    //
    // Color sweep: TWO competing channels — green for cuques going UP
    // (income, golden, F4), red for cuques going DOWN (purchase,
    // prestige reset). Whichever channel is stronger this frame drives
    // the lerp toward white. So a buy that lands during a still-decaying
    // gain flash correctly flips the digits red as the spend channel
    // overtakes the fading gain. Both lerp toward bright white at t=0,
    // which matches the resting (no-flash) style — no hard cut.
    let gain_t = (state.cuques_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
    let spend_t = (state.cuques_spend_flash_ticks as f32 / HUD_FLASH_TICKS as f32).clamp(0.0, 1.0);
    const FLASH_GAIN: (f32, f32, f32) = (80.0, 255.0, 80.0); // bright green
    const FLASH_SPEND: (f32, f32, f32) = (255.0, 90.0, 90.0); // urgent red
    const FLASH_REST: (f32, f32, f32) = (255.0, 255.0, 255.0);
    let (peak, t) = if spend_t > gain_t {
        (FLASH_SPEND, spend_t)
    } else {
        (FLASH_GAIN, gain_t)
    };
    let mix = 1.0 - t;
    let r = peak.0 + (FLASH_REST.0 - peak.0) * mix;
    let g = peak.1 + (FLASH_REST.1 - peak.1) * mix;
    let b = peak.2 + (FLASH_REST.2 - peak.2) * mix;
    let cuques_style = Style::default()
        .fg(Color::Rgb(
            r.clamp(0.0, 255.0) as u8,
            g.clamp(0.0, 255.0) as u8,
            b.clamp(0.0, 255.0) as u8,
        ))
        .add_modifier(Modifier::BOLD);
    let mut hud_spans: Vec<Span> = vec![
        Span::raw(format!("{}: ", lang.hud_cuques)),
        Span::styled(format::big_mag(state.displayed_cuques), cuques_style),
        Span::raw(format!(
            "   {}: {}",
            lang.hud_fps,
            format::rate(state.displayed_fps)
        )),
    ];
    if state.prestige > 0 {
        hud_spans.push(Span::styled(
            format!(
                "   {}: {} (+{:.0}%)",
                lang.prestige_title.trim(),
                state.prestige,
                state.prestige as f64
            ),
            Style::default()
                .fg(Color::Rgb(255, 215, 0))
                .add_modifier(Modifier::BOLD),
        ));
    }
    for b in &state.buffs {
        let secs = b.ticks_remaining().div_ceil(TICK_HZ);
        let (label, color) = match b {
            // The legacy `mult` field is no longer the actual click
            // multiplier (per-click yield is FPS-scaled now); just label
            // the buff and show its remaining time. Cleaner than showing
            // a number that doesn't reflect the real bonus.
            Buff::ClickFrenzy { .. } => {
                (format!("  [!! FRENZY {}s]", secs), Color::Rgb(255, 80, 80))
            }
        };
        hud_spans.push(Span::styled(
            label,
            Style::default().fg(color).add_modifier(Modifier::BOLD),
        ));
    }
    // Active timed per-fingerer modifiers — Purple Coin Buff golden today,
    // anything else timed in the future. Phase 5 of #21 will replace this
    // with a dedicated HUD strip; for now we mirror the legacy chip layout
    // so UX continuity holds across phases.
    for (id, st) in &state.fingerers_state {
        for m in &st.modifiers {
            let crate::game::modifier::ModifierDuration::Ticks(remaining) = m.duration else {
                continue;
            };
            let secs = remaining.div_ceil(TICK_HZ);
            let idx = crate::game::fingerer::FINGERERS
                .iter()
                .position(|f| f.id == id);
            let name = idx
                .and_then(|i| lang.fingerer_names.get(i).copied())
                .unwrap_or("?");
            // Pick a number to show: prefer the strongest single MulFactor
            // effect (matches the old "x7" presentation); fall back to a
            // count-of-effects marker for purely additive sources.
            let mul = m.effects.iter().find_map(|e| match e {
                crate::game::modifier::ModifierEffect::MulFactor(v) => Some(*v),
                _ => None,
            });
            let label = match mul {
                Some(v) => format!("  [++ {} x{} {}s]", name, v.floor_u64(), secs),
                None => format!("  [++ {} {}s]", name, secs),
            };
            let color = match m.source {
                crate::game::modifier::ModifierSource::PurpleCoin => Color::Rgb(220, 140, 255),
                crate::game::modifier::ModifierSource::GreenCoin => Color::Rgb(120, 230, 140),
            };
            hud_spans.push(Span::styled(
                label,
                Style::default().fg(color).add_modifier(Modifier::BOLD),
            ));
        }
    }
    let title = hud_title();
    border::draw_animated(frame, left[0], state, &title);
    let hud_inner = Rect {
        x: left[0].x + 1,
        y: left[0].y + 1,
        width: left[0].width.saturating_sub(2),
        height: left[0].height.saturating_sub(2),
    };
    let hud = Paragraph::new(Line::from(hud_spans));
    frame.render_widget(hud, hud_inner);

    let biscuit_rect = biscuit::draw(frame, left[1], state, zoom_idx);
    let biscuit_focal = biscuit::focal_point(zoom_idx, biscuit_rect);
    hands::draw(frame, left[1], biscuit_rect, biscuit_focal, state);
    effects::draw_particles(frame, biscuit_rect, &state.particles);
    effects::draw_misclicks(frame, &state.misclick_particles);
    if mode != Mode::Tree {
        draw_zoom_indicator(
            frame,
            left[1],
            biscuit::level_label(zoom_idx).unwrap_or("100%"),
        );
    }

    // Skip debug pane and biscuit-zoom indicator in tree mode — the modal
    // covers them but the zoom label specifically renders at the very
    // bottom row of `left[1]` and pokes through. The biscuit underneath
    // also has no business affecting render once we're in tree mode.
    if debug && mode != Mode::Tree {
        debug_pane::draw(frame, left[1]);
    }
    // Render every on-screen powerup; collect (spawn_id, rect) pairs so
    // hit-testing remains stable across catches/swap_removes. Order is
    // render order (Vec order); the click router walks the list and
    // routes the first match, which is fine because the dispersion
    // helper keeps positions distinct.
    let mut powerup_rects: Vec<(u64, Rect)> = Vec::with_capacity(state.powerups.len());
    for p in &state.powerups {
        let r = biscuit::draw_powerup(frame, p, biscuit_rect);
        powerup_rects.push((p.spawn_id, r));
    }

    // J1: achievement toast overlay. Lives in `left[1]` (biscuit/main area)
    // so it covers nothing important on the right; auto-dismisses after
    // TOAST_TICKS via the sim. We render *after* biscuit/powerups so it
    // always sits on top.
    toast::draw(frame, left[1], state);

    // Custom help-bar render: lay out `[X] label` tokens left-to-right,
    // wrapping at the rect width, paint each with mode-aware styling
    // (active mode bolded), and return per-token click rects so the
    // mouse-first player can drive the game without ever touching a key.
    let help_hits = draw_help(frame, left[2], help_text, mode, mouse_pos);

    let mut tree_node_rects: Vec<(crate::game::tree::coord::TreeCoord, Rect)> = Vec::new();
    let mut tree_action_button: Option<(
        TreeButtonAction,
        Rect,
        crate::game::tree::coord::TreeCoord,
    )> = None;
    let mut fingerer_rows: Vec<(usize, Rect)> = Vec::new();
    let mut prestige_reset_rect = Rect::default();
    let mut prestige_confirm_yes_rect = Rect::default();
    let mut prestige_confirm_no_rect = Rect::default();
    match mode {
        Mode::Game => fingerer_rows = sidebar::draw(frame, cols[1], state, mouse_pos),
        Mode::Stats => stats::draw(frame, cols[1], state),
        Mode::Achievements => achievements::draw(frame, cols[1], state),
        Mode::Tree => {
            // Full-screen modal — the tree renderer takes the WHOLE frame
            // area, not just the sidebar column, so the player gets the
            // full canvas to pan around. Pass the live `help_height` so
            // the modal covers exactly down to the help bar; hardcoding
            // a `2` here used to leak a one-row strip of biscuit when
            // the help text wrapped to a single row.
            let out = tree::draw(frame, area, state, mouse_pos, tree_render, help_height);
            tree_node_rects = out.node_rects;
            tree_action_button = out.action_button;
        }
        Mode::Prestige => {
            let rects = prestige::draw(frame, cols[1], state, mouse_pos, prestige_confirm_pending);
            prestige_reset_rect = rects.reset;
            prestige_confirm_yes_rect = rects.yes;
            prestige_confirm_no_rect = rects.no;
        }
    }

    DrawOutput {
        biscuit_rect,
        biscuit_focal,
        powerup_rects,
        play_area: left[1],
        tree_node_rects,
        tree_action_button,
        fingerer_rows,
        help_hits,
        prestige_reset_rect,
        prestige_confirm_yes_rect,
        prestige_confirm_no_rect,
    }
}

/// Custom help-bar renderer.
///
/// Splits the help string into "tokens" (each token is a contiguous
/// non-whitespace run of `[X] label words`, separated from the next by
/// a double space or a newline — the convention used in `i18n::Lang`'s
/// help strings). Each token is laid out left-to-right with wrap at
/// the rect's width, painted at the resolved screen position, and
/// matched against a (mode, key) → action table. Clickable tokens get
/// a slightly brighter color and BOLD; the token under the mouse
/// cursor gets an additional brightness lift + bg fill so the player
/// reads it as a button.
fn draw_help(
    frame: &mut Frame,
    area: Rect,
    text: &str,
    mode: Mode,
    mouse_pos: Option<(u16, u16)>,
) -> Vec<(HelpAction, Rect)> {
    let mut hits: Vec<(HelpAction, Rect)> = Vec::new();
    if area.width == 0 || area.height == 0 {
        return hits;
    }
    let buf = frame.buffer_mut();
    let mut cursor_x: u16 = 0;
    let mut cursor_y: u16 = 0;
    for line in text.split('\n') {
        // Tokens are separated by a literal `  ` (two spaces). Single
        // spaces inside a token are content (e.g. "back to game").
        for token in line.split("  ") {
            let token = token.trim();
            if token.is_empty() {
                continue;
            }
            let w = token.chars().count() as u16;
            // Wrap if the token wouldn't fit on the current line.
            if cursor_x + w > area.width && cursor_x > 0 {
                cursor_y += 1;
                cursor_x = 0;
            }
            if cursor_y >= area.height {
                break;
            }
            let action = map_help_token(token, mode);
            // Hide `[q] quit` (or its localized equivalent) on platforms
            // where the wasm/native runner has no authority to exit —
            // see `platform::Capabilities::can_quit`. Skipping renders
            // AND skips appending to `help_hits`, so the next token
            // slides into the position cursor without leaving a gap.
            if matches!(action, Some(HelpAction::Quit)) && !crate::platform::CAPABILITIES.can_quit {
                continue;
            }
            let active = matches!(action, Some(HelpAction::OpenMode(m)) if m == mode);
            let token_rect = Rect {
                x: area.x + cursor_x,
                y: area.y + cursor_y,
                width: w.min(area.width.saturating_sub(cursor_x)),
                height: 1,
            };
            // Style picker:
            //  - active mode hint                : bright yellow, BOLD
            //  - actionable (clickable)          : light gray, BOLD
            //  - informational                   : dark gray
            //  - hovered                         : color lifted, bg tint
            // Hover lift fires ONLY on actionable hints — informational
            // tokens like `[Space/Click] finger` and `[Shift] x10` are
            // descriptive labels with no click handler, so brightening
            // them on hover would advertise a button that doesn't exist.
            let hovered = action.is_some()
                && mouse_pos
                    .map(|(mx, my)| {
                        mx >= token_rect.x
                            && mx < token_rect.x + token_rect.width
                            && my == token_rect.y
                    })
                    .unwrap_or(false);
            let mut style = if active {
                Style::default()
                    .fg(Color::Rgb(255, 220, 120))
                    .add_modifier(Modifier::BOLD)
            } else if action.is_some() {
                Style::default()
                    .fg(Color::Rgb(180, 180, 180))
                    .add_modifier(Modifier::BOLD)
            } else {
                Style::default().fg(Color::DarkGray)
            };
            if hovered {
                style = style
                    .fg(Color::Rgb(255, 255, 255))
                    .bg(Color::Rgb(40, 40, 50))
                    .add_modifier(Modifier::BOLD);
            }
            buf.set_string(token_rect.x, token_rect.y, token, style);
            if let Some(a) = action {
                hits.push((a, token_rect));
            }
            cursor_x += w + 2; // double-space separator
        }
        cursor_y += 1;
        cursor_x = 0;
        if cursor_y >= area.height {
            break;
        }
    }
    hits
}

/// Match a help-bar token like `"[u] upgrades"` to a `HelpAction`,
/// disambiguated by the current mode (so `[s] stats` opens Stats from
/// Game but `[s/Esc] back to game` from Stats returns to Game).
fn map_help_token(token: &str, mode: Mode) -> Option<HelpAction> {
    // Extract the bracketed key. We accept the first `[...]` group;
    // everything after the first `]` is descriptive label.
    let open = token.find('[')?;
    let close = token[open + 1..].find(']')? + open + 1;
    let key = &token[open + 1..close];
    // Universal hints first.
    if key.eq_ignore_ascii_case("q") {
        return Some(HelpAction::Quit);
    }
    // Back-to-game from any non-Game mode (the `[X/Esc] back ...` pattern
    // covers stats / achievements / upgrades / prestige).
    if mode != Mode::Game && (key.contains("Esc") || key.contains("esc")) {
        return Some(HelpAction::OpenMode(Mode::Game));
    }
    // Single-letter mode openers, only meaningful from Game.
    match (mode, key) {
        (Mode::Game, "t") | (Mode::Game, "T") => Some(HelpAction::OpenMode(Mode::Tree)),
        (Mode::Game, "p") | (Mode::Game, "P") => Some(HelpAction::OpenMode(Mode::Prestige)),
        (Mode::Game, "s") | (Mode::Game, "S") => Some(HelpAction::OpenMode(Mode::Stats)),
        (Mode::Game, "a") | (Mode::Game, "A") => Some(HelpAction::OpenMode(Mode::Achievements)),
        (Mode::Game, "g") | (Mode::Game, "G") => Some(HelpAction::GrabGolden),
        (Mode::Tree, "0") => Some(HelpAction::TreeFocusOrigin),
        (Mode::Tree, "1") => Some(HelpAction::TreeFocusLastBought),
        _ => None,
    }
}