Skip to main content

cuqueclicker_lib/ui/
prestige.rs

1use ratatui::{prelude::*, widgets::*};
2
3use crate::format;
4use crate::game::state::GameState;
5use crate::i18n::t;
6
7/// Output of the Prestige panel render — three optional click rects.
8/// Exactly which ones are populated depends on whether prestige is
9/// available AND whether the player is mid-confirmation:
10///   - **Resting (available, no pending confirm)**: `reset` populated;
11///     `yes` / `no` default. Clicking `reset` flips into pending state.
12///   - **Confirming (pending)**: `yes` / `no` populated; `reset`
13///     default. Clicking `yes` fires `Action::PrestigeReset`, `no`
14///     cancels and returns to resting.
15///   - **Unavailable**: all three default (panel just shows lifetime-
16///     needed hint).
17#[derive(Clone, Copy, Default)]
18pub struct PrestigeRects {
19    pub reset: Rect,
20    pub yes: Rect,
21    pub no: Rect,
22}
23
24pub fn draw(
25    frame: &mut Frame,
26    area: Rect,
27    state: &GameState,
28    mouse_pos: Option<(u16, u16)>,
29    confirm_pending: bool,
30) -> PrestigeRects {
31    let lang = t();
32    let owned = state.prestige;
33    let available = state.prestige_available();
34    let bonus_pct = state.prestige as f64;
35    // Saturating arithmetic — with the prestige cap removed,
36    // `(owned + 1).pow(2) * 1_000_000` would overflow u64 around
37    // owned > 1.36e7 (debug-panic, release-wrap). Saturating keeps
38    // the displayed-only number sane at extreme values.
39    let next_threshold = owned
40        .saturating_add(1)
41        .saturating_mul(owned.saturating_add(1))
42        .saturating_mul(1_000_000);
43
44    // Render the bordered panel chrome first; we paint into its inner
45    // area below using independent sub-rects so the click rects for
46    // the action buttons don't depend on a single Paragraph's wrap
47    // behavior — earlier off-by-one bugs all stemmed from line-index
48    // math drifting under soft-wrap.
49    let block = Block::bordered().title(lang.prestige_title);
50    let inner = block.inner(area);
51    frame.render_widget(block, area);
52
53    let mut rects = PrestigeRects::default();
54    if inner.width == 0 || inner.height == 0 {
55        return rects;
56    }
57
58    // ---- Info section (top): cuques owned, fps bonus, available ----
59    let mut info_lines: Vec<Line> = Vec::new();
60    info_lines.push(Line::from(vec![
61        Span::raw(format!("  {}: ", lang.prestige_owned_label)),
62        Span::styled(
63            format::big(owned as f64),
64            Style::default()
65                .fg(Color::Rgb(255, 215, 0))
66                .add_modifier(Modifier::BOLD),
67        ),
68        Span::raw(format!("  ({})", lang.prestige_currency)),
69    ]));
70    info_lines.push(Line::raw(""));
71    info_lines.push(Line::from(vec![
72        Span::raw(format!("  {}: ", lang.prestige_bonus_label)),
73        Span::styled(
74            format!("+{:.0}% {}", bonus_pct, lang.fps_unit),
75            Style::default().fg(Color::Rgb(120, 230, 120)),
76        ),
77    ]));
78    if available > 0 {
79        info_lines.push(Line::raw(""));
80        info_lines.push(Line::from(vec![
81            Span::raw(format!("  {}: ", lang.prestige_available_label)),
82            Span::styled(
83                format!("+{}", format::big(available as f64)),
84                Style::default()
85                    .fg(Color::Rgb(255, 215, 0))
86                    .add_modifier(Modifier::BOLD),
87            ),
88        ]));
89    } else {
90        info_lines.push(Line::raw(""));
91        for l in lang.prestige_not_enough.lines() {
92            info_lines.push(Line::from(Span::styled(
93                format!("  {l}"),
94                Style::default().fg(Color::DarkGray),
95            )));
96        }
97        info_lines.push(Line::raw(""));
98        info_lines.push(Line::from(vec![
99            Span::raw(format!("  {}: ", lang.prestige_lifetime_needed)),
100            Span::styled(
101                format::big(next_threshold as f64),
102                Style::default().fg(Color::Rgb(200, 180, 140)),
103            ),
104        ]));
105    }
106
107    // ---- Action section (bottom): reset hint OR yes/no buttons ----
108    // Reserve a fixed-height bottom strip whose lines we render WITHOUT
109    // wrap so each Vec line maps 1:1 to a visual row inside the strip.
110    // Click rects are computed from the strip's `(y, height)`, never
111    // from the Paragraph above it. That decoupling was the structural
112    // fix for the off-by-one bug where wrap on any earlier line shifted
113    // the buttons one row away from their click rects.
114    let action_lines: Vec<(Line, Option<ActionTarget>)> = if available == 0 {
115        Vec::new()
116    } else if confirm_pending {
117        let mut v: Vec<(Line, Option<ActionTarget>)> = Vec::new();
118        v.push((
119            Line::from(Span::styled(
120                format!("  {}", lang.prestige_confirm_question),
121                Style::default()
122                    .fg(Color::Rgb(255, 90, 90))
123                    .add_modifier(Modifier::BOLD),
124            )),
125            None,
126        ));
127        for chunk in lang.prestige_confirm_warning.lines() {
128            v.push((
129                Line::from(Span::styled(
130                    format!("  {chunk}"),
131                    Style::default().fg(Color::Rgb(220, 180, 120)),
132                )),
133                None,
134            ));
135        }
136        v.push((Line::raw(""), None));
137        v.push((
138            Line::from(Span::styled(
139                format!("  {}", lang.prestige_confirm_yes),
140                Style::default()
141                    .fg(Color::Rgb(255, 100, 100))
142                    .add_modifier(Modifier::BOLD),
143            )),
144            Some(ActionTarget::Yes),
145        ));
146        // Blank row between Yes / No so a touch / mouse player has a
147        // forgiving gap between the two click targets.
148        v.push((Line::raw(""), None));
149        v.push((
150            Line::from(Span::styled(
151                format!("  {}", lang.prestige_confirm_no),
152                Style::default()
153                    .fg(Color::Rgb(120, 220, 120))
154                    .add_modifier(Modifier::BOLD),
155            )),
156            Some(ActionTarget::No),
157        ));
158        v
159    } else {
160        vec![(
161            Line::from(Span::styled(
162                format!("  {}", lang.prestige_confirm_hint),
163                Style::default().fg(Color::Rgb(220, 140, 255)),
164            )),
165            Some(ActionTarget::Reset),
166        )]
167    };
168
169    let action_h = action_lines.len() as u16;
170    let chunks = if action_h > 0 && action_h < inner.height {
171        Layout::vertical([Constraint::Min(1), Constraint::Length(action_h)]).split(inner)
172    } else {
173        // Action strip would overflow the panel — fall back to drawing
174        // info only. The action buttons can't render so click rects
175        // stay default.
176        Layout::vertical([Constraint::Min(1), Constraint::Length(0)]).split(inner)
177    };
178    let info_area = chunks[0];
179    let action_area = chunks[1];
180
181    // Info section uses Wrap { trim: false } so long lines (e.g. pt_BR
182    // currency name) wrap inside the info strip without affecting the
183    // action strip below.
184    let info_para = Paragraph::new(info_lines).wrap(Wrap { trim: false });
185    frame.render_widget(info_para, info_area);
186
187    if action_h == 0 || action_area.height == 0 {
188        return rects;
189    }
190    // Action section: NO wrap — each Line is exactly one visual row.
191    // Click rects derive from `action_area.y + offset` where `offset`
192    // is the Vec index (which equals the visual row because wrap is
193    // off).
194    let mut action_para_lines: Vec<Line> = Vec::with_capacity(action_lines.len());
195    for (line, target) in action_lines.iter() {
196        let row_y = action_area.y + action_para_lines.len() as u16;
197        let rect = Rect {
198            x: action_area.x,
199            y: row_y,
200            width: action_area.width,
201            height: 1,
202        };
203        match target {
204            Some(ActionTarget::Reset) => rects.reset = rect,
205            Some(ActionTarget::Yes) => rects.yes = rect,
206            Some(ActionTarget::No) => rects.no = rect,
207            None => {}
208        }
209        action_para_lines.push(line.clone());
210    }
211    let action_para = Paragraph::new(action_para_lines);
212    frame.render_widget(action_para, action_area);
213
214    // Hover lift on whichever click rect the mouse is over.
215    if let Some((mx, my)) = mouse_pos {
216        let buf = frame.buffer_mut();
217        for r in [rects.reset, rects.yes, rects.no] {
218            if r.width == 0 {
219                continue;
220            }
221            if !(mx >= r.x && mx < r.x + r.width && my == r.y) {
222                continue;
223            }
224            for dx in 0..r.width {
225                let cx = r.x + dx;
226                if cx >= buf.area.x + buf.area.width {
227                    break;
228                }
229                let cell = &mut buf[(cx, r.y)];
230                cell.set_fg(Color::Rgb(255, 255, 255));
231                cell.set_bg(Color::Rgb(40, 30, 50));
232                cell.modifier.insert(Modifier::BOLD);
233            }
234        }
235    }
236    rects
237}
238
239#[derive(Copy, Clone)]
240enum ActionTarget {
241    Reset,
242    Yes,
243    No,
244}