Skip to main content

cuqueclicker_lib/ui/
toast.rs

1//! Achievement-unlock toast overlay.
2//!
3//! Rendered *over* the biscuit area so it's prominent without disturbing
4//! the right-side panel. The sim populates `state.active_unlock_id` /
5//! `active_unlock_ticks` from the queue in `state.newly_unlocked`; this
6//! module just translates that into a brief gold-bordered popup.
7//!
8//! Lives ~4s on screen (TOAST_TICKS). The whole box (bg, border, header,
9//! body) shares a single `strength` curve so it fades in and out as one
10//! object — never the empty box first or the text alone after.
11use ratatui::{prelude::*, widgets::*};
12
13use crate::game::achievement::ACHIEVEMENTS;
14use crate::game::state::{GameState, TOAST_TICKS};
15use crate::i18n::t;
16
17/// Draw the toast over `area` if one is active. No-op when nothing's queued.
18pub fn draw(frame: &mut Frame, area: Rect, state: &GameState) {
19    let Some(id) = state.active_unlock_id.as_deref() else {
20        return;
21    };
22    if state.active_unlock_ticks == 0 {
23        return;
24    }
25    // Resolve catalog index → localized name. Unknown ids are skipped
26    // silently — never show a broken "?" toast.
27    let Some(idx) = ACHIEVEMENTS.iter().position(|a| a.id == id) else {
28        return;
29    };
30    let lang = t();
31    let Some(name) = lang.achievement_names.get(idx).copied() else {
32        return;
33    };
34
35    // Single ease curve drives EVERYTHING: bg darkness, border color, header
36    // color, body color. They all rise and fall together so the toast reads
37    // as one fading object.
38    let life = state.active_unlock_ticks as f32 / TOAST_TICKS as f32;
39    let strength = ease_in_out(life);
40    if strength <= 0.01 {
41        return;
42    }
43
44    let header_plain = header_plain();
45    let body_text = format!("  {name}  ");
46    let inner_w = (header_plain.chars().count().max(body_text.chars().count()) + 2) as u16;
47    let w = (inner_w + 2).min(area.width.saturating_sub(2));
48    let h: u16 = 5;
49    if area.width < w + 2 || area.height < h + 2 {
50        return;
51    }
52
53    // Centered horizontally, ~1/4 of the way down so it sits near the top
54    // without overlapping the HUD or biscuit eye.
55    let x = area.x + (area.width.saturating_sub(w)) / 2;
56    let y = area.y + 2;
57    let rect = Rect {
58        x,
59        y,
60        width: w,
61        height: h,
62    };
63
64    // Solid bg fill is the foundation: paint every cell in the rect with the
65    // toast bg before anything else, so the box is opaque (no biscuit /
66    // particles bleeding through the blank rows). Without this, ratatui's
67    // `Block::bordered().style(bg)` only colors the border cells; the
68    // interior keeps whatever the underlying widget drew, which is why the
69    // earlier toast looked hollow.
70    let bg = Color::Rgb((30.0 * strength) as u8, (20.0 * strength) as u8, 0);
71    let bg_style = Style::default().bg(bg);
72    {
73        let buf = frame.buffer_mut();
74        for dy in 0..rect.height {
75            for dx in 0..rect.width {
76                let cx = rect.x + dx;
77                let cy = rect.y + dy;
78                if cx >= buf.area.x + buf.area.width || cy >= buf.area.y + buf.area.height {
79                    continue;
80                }
81                let cell = &mut buf[(cx, cy)];
82                cell.set_char(' ');
83                cell.set_style(bg_style);
84            }
85        }
86    }
87
88    // Border + text now ride the same `strength`. We fade the foreground
89    // colors via the same multiplier as the bg so the whole popup feels
90    // like one object.
91    let border_fg = Color::Rgb(
92        (255.0 * strength) as u8,
93        (180.0 * strength) as u8,
94        (40.0 * strength) as u8,
95    );
96    let header_fg = border_fg;
97    let body_fg = Color::Rgb(
98        (255.0 * strength) as u8,
99        (230.0 * strength) as u8,
100        ((180.0 * strength) as u8).max((60.0 * strength) as u8),
101    );
102    let header_style = Style::default()
103        .fg(header_fg)
104        .bg(bg)
105        .add_modifier(Modifier::BOLD);
106    let body_style = Style::default()
107        .fg(body_fg)
108        .bg(bg)
109        .add_modifier(Modifier::BOLD);
110    let block = Block::bordered()
111        .border_style(Style::default().fg(border_fg).bg(bg))
112        .style(bg_style);
113    let lines = vec![
114        // The leading/trailing blanks are styled with the bg so they paint
115        // their cells (instead of leaving the underlying buffer through).
116        Line::from(Span::styled(" ".repeat(w as usize), bg_style)),
117        Line::from(Span::styled(header_plain.to_string(), header_style)),
118        Line::from(Span::styled(body_text, body_style)),
119        Line::from(Span::styled(" ".repeat(w as usize), bg_style)),
120    ];
121    let p = Paragraph::new(lines)
122        .alignment(Alignment::Center)
123        .block(block);
124    frame.render_widget(p, rect);
125}
126
127fn header_plain() -> &'static str {
128    " *** ACHIEVEMENT UNLOCKED *** "
129}
130
131fn smoothstep(t: f32) -> f32 {
132    let t = t.clamp(0.0, 1.0);
133    t * t * (3.0 - 2.0 * t)
134}
135
136/// `1.0` for the bulk of the toast; smooth ramps at the entry and exit so
137/// it feels like a popup, not a hard cut. `life` is remaining/total.
138///
139/// `life` decays from 1.0 → 0.0 over the toast's lifetime. The entry ramp
140/// is shorter than the exit ramp so the popup feels snappy on arrival and
141/// gentle on departure.
142fn ease_in_out(life: f32) -> f32 {
143    let life = life.clamp(0.0, 1.0);
144    // Ramp up over the first 15% (entry), hold at full, ramp down over the
145    // last 25% (exit). Both bg and fg use this single curve.
146    let entry = smoothstep((1.0 - life) / 0.15);
147    let exit = smoothstep(life / 0.25);
148    entry.min(exit)
149}