cuqueclicker_lib/ui/biscuit.rs
1use ratatui::{prelude::*, widgets::*};
2
3use crate::game::powerup::{Powerup, PowerupKind};
4use crate::game::state::{Buff, CLENCH_SQUASH_TICKS, CLENCH_TICKS, GameState};
5
6/// Asshole-spin animation frames. Cycled by `total_clicks % N` while a
7/// spacebar hold has been detected (≥1s of continuous repeat). `*` lands
8/// every 5th frame as a "flash" sparkle in the rotation so the cycle reads
9/// as an actually-spinning asshole occasionally bursting into a star,
10/// rather than four indistinguishable line strokes.
11const SPIN_FRAMES: [char; 5] = ['\\', '|', '/', '-', '*'];
12
13/// Resolve a prestige count to (tint_color, mix_strength) for the cuque
14/// body. The tint is a noble hue the body bleeds toward; the mix is how
15/// strongly the tint dominates the resting tan. Steps are coarse on
16/// purpose — a player needs to PRESTIGE several times in a tier to start
17/// glimpsing the next one, so a tier transition reads as earned rather
18/// than continuous drift. Caps at "divine white" past tier 5.
19fn prestige_body_tint(prestige: u64) -> ((f32, f32, f32), f32) {
20 // Pure tan when at 0; each tier biases the cuque toward a distinct
21 // hue. Mix grows with tier so endgame is dramatic, but capped < 0.7
22 // so the body never goes monochrome — you can still tell it's a cuque.
23 match prestige {
24 0 => ((220.0, 170.0, 150.0), 0.0),
25 1..=2 => ((255.0, 200.0, 110.0), 0.18), // warm gold
26 3..=5 => ((255.0, 215.0, 80.0), 0.32), // saturated gold
27 6..=9 => ((230.0, 220.0, 235.0), 0.40), // silver-pink
28 10..=14 => ((180.0, 230.0, 255.0), 0.50), // ethereal cyan
29 15..=24 => ((220.0, 200.0, 255.0), 0.55), // celestial violet
30 _ => ((255.0, 250.0, 240.0), 0.65), // divine white
31 }
32}
33
34// IMPORTANT: the focal cell (asshole) is intentionally a SPACE in each
35// art slice below. The renderer overpaints that cell with the live glyph
36// (`O` / `*` / spin frame `\ | / - *`) at draw time, using the
37// `asshole_col` / `asshole_row` declared on each `BiscuitArt`. We do NOT
38// substitute glyphs into the strings — that ran into the obvious trap of
39// `replace('O', '|')` collateral-damaging the `|` walls on rows 9-21,
40// and the burning-pulse overpaint also got fooled into picking the
41// wrong row when searching for a stand-in glyph.
42// Body walls at cols 1 and 59 → visual center column 30. The right-side
43// curve rows (2-8 top, 21-27 bottom) used to extend 1 column further from
44// center than their left-side mirrors, so the right wall `|` sat at the
45// same column as the curve `\` above it (no rounding offset) while the
46// left side had `/` 1 col inside the wall — visibly asymmetric. Shifted
47// the right cluster on each curve row 1 col left so the right contour now
48// rounds into the wall the same way the left does.
49//
50// Rows 0, 1, 28, 29 (the underscore row + adjacent `__,-~~` row) sit at
51// half-column offsets from the body center; their |L|/|R| asymmetry is
52// inherent to the even-width middle gap, and a 1-col shift just flips
53// which side is shorter. Left as-is.
54const BISCUIT_FULL: &[&str] = &[
55 r" ____________________ ",
56 r" __,-~~ ~~-,__ ",
57 r" ,-~' `~-, ",
58 r" ,-' `-, ",
59 r" ,' `. ",
60 r" / -~-~-~- -~-~-~- \ ",
61 r" / \ ",
62 r" / -~~-~-~~- \ ",
63 r" / \ ",
64 r" | -~-~-~-~- -~-~-~-~- |",
65 r" | |",
66 r" | |",
67 r" | \\\\\\\\ | //////// |",
68 r" | \\\\\\\\ | //////// |",
69 r" | \\\\\\\\\|///////// |",
70 r" | ~ - - - - - - - - - - ~ |",
71 r" | /////////|\\\\\\\\\ |",
72 r" | //////// | \\\\\\\\ |",
73 r" | //////// | \\\\\\\\ |",
74 r" | |",
75 r" | -~-~-~-~- -~-~-~-~- |",
76 r" \ / ",
77 r" \ -~~-~-~~- / ",
78 r" \ / ",
79 r" \ -~-~-~- -~-~-~- / ",
80 r" `. ,' ",
81 r" `-, ,-' ",
82 r" `~-, ,-~' ",
83 r" `~-,,_ _,,-~' ",
84 r" `~-,,______________,,-~' ",
85];
86
87const BISCUIT_MEDIUM: &[&str] = &[
88 r" ________________ ",
89 r" ,-~ ~-, ",
90 r" ,-' `-, ",
91 r" ,' `. ",
92 r" / -~-~- -~-~- \ ",
93 r" / \",
94 r" | \\\\\ | ///// |",
95 r" | \\\\\ | ///// |",
96 r" | \\\\\\|////// |",
97 r" | ~ - - - - - - ~ |",
98 r" | //////|\\\\\\ |",
99 r" | ///// | \\\\\ |",
100 r" | ///// | \\\\\ |",
101 r" \ /",
102 r" \ -~-~- -~-~- / ",
103 r" `. ,' ",
104 r" `-, ,-' ",
105 r" `~-,,_______________,,-~' ",
106];
107
108// Body walls sit at cols 1 and 25 → visual center column 13. The rounded
109// contour rows (0-3, 9-11) used to terminate one column short of the wall
110// on the right side, leaving a 2-column gap between e.g. `\` (row 3) and
111// `|` (row 4). Symmetrized around col 13 so each row's left margin and
112// right margin are equal — same shape now reads as a closed oval.
113const BISCUIT_SMALL: &[&str] = &[
114 r" ___________ ",
115 r" ,-~ ~-, ",
116 r" ,' `. ",
117 r" / -~-~- -~-~- \ ",
118 r" | \\\ | /// | ",
119 r" | \\\|/// | ",
120 r" | ~ - - - - ~ | ",
121 r" | ///|\\\ | ",
122 r" | /// | \\\ | ",
123 r" \ -~-~- -~-~- / ",
124 r" `. ,' ",
125 r" `-,,_________,,-' ",
126];
127
128/// 16 cols × 8 rows. Borrowed by the tree-modal anchor (the (0, 0) lot)
129/// to render the cuque-as-anchor instead of a regular upgrade box.
130pub(crate) const BISCUIT_TINY: &[&str] = &[
131 r" ______ ",
132 r" ,~ ~, ",
133 r" / \ ",
134 r" | \|/ | ",
135 r" | - - | ",
136 r" | /|\ | ",
137 r" \ / ",
138 r" `-,____,-' ",
139];
140/// Focal cell ("the asshole") of `BISCUIT_TINY`, expressed in art-local
141/// (col, row) coords. Anchor render in the tree modal paints an `O` at
142/// this cell so it reads as a cuque, not just an outline.
143pub(crate) const BISCUIT_TINY_FOCAL: (u16, u16) = (7, 4);
144
145/// One zoom level. `(asshole_col, asshole_row)` are the exact in-art
146/// coordinates of the focal cell — declared at author time, never searched
147/// for at draw time. The renderer prints the static `rows` verbatim (the
148/// focal cell is a SPACE in the source) and then paints exactly one cell
149/// at that coordinate with the live glyph (`O` / `*` / spin frame). Pros:
150/// - centering doesn't depend on the glyph (spin frames are ASCII chars
151/// that also appear elsewhere in the cuque outline; substitution
152/// would either miss or hit-too-many cells);
153/// - the burning-pulse overpaint targets exactly one cell, every frame,
154/// with no string search to mis-fire on outline `|` walls;
155/// - reorganizing or extending the art is a localized edit — bump the
156/// coords here and the renderer follows.
157///
158/// Authors of new levels MUST update both coords when adding art.
159struct BiscuitArt {
160 rows: &'static [&'static str],
161 asshole_col: u16,
162 asshole_row: u16,
163 label: Option<&'static str>,
164}
165
166const BISCUIT_LEVELS: &[BiscuitArt] = &[
167 BiscuitArt {
168 rows: BISCUIT_FULL,
169 asshole_col: 31,
170 asshole_row: 15,
171 label: None,
172 },
173 BiscuitArt {
174 rows: BISCUIT_MEDIUM,
175 asshole_col: 20,
176 asshole_row: 9,
177 label: Some("70%"),
178 },
179 BiscuitArt {
180 rows: BISCUIT_SMALL,
181 asshole_col: 13,
182 asshole_row: 6,
183 label: Some("45%"),
184 },
185 BiscuitArt {
186 rows: BISCUIT_TINY,
187 asshole_col: 7,
188 asshole_row: 4,
189 label: Some("25%"),
190 },
191];
192
193pub fn level_count() -> usize {
194 BISCUIT_LEVELS.len()
195}
196
197/// Screen coordinates of the focal cell ("the asshole") for the given
198/// zoom level + biscuit rect. Used by `hands::draw` to orbit the ring
199/// around the visual cuque center rather than the bounding-box center
200/// (which differs by up to 1 column in TINY/FULL because each art's
201/// `asshole_col` isn't exactly `width / 2`).
202pub fn focal_point(zoom_idx: usize, biscuit: Rect) -> (u16, u16) {
203 let level = &BISCUIT_LEVELS[zoom_idx.min(BISCUIT_LEVELS.len() - 1)];
204 (biscuit.x + level.asshole_col, biscuit.y + level.asshole_row)
205}
206
207pub fn level_label(idx: usize) -> Option<&'static str> {
208 BISCUIT_LEVELS.get(idx).and_then(|a| a.label)
209}
210
211/// Draw the biscuit. Reads:
212///
213/// - `state.clench_ticks` — counts down a click flash. While >0, the eye
214/// becomes `*` and the body shifts pink. The first `CLENCH_SQUASH_TICKS`
215/// of that countdown also drop the top blank row, giving a one-frame
216/// vertical squash before the spring back.
217/// - active `ClickFrenzy` buff — biscuit is tinted toward red and shakes
218/// ±1 col on clench frames. Pure visual chaos, no behavior change.
219/// - `state.session_ticks` — drives a slow ambient breathing color cycle
220/// so the biscuit isn't completely static at idle.
221pub fn draw(frame: &mut Frame, area: Rect, state: &GameState, zoom_idx: usize) -> Rect {
222 let level = &BISCUIT_LEVELS[zoom_idx.min(BISCUIT_LEVELS.len() - 1)];
223 let art = level.rows;
224 let clenched = state.clench_ticks > 0;
225 // First CLENCH_SQUASH_TICKS frames of the clench: render a vertically
226 // squashed variant of the art so the cuque visibly contracts, then
227 // springs back. clench_ticks counts down from CLENCH_TICKS so "early in
228 // the clench" means clench_ticks is large.
229 let squash = clenched && state.clench_ticks + CLENCH_SQUASH_TICKS > CLENCH_TICKS;
230
231 // CRITICAL: the squash transformation MUST preserve total row count.
232 // `hands::draw` reads `biscuit.height` and `biscuit.y` to compute the
233 // orbital center + radii — if either changes per-frame, every hand
234 // around the cuque jitters on each click. The squash is built by
235 // dropping the rows immediately above + below the eye and padding with
236 // a blank row at top and bottom. Net: same height, eye stays at the
237 // same screen y, outer outline contracts inward toward the eye, hands
238 // around the biscuit don't move.
239 let render_art_owned: Vec<String> = if squash {
240 squashed_art(art, level.asshole_row as usize)
241 } else {
242 art.iter().map(|s| s.to_string()).collect()
243 };
244 let render_art: Vec<&str> = render_art_owned.iter().map(|s| s.as_str()).collect();
245
246 let w = render_art
247 .iter()
248 .map(|s| s.chars().count())
249 .max()
250 .unwrap_or(0) as u16;
251 let h = render_art.len() as u16;
252 // Anchor placement to the ASSHOLE column declared on the art level.
253 // Centering the bounding box by `(area.width - w) / 2` integer-truncates
254 // and combines with each art's different in-art asshole column, so the
255 // asshole drifts left/right as the player zooms. Anchoring the asshole
256 // itself to a fixed screen column keeps the focal point stationary on
257 // every zoom — the surrounding cuque shifts; the asshole doesn't. The
258 // declared column is independent of which glyph happens to live in
259 // that cell, so spin animation frames (`\ | / -`) work without breaking
260 // alignment.
261 let target_asshole_col = area.x + area.width / 2;
262 let x_base = target_asshole_col
263 .saturating_sub(level.asshole_col)
264 .max(area.x)
265 .min((area.x + area.width).saturating_sub(w));
266 let y_base = area.y + area.height.saturating_sub(h) / 2;
267
268 // The stable rect is what we RETURN to callers (hands, particles,
269 // golden). It must NOT depend on per-frame transients like the Frenzy
270 // shake — otherwise the orbital hands and floating particles jitter on
271 // every clench. Frenzy shake is applied only to the render position
272 // below.
273 let stable_rect = Rect {
274 x: x_base,
275 y: y_base,
276 width: w.min(area.width),
277 height: h.min(area.height.saturating_sub(y_base - area.y)),
278 };
279
280 // Frenzy shake: ±1 col jitter while clenched and frenzied. Drives off
281 // session_ticks so successive frames pick different offsets without
282 // needing per-render RNG state.
283 let frenzy_active = state
284 .buffs
285 .iter()
286 .any(|b| matches!(b, Buff::ClickFrenzy { .. }));
287 let shake = if frenzy_active && clenched {
288 (state.session_ticks % 3) as i32 - 1
289 } else {
290 0
291 };
292 let render_x = ((x_base as i32 + shake)
293 .max(area.x as i32)
294 .min((area.x + area.width).saturating_sub(stable_rect.width) as i32))
295 as u16;
296 let render_rect = Rect {
297 x: render_x,
298 y: stable_rect.y,
299 width: stable_rect.width,
300 height: stable_rect.height,
301 };
302
303 // Render the static art lines as-is — the focal cell is a literal SPACE
304 // in the source. The asshole glyph is painted directly into its
305 // declared (asshole_col, asshole_row) cell after the body draw.
306 let lines: Vec<Line> = render_art
307 .iter()
308 .map(|s| Line::from(s.to_string()))
309 .collect();
310
311 // Color blend:
312 // - resting tan (220, 170, 150) when calm.
313 // - clenched pink (255, 120, 140); during Frenzy bias the pink redder.
314 // - idle: slow ±~5% sinusoidal breath on brightness, so the biscuit
315 // never freezes between events.
316 let base = if clenched {
317 if frenzy_active {
318 (255.0_f32, 80.0, 110.0)
319 } else {
320 (255.0_f32, 120.0, 140.0)
321 }
322 } else {
323 let t = (state.session_ticks as f32) / 25.0; // ~8s period at 20Hz
324 let breath = 1.0 + 0.05 * t.sin();
325 // Resting tan, then re-tinted toward the prestige tier color.
326 // Higher tiers earn nobler hues so endgame feels visibly rewarded —
327 // tan → warm gold → silver-pink → ethereal cyan → divine white.
328 let (tint, mix) = prestige_body_tint(state.prestige);
329 let base_r = 220.0 * breath;
330 let base_g = 170.0 * breath;
331 let base_b = 150.0 * breath;
332 let r = base_r + (tint.0 - base_r) * mix;
333 let g = base_g + (tint.1 - base_g) * mix;
334 let b = base_b + (tint.2 - base_b) * mix;
335 (
336 r.clamp(0.0, 255.0),
337 g.clamp(0.0, 255.0),
338 b.clamp(0.0, 255.0),
339 )
340 };
341
342 let color = Color::Rgb(base.0 as u8, base.1 as u8, base.2 as u8);
343 let p = Paragraph::new(lines).style(Style::default().fg(color));
344 frame.render_widget(p, render_rect);
345
346 // Asshole glyph picker:
347 // - not clenched → resting `O` (cuque body color)
348 // - clenched, space NOT held → burning `*` with a hot pulsing red
349 // - clenched, space HELD ≥ 1s → spin frame `\ | / - *`, advanced by
350 // `total_clicks % 5` so each repeat tick
351 // rotates one step (`*` is the flash
352 // frame in the cycle).
353 //
354 // Painted directly into the declared (asshole_col, asshole_row) cell —
355 // no string substitution, no row search. The squash transform
356 // preserves the asshole row index, so this works in both calm and
357 // squashed states.
358 let space_held = state.space_held();
359 let asshole_glyph: char = if !clenched {
360 'O'
361 } else if space_held {
362 SPIN_FRAMES[(state.total_clicks as usize) % SPIN_FRAMES.len()]
363 } else {
364 '*'
365 };
366 let buf = frame.buffer_mut();
367 let cx = render_rect.x + level.asshole_col;
368 let cy = render_rect.y + level.asshole_row;
369 if cx < buf.area.x + buf.area.width && cy < buf.area.y + buf.area.height {
370 let style = if clenched {
371 // Pulse the focal cell at ~5Hz (period ~4 ticks at 20Hz) so the
372 // asshole reads as actively burning, distinct from the merely
373 // pink cuque body.
374 let phase = (state.session_ticks as f32) * 0.8;
375 let pulse = (phase.sin() + 1.0) * 0.5;
376 let r = (200.0 + 55.0 * pulse) as u8;
377 let g = (30.0 + 60.0 * pulse) as u8;
378 Style::default()
379 .fg(Color::Rgb(r, g, 0))
380 .add_modifier(Modifier::BOLD)
381 } else {
382 // Resting `O` matches the cuque body color so it doesn't pop.
383 Style::default().fg(color)
384 };
385 let cell = &mut buf[(cx, cy)];
386 cell.set_char(asshole_glyph);
387 cell.set_style(style);
388 }
389 // Return the STABLE rect so hands / particles / golden see a steady
390 // biscuit position even when render_rect was shifted by the Frenzy
391 // shake or vertically squeezed by the squash padding.
392 stable_rect
393}
394
395/// Render the golden cuque marker. Position is resolved against the CURRENT
396/// `biscuit` rect every frame from the golden's stored fractional anchor —
397/// so the marker travels with the biscuit on zoom and resize, instead of
398/// stranding in the old screen position. Returned `Rect` is the actual
399/// drawn rect, used by the click router for hit-testing.
400///
401/// Build the "squashed" frame of a biscuit ASCII level by removing the rows
402/// immediately above and below the asshole row and padding with a blank
403/// row at top and bottom.
404///
405/// Why this shape: a real squash needs the centerline (asshole) to stay
406/// anchored while the upper and lower halves contract toward it — that's
407/// what reads as a flattened ellipsoid. Just shrinking from the top makes
408/// the cuque look like the topmost row is flickering, not pulsing.
409///
410/// Why the blank padding: total row count MUST be preserved. The biscuit
411/// rect that this function feeds is read by `hands::draw` to place the
412/// orbital fingerers — any change to rect.height (or rect.y, via
413/// recentering) would shift every hand around the cuque on every click.
414/// Padding keeps the rect identical between calm and squashed states.
415///
416/// Critical invariant: the asshole row's index in the output is the same
417/// as `asshole_row` in the input, so the renderer can use the level's
418/// declared `asshole_row` regardless of squash state.
419///
420/// Falls back to a plain copy if the art is too short to safely drop two
421/// rows around the asshole row.
422fn squashed_art(art: &[&str], asshole_row: usize) -> Vec<String> {
423 let n = art.len();
424 if n < 5 || asshole_row == 0 || asshole_row + 1 >= n {
425 return art.iter().map(|s| s.to_string()).collect();
426 }
427 let width = art.iter().map(|s| s.chars().count()).max().unwrap_or(0);
428 let blank: String = " ".repeat(width);
429
430 let mut out: Vec<String> = Vec::with_capacity(n);
431 out.push(blank.clone()); // top pad replaces the dropped (asshole_row - 1)
432 for s in art.iter().take(asshole_row - 1) {
433 out.push((*s).to_string());
434 }
435 out.push(art[asshole_row].to_string());
436 for s in art.iter().skip(asshole_row + 2) {
437 out.push((*s).to_string());
438 }
439 out.push(blank); // bottom pad replaces the dropped (asshole_row + 1)
440 debug_assert_eq!(out.len(), n);
441 out
442}
443
444/// Per-kind palette/glyph table. `bright`/`dim`/`accent` are the three
445/// colors the shimmer wave samples; `bg` stays low-key so the eye reads
446/// the *text* sliding through hues, not a blinking box. Centralized here
447/// so a new `PowerupKind` is one extra arm.
448struct PowerupPalette {
449 center: char,
450 bright: (f32, f32, f32),
451 dim: (f32, f32, f32),
452 accent: (f32, f32, f32),
453 bg: Color,
454}
455
456fn powerup_palette(kind: PowerupKind) -> PowerupPalette {
457 match kind {
458 PowerupKind::Lucky => PowerupPalette {
459 center: '$',
460 bright: (255.0, 230.0, 80.0),
461 dim: (140.0, 90.0, 0.0),
462 accent: (255.0, 170.0, 30.0),
463 bg: Color::Rgb(40, 25, 0),
464 },
465 PowerupKind::Frenzy => PowerupPalette {
466 center: '!',
467 bright: (255.0, 110.0, 110.0),
468 dim: (120.0, 0.0, 0.0),
469 accent: (255.0, 200.0, 60.0),
470 bg: Color::Rgb(50, 0, 0),
471 },
472 PowerupKind::Buff => PowerupPalette {
473 center: '+',
474 bright: (230.0, 160.0, 255.0),
475 dim: (80.0, 20.0, 110.0),
476 accent: (140.0, 220.0, 255.0),
477 bg: Color::Rgb(35, 0, 45),
478 },
479 PowerupKind::GreenCoin => PowerupPalette {
480 center: '$',
481 bright: (140.0, 255.0, 160.0),
482 dim: (10.0, 80.0, 30.0),
483 accent: (200.0, 255.0, 110.0),
484 bg: Color::Rgb(0, 30, 10),
485 },
486 }
487}
488
489/// J9 juice: the marker shimmers. Each character of the 5-wide marker
490/// samples its own foreground color from a horizontally-traveling wave
491/// between a `bright` peak, a `dim` trough, and an `accent` highlight on
492/// the off-phase. The bg stays a constant low-key tint, so what the player
493/// sees is the TEXT itself sliding through colors — not a flashing box.
494/// In the final 20% of life the wave speeds up and the trough darkens so
495/// a soon-to-expire powerup visibly accelerates without losing legibility.
496///
497/// Unified across kinds: `Lucky`/`Frenzy`/`Buff`/`GreenCoin` differ only
498/// in their `powerup_palette` entry. Adding a new kind = add a palette
499/// arm; this function inherits it.
500pub fn draw_powerup(frame: &mut Frame, powerup: &Powerup, biscuit: Rect) -> Rect {
501 let buf = frame.buffer_mut();
502 let PowerupPalette {
503 center,
504 bright,
505 dim,
506 accent,
507 bg,
508 } = powerup_palette(powerup.kind);
509
510 // Wave speed (rad/tick) and trough depth both bump in alarm mode. The
511 // normal phase pulses at ~1.9 Hz (0.6 rad/tick × 20 ticks/s ÷ TAU);
512 // the alarm phase doubles to ~4.8 Hz so a soon-to-expire powerup
513 // visibly speeds up. Earlier values (0.22 / 0.55) read as too sleepy.
514 let life_total = powerup.kind.lifetime_ticks();
515 let life_frac = (powerup.life_ticks as f32 / life_total as f32).clamp(0.0, 1.0);
516 let alarm = life_frac < 0.20;
517 let speed = if alarm { 1.5 } else { 0.6 };
518 let dim_pull = if alarm { 1.0 } else { 0.6 };
519 // Phase advances every tick; per-cell offset shifts the wave across the
520 // 5-cell width so neighbors land at different points of the gradient.
521 let phase = (life_total - powerup.life_ticks) as f32 * speed;
522 let cell_offset = std::f32::consts::TAU / 5.0; // one full cycle across width
523
524 let lines: [String; 3] = [
525 ".---.".to_string(),
526 format!("( {} )", center),
527 "`---'".to_string(),
528 ];
529 let w: u16 = 5;
530 let h: u16 = 3;
531
532 let area = buf.area;
533 if area.width == 0 || area.height == 0 || biscuit.width < w || biscuit.height < h {
534 return Rect::default();
535 }
536
537 let (anchor_col, anchor_row) =
538 crate::game::state::biscuit_frac_to_screen(powerup.frac_x, powerup.frac_y, biscuit);
539 let mut col = anchor_col;
540 let mut row = anchor_row;
541 // Keep the 5x3 marker fully inside the biscuit so it never overlaps the
542 // sidebar / HUD chrome, then clamp once more to the screen for safety.
543 if col + w > biscuit.x + biscuit.width {
544 col = (biscuit.x + biscuit.width).saturating_sub(w);
545 }
546 if row + h > biscuit.y + biscuit.height {
547 row = (biscuit.y + biscuit.height).saturating_sub(h);
548 }
549 if col < biscuit.x {
550 col = biscuit.x;
551 }
552 if row < biscuit.y {
553 row = biscuit.y;
554 }
555 if col + w > area.x + area.width {
556 col = (area.x + area.width).saturating_sub(w);
557 }
558 if row + h > area.y + area.height {
559 row = (area.y + area.height).saturating_sub(h);
560 }
561
562 // Per-character horizontal gradient: walk every cell, sample the wave
563 // for that cell's column offset, and write a 1-char styled span. Cheap
564 // (15 cells max) and gives "shimmering text" instead of "blinking box".
565 for (dy, line) in lines.iter().enumerate() {
566 let y = row + dy as u16;
567 if y >= area.y + area.height {
568 break;
569 }
570 for (i, ch) in line.chars().enumerate() {
571 let x = col + i as u16;
572 if x >= area.x + area.width {
573 break;
574 }
575 let arg = phase + i as f32 * cell_offset;
576 let wave_main = (arg.sin() + 1.0) * 0.5; // 0..1
577 // Accent rides on a quarter-phase shift so it brightens in
578 // between the main bright peaks rather than reinforcing them.
579 let wave_accent = ((arg + std::f32::consts::FRAC_PI_2).sin() + 1.0) * 0.5;
580 // Pull the trough darker by `dim_pull` so alarm mode visibly
581 // crushes the dim end without affecting peak readability.
582 let dim_dim = (
583 dim.0 * (1.0 - 0.4 * dim_pull),
584 dim.1 * (1.0 - 0.4 * dim_pull),
585 dim.2 * (1.0 - 0.4 * dim_pull),
586 );
587 let main_r = dim_dim.0 + (bright.0 - dim_dim.0) * wave_main;
588 let main_g = dim_dim.1 + (bright.1 - dim_dim.1) * wave_main;
589 let main_b = dim_dim.2 + (bright.2 - dim_dim.2) * wave_main;
590 // Cap accent contribution at 35% so it tints without washing
591 // out the bright peak.
592 let accent_w = wave_accent * 0.35;
593 let r = main_r + (accent.0 - main_r) * accent_w;
594 let g = main_g + (accent.1 - main_g) * accent_w;
595 let b = main_b + (accent.2 - main_b) * accent_w;
596 let style = Style::default()
597 .fg(Color::Rgb(
598 r.clamp(0.0, 255.0) as u8,
599 g.clamp(0.0, 255.0) as u8,
600 b.clamp(0.0, 255.0) as u8,
601 ))
602 .bg(bg)
603 .add_modifier(Modifier::BOLD);
604 buf.set_string(x, y, ch.to_string(), style);
605 }
606 }
607
608 Rect {
609 x: col,
610 y: row,
611 width: w,
612 height: h,
613 }
614}