返回 CodeWhale
ambient_life.rs
根目录 / crates / tui / src / tui / ambient_life.rs
1 //! Ambient ocean life for the underwater transcript field.
2 //!
3 //! One clear owner for the fish school, jellyfish, bubbles, and the rare
4 //! whale cameo — nothing else lives in the water (2026-07-23 product
5 //! decision: seaweed and bio-dust are gone). Motion stays inside the
6 //! existing delta/interpolation path: this module never requests frames on
7 //! its own.
8 //!
9 //! Native silhouettes use the shared 2×4 braille cell: fish move in half
10 //! columns with a one-dot bob; jellyfish rise in quarter rows. A bounded pose
11 //! table owns no clock or simulation. ASCII-safe terminals retain the original
12 //! silhouettes through the same habitat, population and collision path.
13 //!
14 //! Motion language (shared with the rest of the shell): every mark can lerp
15 //! between the water and its ink at a time-varying brightness. Fish carry a
16 //! travelling sin² wave, jellyfish a slow band-bounded pulse that opens and
17 //! closes the dome while the tentacles trail it by ~0.6 s, bubbles an
18 //! occasional raised-cosine glint. Phases are wall-clock keyed and entity
19 //! periods deliberately never match, so nothing strobes in sync.
20 //!
21 //! The jellyfish is a *visitor*, not scenery: one at most, present for roughly
22 //! a fifth of a ~5-minute cycle and dimmer than everything around it. See the
23 //! `JELLY_VISIT_*` constants for the rarity knobs and why they are set where
24 //! they are.
25 //!
26 //! Fish swim on a wrap-around path: they exit one edge and re-enter the
27 //! other still facing their travel direction, so facing always equals
28 //! velocity by construction. Direction may only change while the school is
29 //! fully off-screen.
30 //!
31 //! The aquarium has a habitat and it defers to whatever is composed above it.
32 //! Collision is one rule — [`is_open_water`]: a mark may only land in a
33 //! horizontal span that carries no text and has none within
34 //! [`TEXT_CLEARANCE_ROWS`] of it, measured off the rendered lines rather than
35 //! guessed from fractions of the field. Everything else follows from it. A
36 //! short status line therefore leaves honest water beside it instead of
37 //! claiming the whole row. The school rides a band off the
38 //! floor ([`SCHOOL_FLOOR_GAP`]); bubbles rise a few rows from the floor and
39 //! dissolve ([`BUBBLE_MAX_RISE_ROWS`]); the jellyfish only surfaces where
40 //! [`deep_water_rows`] says the water is deep enough to hold it *and* the
41 //! school; and the surface caustics stop at the first row of the composition.
42 //! Light above, life below, words in between — and as a transcript fills the
43 //! field the water closes row by row until nothing moves behind the text the
44 //! reader is actually reading.
45 //!
46 //! Two clocks feed this module and neither is a token counter. Positions ride
47 //! `App::sample_ambient_clock_ms`, which advances by real elapsed time clamped
48 //! to `App::AMBIENT_MAX_STEP_MS` per draw, so drift speed is identical at 16 ms
49 //! and 33 ms frames and a stalled-then-resumed frame cannot jump a creature.
50 //! Sideways *placement*, by contrast, is a function of the transcript text
51 //! under the silhouette — which does change with token throughput — so it is
52 //! bounded by [`JELLY_MAX_TEXT_DODGE_COLS`].
53 //!
54 //! Under reduced motion there is no ambient life at all: `ocean::life_presence`
55 //! returns 0 and rendering exits before building marks or initializing pet
56 //! tapes. Reduced motion spends no simulation work on invisible creatures.
57 //!
58 //! `render_ambient_life` returns per-frame budget counters
59 //! ([`AmbientFrameStats`]): marks built always splits exactly into painted +
60 //! text-skipped + clipped. Counting is a handful of `u32` increments — no
61 //! allocation, no frame requests.
62
63 use ratatui::{
64 buffer::Buffer,
65 layout::Rect,
66 style::{Color, Modifier, Style},
67 text::Line,
68 };
69 use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
70
71 use crate::tui::ocean::{self, OceanColumn};
72
73 #[path = "ambient_life/native_poses.rs"]
74 mod native_poses;
75 #[path = "ambient_life/pet_cameo.rs"]
76 mod pet_cameo;
77 #[path = "ambient_life/pet_sim.rs"]
78 pub mod pet_sim;
79 #[path = "ambient_life/pet_widget.rs"]
80 pub mod pet_widget;
81
82 /// Depth layers for parallax. Nearer life is larger, faster, and more visible.
83 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
84 enum Depth {
85 Background,
86 Midground,
87 Foreground,
88 }
89
90 impl Depth {
91 #[must_use]
92 fn ink_index(self) -> usize {
93 match self {
94 Self::Background => 1,
95 Self::Midground | Self::Foreground => 0,
96 }
97 }
98 }
99
100 /// Creature density tier mirrored from shell width/height.
101 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
102 pub enum LifeDensity {
103 Sparse,
104 Normal,
105 Rich,
106 }
107
108 impl LifeDensity {
109 #[must_use]
110 pub fn from_area(area: Rect) -> Self {
111 if area.width < 56 || area.height < 12 {
112 Self::Sparse
113 } else if area.width < 88 || area.height < 20 {
114 Self::Normal
115 } else {
116 Self::Rich
117 }
118 }
119
120 #[must_use]
121 fn school_size(self) -> usize {
122 // One loose wedge of real fish; two schools compete with the whale.
123 match self {
124 Self::Sparse => 3,
125 Self::Normal => 5,
126 Self::Rich => 7,
127 }
128 }
129
130 #[must_use]
131 fn jellyfish_count(self) -> usize {
132 // At most one jellyfish in the water at a time, at every tier. Two
133 // put a pulsing silhouette in *both* side lanes, which is what made
134 // them read as resident scenery instead of a passing visitor. The
135 // rarity knob that matters is the visit duty cycle
136 // ([`JELLY_VISIT_CYCLE_SLOTS`]), not the population.
137 match self {
138 Self::Sparse | Self::Normal | Self::Rich => 1,
139 }
140 }
141
142 #[must_use]
143 fn bubble_streams(self) -> usize {
144 match self {
145 Self::Sparse => 1,
146 Self::Normal => 2,
147 Self::Rich => 2,
148 }
149 }
150 }
151
152 /// Lower floors so smaller windows still retain some life (was 68×15).
153 /// Keep in sync with [`crate::tui::ocean::AMBIENT_MIN_WIDTH`].
154 pub const AMBIENT_MIN_WIDTH: u16 = crate::tui::ocean::AMBIENT_MIN_WIDTH;
155 pub const AMBIENT_MIN_HEIGHT: u16 = crate::tui::ocean::AMBIENT_MIN_HEIGHT;
156
157 /// Snapshot of ambient positions for one frame (memoized once per draw).
158 #[derive(Debug, Clone)]
159 struct FrameMarks {
160 marks: Vec<AmbientMark>,
161 }
162
163 #[derive(Debug, Clone, Copy)]
164 struct AmbientMark {
165 x: u16,
166 y: u16,
167 glyph: &'static str,
168 /// Multi-row creature identity. Every part relocates or is withheld as one
169 /// unit so a jellyfish never degrades into a detached dome or tentacles.
170 jellyfish: Option<usize>,
171 depth: Depth,
172 style_mod: Option<Modifier>,
173 /// Time-varying glow in `[0, 1]`: the mark's ink is lerped from the
174 /// painted water toward full ink at this amount. `None` renders the
175 /// plain habitat ink.
176 brightness: Option<f32>,
177 }
178
179 /// Per-frame render budget counters. `marks_built` splits exactly into
180 /// `marks_painted + marks_skipped_text + marks_clipped`. `cells_written`
181 /// counts individual cell writes: a multi-cell glyph counts each of its
182 /// cells, and two overlapping marks count the shared cell once per write.
183 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
184 pub struct AmbientFrameStats {
185 pub marks_built: u32,
186 pub marks_painted: u32,
187 pub marks_skipped_text: u32,
188 pub marks_clipped: u32,
189 pub cells_written: u32,
190 }
191
192 /// Bounded school (7), jellyfish (4), bubbles (2), plus at most three
193 /// 18-by-6 dot-whale widgets including their labels. No particle allocations
194 /// or simulation steps occur per paint after the fixed cameo tapes are cached.
195 #[cfg(test)]
196 pub const MAX_FRAME_MARKS: u32 = 13 + pet_cameo::MAX_MARKS;
197
198 /// Optional pointer reaction for fish dart / bubble rise.
199 #[derive(Debug, Clone, Copy, Default)]
200 pub struct AmbientCursor {
201 pub column: u16,
202 pub row: u16,
203 /// When set, fish flee from this point for ~800 ms of shared ocean clock.
204 pub flee_elapsed_ms: Option<u128>,
205 }
206
207 /// Optional whale cameo trigger (e.g. successful turn completion).
208 #[derive(Debug, Clone, Copy, Default)]
209 pub struct WhaleCameo {
210 pub elapsed_ms: Option<u128>,
211 /// Anchor column within the field (composer / center).
212 pub anchor_x: u16,
213 pub anchor_y: u16,
214 }
215
216 /// How the ambient scene is shaped by live agent activity. The underwater
217 /// used to be phase-agnostic: same fish, same pace, whether the agent was
218 /// thinking, running tools, or orchestrating sub-agents. Each treatment is a
219 /// bounded parameter shift — never a second scene graph.
220 #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
221 pub enum AmbientActivity {
222 #[default]
223 Baseline,
224 Reasoning,
225 /// Read-shaped exploration: quieter than generic tool work, brighter
226 /// than hidden reasoning — skimming, not digging.
227 Reading,
228 Tools,
229 Subagents,
230 Verifying,
231 }
232
233 impl AmbientActivity {
234 #[must_use]
235 pub fn from_kind(kind: crate::tui::underwater::LiveActivityKind) -> Self {
236 match kind {
237 crate::tui::underwater::LiveActivityKind::Reasoning => Self::Reasoning,
238 crate::tui::underwater::LiveActivityKind::Reading => Self::Reading,
239 crate::tui::underwater::LiveActivityKind::UsingTool => Self::Tools,
240 crate::tui::underwater::LiveActivityKind::UsingSubagents => Self::Subagents,
241 crate::tui::underwater::LiveActivityKind::Verifying => Self::Verifying,
242 _ => Self::Baseline,
243 }
244 }
245 }
246
247 /// Render ambient life into empty water cells of `area`.
248 ///
249 /// Returns per-frame budget counters for tests and debug tooling; the
250 /// counting itself is a few `u32` increments, never an allocation.
251 #[allow(clippy::too_many_arguments)]
252 pub fn render_ambient_life(
253 area: Rect,
254 buf: &mut Buffer,
255 inks: (Color, Color),
256 lines: &[Line<'static>],
257 elapsed_ms: u128,
258 presence: f32,
259 cursor: AmbientCursor,
260 whale: WhaleCameo,
261 activity: AmbientActivity,
262 ) -> AmbientFrameStats {
263 if area.width < AMBIENT_MIN_WIDTH
264 || area.height < AMBIENT_MIN_HEIGHT
265 || !presence.is_finite()
266 || presence <= 0.0
267 {
268 return AmbientFrameStats::default();
269 }
270
271 // Geometry always samples the same clock. Scaling its absolute age when
272 // activity changes teleports the scene; activity already owns ink/cameos.
273 let density = LifeDensity::from_area(area);
274 let mut stats = AmbientFrameStats::default();
275 // Positions always ride the live monotonic clock; `presence` fades the
276 // marks in and out, so the animated/static boundary eases instead of
277 // snapping fish between t=0 and their mid-path positions.
278 let frame = build_frame_marks(
279 area,
280 elapsed_ms,
281 density,
282 lines,
283 cursor,
284 crate::tui::color_compat::ascii_safe_enabled(),
285 &mut stats,
286 );
287 paint_marks(area, buf, inks, lines, &frame, presence, &mut stats);
288 pet_cameo::paint(
289 area, buf, inks.0, lines, presence, whale, activity, &mut stats,
290 );
291 stats
292 }
293
294 #[allow(clippy::too_many_arguments)]
295 fn build_frame_marks(
296 area: Rect,
297 elapsed_ms: u128,
298 density: LifeDensity,
299 lines: &[Line<'static>],
300 cursor: AmbientCursor,
301 ascii_safe: bool,
302 stats: &mut AmbientFrameStats,
303 ) -> FrameMarks {
304 let mut marks = Vec::with_capacity(48);
305 let t = elapsed_ms;
306
307 // Where the water is. The old rule was a guess at where the composition
308 // sat — fifths of the field — and it was wrong on every real screen: at
309 // 80×24 it reserved two rows in the middle of the field while the
310 // wordmark, caption, and invitation lived three rows lower, so a fish
311 // surfaced in the one-row gap between the caption and the invitation.
312 // Now the field is measured, not guessed: [`is_open_water`] asks the
313 // rendered lines directly.
314 let water = |x: u16, y: u16, width: u16| is_open_water(lines, x, y, width);
315
316 // --- One loose fish school along the floor ---
317 // The school enters one edge, crosses, and exits the other; direction
318 // may only change while it is fully off-screen, so facing always equals
319 // velocity. A travelling sin² brightness wave runs through the wedge.
320 let school_size = density.school_size().min(SCHOOL_WEDGE.len());
321 let school_span = SCHOOL_WEDGE
322 .iter()
323 .take(school_size)
324 .map(|(_, dx)| *dx)
325 .max()
326 .unwrap_or(0)
327 .saturating_add(LEAD_FISH_RIGHT.len() as u16);
328 let travel = u128::from(area.width.saturating_add(school_span).max(1));
329 let cycle_ms = travel.saturating_mul(SCHOOL_CELL_MS);
330 // Half-cycle head start: freshly opened water shows the school
331 // mid-crossing instead of an empty entry beat.
332 let school_clock = t.saturating_add(cycle_ms / 2);
333 let cycle_index = school_clock / cycle_ms;
334 let cycle_frac = (school_clock % cycle_ms) as f64 / cycle_ms as f64;
335 let cycle_step = (cycle_frac * travel as f64).round() as i32;
336 let cycle_dot_step = (cycle_frac * travel as f64 * 2.0).floor() as i32;
337 let swims_right = school_swims_right(cycle_index);
338 // The school has one home: the deep water just off the floor. It used to
339 // alternate between an upper and a lower band, which is most of why the
340 // aquarium read as decoration sprinkled over the whole field instead of
341 // as depth beneath it. Direction still alternates — that is the part a
342 // viewer reads as "the fish came back" — but the band does not.
343 let anchor_y = school_band_row(area);
344 let ptr = cursor.column.saturating_sub(area.x);
345 let ptr_y = cursor.row.saturating_sub(area.y);
346 for (m, (dy, dx)) in SCHOOL_WEDGE.iter().take(school_size).enumerate() {
347 let ascii_body = fish_body(swims_right, m == 0);
348 let body_w = if ascii_safe {
349 ascii_body.width() as u16
350 } else {
351 4
352 };
353 // Nose position in wrap space; trailers sit `dx` columns behind the
354 // lead relative to travel, so the wedge follows instead of leading.
355 // Right-swimmers enter from the left edge, left-swimmers from the
356 // right edge — both facing exactly the way they move.
357 let x_dots = if swims_right {
358 cycle_dot_step - i32::from(*dx) * 2 - i32::from(body_w) * 2
359 } else {
360 i32::from(area.width) * 2 - cycle_dot_step + i32::from(*dx) * 2
361 };
362 let mut x_i32 = if ascii_safe {
363 if swims_right {
364 cycle_step - i32::from(*dx) - i32::from(body_w)
365 } else {
366 i32::from(area.width) - cycle_step + i32::from(*dx)
367 }
368 } else {
369 x_dots.div_euclid(2)
370 };
371 // Native fish bob by one dot inside a cell, never by a whole text row.
372 let bob = sine_bob(t, 3_400 + (m as u128) * 640, 1);
373 let y_i32 =
374 i32::from(anchor_y) + i32::from(*dy) + if ascii_safe { i32::from(bob) } else { 0 };
375 let body = if ascii_safe {
376 ascii_body
377 } else {
378 native_poses::fish(
379 swims_right,
380 ((t / 300 + m as u128) % 4) as usize,
381 x_dots.rem_euclid(2) as usize,
382 usize::from(bob),
383 )
384 };
385 // Fish dart sideways away from the scatter anchor (nearby only).
386 if let Some(flee_ms) = cursor.flee_elapsed_ms {
387 let flee = i32::from(fish_flee_offset(flee_ms));
388 if x_i32.abs_diff(i32::from(ptr)) < 16 && y_i32.abs_diff(i32::from(ptr_y)) < 6 {
389 // Horizontal only. The old ±1 row kick pushed the outer
390 // fish off the school's band and straight into the row the
391 // composition had already claimed, so a scatter punched a
392 // hole in the wedge exactly when the eye was on it.
393 if x_i32 >= i32::from(ptr) {
394 x_i32 += flee;
395 } else {
396 x_i32 -= flee;
397 }
398 }
399 }
400 let max_x = i32::from(area.width.saturating_sub(body_w));
401 let max_y = i32::from(area.height.saturating_sub(1));
402 if x_i32 < 0 || x_i32 > max_x || y_i32 < 0 || y_i32 > max_y {
403 continue; // off-screen while wrapping
404 }
405 let y = y_i32 as u16;
406 // Never swim through the composition or the row of air around it.
407 if !water(x_i32 as u16, y, body_w) {
408 continue;
409 }
410 let brightness = FISH_BRIGHTNESS_FLOOR
411 + (1.0 - FISH_BRIGHTNESS_FLOOR)
412 * wave01(t, FISH_WAVE_MS, (m as u128).saturating_mul(320));
413 marks.push(AmbientMark {
414 x: x_i32 as u16,
415 y,
416 glyph: body,
417 jellyfish: None,
418 depth: if m == 0 {
419 Depth::Foreground
420 } else {
421 Depth::Midground
422 },
423 style_mod: None,
424 brightness: Some(brightness),
425 });
426 }
427
428 // --- Jellyfish: a pulsing dome with lagging tentacles ---
429 // Native braille shapes have a contracting bell and a wave travelling
430 // down two trailing arms; fractional placement still fits the 5×3-cell
431 // habitat. ASCII keeps two dome rows above two swaying strokes, with a
432 // 3-cell compact silhouette. Both representations share the rare visit,
433 // shallow glow and whole-silhouette clearance below.
434 //
435 // It only visits water deep enough to hold it: three rows of silhouette,
436 // a row of clear water, and the school's own band, measured up from the
437 // floor. At 80×24 the composition leaves four rows of water and the
438 // jellyfish used to land inside the school — a five-cell pulsing
439 // silhouette and a wedge of fish sharing four rows of a 24-row terminal
440 // is the definition of not earning the space. Below the budget it simply
441 // does not come up.
442 let jellyfish_count = density.jellyfish_count();
443 for j in 0..jellyfish_count {
444 let phase = 3_100u128.saturating_add((j as u128) * 4_700);
445 let lane_x = if j % 2 == 0 {
446 area.width.saturating_mul(5) / 6
447 } else {
448 area.width / 6
449 };
450 let wobble = sine_bob(t, 5_200 + phase, 1);
451 let compact = density == LifeDensity::Sparse;
452 let (dome_top, dome_skirt, tentacle_cols): (&[&str], &[&str], &[u16]) = if compact {
453 (JELLY_DOME_TOP_COMPACT, JELLY_DOME_SKIRT_COMPACT, &[0, 2])
454 } else {
455 // Two tentacles hanging from the rim, not three abreast. Three
456 // adjacent one-cell strokes spend most of their sway table
457 // rendering as `||\` or `|||` — a solid bar of punctuation under
458 // the bell, which is what the dogfood frame actually showed.
459 (JELLY_DOME_TOP_FRAMES, JELLY_DOME_SKIRT_FRAMES, &[1, 3])
460 };
461 let dome_w = if ascii_safe {
462 dome_top[0].width() as u16
463 } else {
464 5
465 };
466 let wobble_dots = sine_bob(t, 5_200 + phase, 2);
467 let x = lane_x
468 .saturating_add(if ascii_safe { wobble } else { wobble_dots / 2 })
469 .min(area.width.saturating_sub(dome_w + 1));
470 if deep_water_rows(area, lines, x, dome_w) < JELLY_MIN_DEEP_ROWS {
471 continue;
472 }
473 // A visit is a short, slow rise near the floor followed by a long
474 // absence: the jelly climbs [`JELLY_VISIT_ROWS`] rows and then spends
475 // the rest of the cycle out of sight. Native movement samples quarter
476 // rows; the ASCII fallback retains its slow whole-row steps.
477 let rise_period = JELLY_RISE_ROW_MS.saturating_add((j as u128) * JELLY_RISE_ROW_STAGGER_MS);
478 let cycle_duration = rise_period.saturating_mul(JELLY_VISIT_CYCLE_SLOTS);
479 let cycle_pos = t.saturating_add(phase) % cycle_duration;
480 let visit_duration = rise_period.saturating_mul(u128::from(JELLY_VISIT_ROWS));
481 if cycle_pos >= visit_duration {
482 continue; // still down in the dark between visits
483 }
484 let visit_progress = cycle_pos as f64 / visit_duration as f64;
485 let risen = (visit_progress * f64::from(JELLY_VISIT_ROWS)).round() as u16;
486 let y_dots = i32::from(area.height.saturating_sub(JELLY_FLOOR_GAP)) * 4
487 - (visit_progress * f64::from(JELLY_VISIT_ROWS) * 4.0).floor() as i32;
488 let y = if ascii_safe {
489 area.height
490 .saturating_sub(JELLY_FLOOR_GAP)
491 .saturating_sub(risen)
492 } else {
493 y_dots.div_euclid(4).max(0) as u16
494 };
495 if y == 0 || !water(x, y, dome_w) {
496 continue;
497 }
498 let dome_pulse = wave01(t, JELLY_PULSE_MS, phase);
499 let dome_brightness = jelly_glow(dome_pulse);
500 let tentacle_pulse = wave01(
501 t.saturating_sub(JELLY_TENTACLE_LAG_MS),
502 JELLY_PULSE_MS,
503 phase,
504 );
505 let tentacle_brightness = jelly_glow(tentacle_pulse);
506 // The dome opens/closes on the smooth continuous phase curve; the parked
507 // pose holds the half-pulsed (contracted) frame.
508 let pulse_frame = usize::from(dome_pulse > 0.5);
509 let skirt_row = y.saturating_add(1);
510 let tentacle_row = y.saturating_add(2);
511 // Treat the silhouette as one visual unit. The former per-row quiet
512 // band checks deliberately allowed the dome, skirt, or tentacles to
513 // disappear independently, which is exactly the broken punctuation
514 // visible in the v0.9.2 dogfood screenshot.
515 if tentacle_row >= area.height
516 || ![y, skirt_row, tentacle_row]
517 .into_iter()
518 .all(|row| water(x, row, dome_w))
519 {
520 continue;
521 }
522 if !ascii_safe {
523 let pose = ((t.saturating_add(phase) % JELLY_PULSE_MS) * 16 / JELLY_PULSE_MS) as usize;
524 for (row, glyph) in native_poses::jelly(
525 pose,
526 usize::from(wobble_dots % 2),
527 y_dots.rem_euclid(4) as usize,
528 )
529 .iter()
530 .enumerate()
531 {
532 marks.push(AmbientMark {
533 x,
534 y: y + row as u16,
535 glyph,
536 jellyfish: Some(j),
537 depth: Depth::Background,
538 style_mod: None,
539 brightness: Some(if row == 0 {
540 dome_brightness
541 } else {
542 tentacle_brightness
543 }),
544 });
545 }
546 continue;
547 }
548 for (row, glyph) in [
549 (y, dome_top[pulse_frame]),
550 (skirt_row, dome_skirt[pulse_frame]),
551 ] {
552 marks.push(AmbientMark {
553 x,
554 y: row,
555 glyph,
556 jellyfish: Some(j),
557 // Background ink, same as the tentacles: the dome used to sit
558 // a layer nearer than everything else in the side lanes,
559 // which is most of why it drew the eye.
560 depth: Depth::Background,
561 style_mod: None,
562 brightness: Some(dome_brightness),
563 });
564 }
565 for (col, &dx) in tentacle_cols.iter().enumerate() {
566 // Each column runs the sway table with its own phase offset
567 // so the trio lags left-to-right; the parked pose holds a
568 // mid-sway frame.
569 let frame = t
570 .saturating_add(phase)
571 .saturating_add((col as u128) * JELLY_TENTACLE_PHASE_STEP_MS)
572 / JELLY_TENTACLE_SWAY_MS;
573 let sway = JELLY_TENTACLE_FRAMES[(frame as usize) % JELLY_TENTACLE_FRAMES.len()];
574 marks.push(AmbientMark {
575 x: x.saturating_add(dx),
576 y: tentacle_row,
577 glyph: sway,
578 jellyfish: Some(j),
579 depth: Depth::Background,
580 style_mod: None,
581 brightness: Some(tentacle_brightness),
582 });
583 }
584 }
585
586 // --- Marine snow & rising bubble streams floating upward ---
587 // Floating particles rise smoothly through the water column, dissolving
588 // gently with continuous time-based floating physics.
589 for b in 0..density.bubble_streams() {
590 let phase = (b as u128).saturating_mul(1_900);
591 // Edge columns — avoid center brand.
592 let column = if b % 2 == 0 {
593 area.width / 8
594 } else {
595 area.width.saturating_mul(7) / 8
596 };
597 let rise_period = BUBBLE_RISE_MS.saturating_add(phase % 900);
598 let cycle = (t.saturating_add(phase) % rise_period) as f64 / rise_period as f64;
599 let boost = if cursor.flee_elapsed_ms.is_some() && column.abs_diff(ptr) < 10 {
600 2
601 } else {
602 0
603 };
604 // Continuous horizontal floating drift
605 let drift_phase = (t.saturating_add(phase) as f64 / 2_100.0) * std::f64::consts::TAU;
606 let drift = (drift_phase.sin() * 0.6).round() as i16;
607 let col = (column as i16 + drift).clamp(0, (area.width.saturating_sub(1)) as i16) as u16;
608
609 let rise = ((cycle * f64::from(BUBBLE_MAX_RISE_ROWS)).round() as u16)
610 .saturating_add(boost)
611 .min(BUBBLE_MAX_RISE_ROWS);
612 let y = area.height.saturating_sub(2).saturating_sub(rise);
613 if !water(col, y, 1) {
614 continue;
615 }
616 // Size is a function of height risen, not of discrete clock jumps.
617 let glyph = bubble_glyph(rise);
618 let brightness = glint01(
619 t,
620 BUBBLE_GLINT_MS.saturating_add(phase % 700),
621 600,
622 BUBBLE_BRIGHTNESS_FLOOR,
623 phase,
624 ) * bubble_dissolve(rise);
625 marks.push(AmbientMark {
626 x: col,
627 y,
628 glyph,
629 jellyfish: None,
630 depth: Depth::Foreground,
631 style_mod: None,
632 brightness: Some(brightness),
633 });
634 }
635
636 stats.marks_built = marks.len() as u32;
637 FrameMarks { marks }
638 }
639
640 /// Loose diagonal wedge for the school: `(row_offset, columns_behind_lead)`.
641 /// The slight row spread is what makes it read as a school, not a text row.
642 ///
643 /// Three rows, not five. The ±2 rows put the wedge across a fifth of a 24-row
644 /// terminal, which reads as fish scattered over the screen rather than as one
645 /// shoal; at ±1 (plus each fish's own bob) the school still has depth but
646 /// stays a single object the eye can take in at once.
647 const SCHOOL_WEDGE: &[(i16, u16)] = &[(0, 0), (-1, 4), (1, 6), (-1, 9), (1, 11), (0, 14), (-1, 17)];
648
649 /// Rows between the school's centre line and the bottom of the field. With the
650 /// ±1 wedge and a one-row bob the shoal occupies `height-4 ..= height-1`: the
651 /// deep water, clear of anything the composition is using.
652 const SCHOOL_FLOOR_GAP: u16 = 3;
653
654 /// The row the school centres on, in field-local coordinates. Public so the
655 /// compositor can aim a scatter at the shoal instead of guessing where it is.
656 #[must_use]
657 pub fn school_band_row(area: Rect) -> u16 {
658 area.height.saturating_sub(SCHOOL_FLOOR_GAP)
659 }
660
661 /// Wall-clock milliseconds per column of school travel (~2.6 cells/s).
662 const SCHOOL_CELL_MS: u128 = 380;
663 /// Travelling brightness-wave period through the wedge.
664 const FISH_WAVE_MS: u128 = 2_200;
665 /// Fish are small: never let one sink into the gradient.
666 const FISH_BRIGHTNESS_FLOOR: f32 = 0.45;
667
668 /// Lead fish silhouettes (ASCII only — width == len). Members drop the eye.
669 const LEAD_FISH_RIGHT: &str = "><o>";
670 const LEAD_FISH_LEFT: &str = "<o><";
671
672 /// Jellyfish silhouette frames — pure ASCII by construction so the
673 /// ascii_safe tier needs no fallback mapping for them (len == width).
674 ///
675 /// Full dome (Rich/Normal), two rows with an open/closed pulse pair: a
676 /// rounded arc over the bell's rim.
677 ///
678 /// The skirt is the bell's lower rim and nothing else: it carries the pulse by
679 /// flaring (`\` `/`) and contracting (`(` `)`), the way a real bell swims. It
680 /// holds no interior glyphs on purpose — an earlier pair put marks inside the
681 /// rim (`(v_v)` / `(v.v)`), which read as two eyes and a mouth. The motion the
682 /// silhouette is meant to sell lives in the tentacle row below, not in the
683 /// skirt.
684 ///
685 /// Both contracted frames are left-right symmetric on purpose. The former
686 /// `.'-.'` and `'.'` were not — a dot on one side and an apostrophe on the
687 /// other — and an asymmetric five-cell arc does not read as a bell at all; in
688 /// the 80×24 dogfood frame it read as three unrelated rows of punctuation.
689 const JELLY_DOME_TOP_FRAMES: &[&str] = &[".-~-.", ".'-'."];
690 const JELLY_DOME_SKIRT_FRAMES: &[&str] = &["\\___/", "(___)"];
691 /// Compact dome for the Sparse (narrow) tier: same two-row read at 3 cells.
692 const JELLY_DOME_TOP_COMPACT: &[&str] = &[".-.", "'-'"];
693 const JELLY_DOME_SKIRT_COMPACT: &[&str] = &["\\_/", "(_)"];
694 /// Tentacle sway frames (all width-1). Each column runs the same table with
695 /// a phase offset so the pair lags instead of strobing in sync.
696 const JELLY_TENTACLE_FRAMES: &[&str] = &["|", "/", "|", "\\"];
697
698 /// How far sideways a jellyfish may dodge to clear transcript text before it
699 /// is withheld for the frame instead.
700 ///
701 /// Placement is a pure function of the text under the silhouette, so during a
702 /// fast stream it is effectively a function of token throughput: a growing
703 /// line pushes the anchor one column per character, and a wrap or a scroll
704 /// collapses that row's occupied bounds and snaps the anchor back tens of
705 /// columns in a single frame. On screen that reads as teleporting, and it only
706 /// shows up on models fast enough to change those bounds every frame — which
707 /// is why slow providers never surfaced it.
708 ///
709 /// Bounding the dodge keeps the behavior the silhouette was actually given
710 /// (ease around a word that happens to brush its lane) and turns everything
711 /// larger into the same quiet withhold the fish already use. Worst-case
712 /// frame-to-frame movement is therefore `2 * JELLY_MAX_TEXT_DODGE_COLS`, at
713 /// the single moment a left-hand candidate overtakes a right-hand one.
714 const JELLY_MAX_TEXT_DODGE_COLS: u16 = 3;
715
716 // --- Jellyfish rarity ------------------------------------------------------
717 // The jellyfish is the loudest thing in the water: a five-cell silhouette that
718 // changes glyph as it pulses, parked in a side lane. Before v0.9.4 it was also
719 // permanently resident, which is the combination that made it obnoxious rather
720 // than incidental. Everything below is one knob with one stated intent, so the
721 // balance can be retuned without re-deriving it from the motion code.
722
723 /// Wall-clock milliseconds a jellyfish spends on each row of its rise
724 /// (~9.4 s). Native placement samples quarter rows within this duration;
725 /// ASCII-safe placement keeps the original slow whole-row cadence.
726 const JELLY_RISE_ROW_MS: u128 = 9_400;
727 /// Per-jelly rise-rate stagger, so two jellyfish (should a tier ever want
728 /// them again) can never step in lockstep.
729 const JELLY_RISE_ROW_STAGGER_MS: u128 = 1_400;
730 /// Rows climbed in a single visit — about 56 s of presence.
731 const JELLY_VISIT_ROWS: u16 = 6;
732 /// Rows between the jellyfish's dome and the bottom of the field. The
733 /// silhouette is three rows tall, so this leaves exactly one row of clear
734 /// water between its tentacles and the top of the school's band — the
735 /// jellyfish is a visitor in the same water, not a passenger on the shoal.
736 const JELLY_FLOOR_GAP: u16 = 8;
737 /// Unbroken water rows (measured up from the floor) a jellyfish needs before
738 /// it will surface at all: its own three rows, the gap, and the school's band.
739 /// Same number as [`JELLY_FLOOR_GAP`] by construction — the dome's row is the
740 /// deepest row it touches.
741 const JELLY_MIN_DEEP_ROWS: u16 = JELLY_FLOOR_GAP;
742 /// Row-slots in one full visit cycle. Slots at or past [`JELLY_VISIT_ROWS`]
743 /// are spent out of sight, and that gap is *the* rarity knob: at 32 slots the
744 /// cycle is ~5 min and a jellyfish is present under a fifth of the time —
745 /// occasionally noticed, never resident. Raise it to make them rarer; lower
746 /// it to bring them back. It must stay `> JELLY_VISIT_ROWS` or the jelly
747 /// becomes permanent again.
748 const JELLY_VISIT_CYCLE_SLOTS: u128 = 32;
749
750 // --- Jellyfish motion and glow ---------------------------------------------
751
752 /// Dome pulse period. Slow on purpose: a pulse fast enough to notice in
753 /// peripheral vision is a pulse that interrupts reading.
754 const JELLY_PULSE_MS: u128 = 5_200;
755 /// The tentacles repeat the dome pulse this much later. Held at ~12% of
756 /// [`JELLY_PULSE_MS`] — the lag is what sells "jellyfish", so it scales with
757 /// the pulse rather than staying an absolute number.
758 const JELLY_TENTACLE_LAG_MS: u128 = 620;
759 /// Wall-clock milliseconds per tentacle sway frame.
760 const JELLY_TENTACLE_SWAY_MS: u128 = 2_600;
761 /// Per-column sway phase offset, so the two tentacles never move in sync.
762 /// Keep this a non-divisor of [`JELLY_TENTACLE_SWAY_MS`] or the pair strobes.
763 const JELLY_TENTACLE_PHASE_STEP_MS: u128 = 700;
764 /// Dimmest point of the pulse: still legible against the water, no lower.
765 const JELLY_BRIGHTNESS_FLOOR: f32 = 0.28;
766 /// Brightest point of the pulse. Deliberately well short of full ink — the
767 /// jellyfish used to swing floor-to-1.0, and that swing (not its presence)
768 /// is what pulled the eye off the transcript.
769 const JELLY_BRIGHTNESS_CEIL: f32 = 0.62;
770
771 /// Map a `[0, 1]` pulse onto the jellyfish's shallow glow band.
772 #[must_use]
773 fn jelly_glow(pulse: f32) -> f32 {
774 JELLY_BRIGHTNESS_FLOOR + (JELLY_BRIGHTNESS_CEIL - JELLY_BRIGHTNESS_FLOOR) * pulse
775 }
776
777 /// Bubbles stay mostly steady with occasional glints, not a constant wave.
778 const BUBBLE_BRIGHTNESS_FLOOR: f32 = 0.55;
779 /// Rows a bubble climbs before it dissolves. Short on purpose: a bubble that
780 /// crosses the whole field is a moving speck with no source and no end.
781 const BUBBLE_MAX_RISE_ROWS: u16 = 5;
782 /// Wall-clock milliseconds for one bubble to make that climb.
783 const BUBBLE_RISE_MS: u128 = 3_200;
784 /// Base period of the raised-cosine glint.
785 const BUBBLE_GLINT_MS: u128 = 2_600;
786 /// How much of its brightness a bubble keeps at the top of its rise.
787 const BUBBLE_DISSOLVE_CEIL: f32 = 0.25;
788
789 /// Bubbles grow as they rise. Keyed to height, never to the clock.
790 #[must_use]
791 fn bubble_glyph(rise: u16) -> &'static str {
792 match rise {
793 0..=1 => "·",
794 2..=3 => "˚",
795 _ => "°",
796 }
797 }
798
799 /// Linear fade across the rise: full at the floor, nearly gone at the top.
800 #[must_use]
801 fn bubble_dissolve(rise: u16) -> f32 {
802 let span = f32::from(BUBBLE_MAX_RISE_ROWS.max(1));
803 let remaining = f32::from(BUBBLE_MAX_RISE_ROWS.saturating_sub(rise)) / span;
804 BUBBLE_DISSOLVE_CEIL + (1.0 - BUBBLE_DISSOLVE_CEIL) * remaining
805 }
806
807 /// One soft sin² hump per `period_ms`, wall-clock keyed, in `[0, 1]`.
808 #[must_use]
809 fn wave01(elapsed_ms: u128, period_ms: u128, phase_ms: u128) -> f32 {
810 if period_ms == 0 {
811 return 1.0;
812 }
813 let frac = (elapsed_ms.saturating_add(phase_ms) % period_ms) as f64 / period_ms as f64;
814 let s = (frac * std::f64::consts::PI).sin();
815 (s * s) as f32
816 }
817
818 /// Mostly `floor`, with a raised-cosine glint to full brightness for
819 /// `glint_ms` out of every `period_ms`.
820 #[must_use]
821 fn glint01(elapsed_ms: u128, period_ms: u128, glint_ms: u128, floor: f32, phase_ms: u128) -> f32 {
822 if period_ms == 0 || glint_ms == 0 {
823 return floor;
824 }
825 let pos = elapsed_ms.saturating_add(phase_ms) % period_ms;
826 if pos >= glint_ms {
827 return floor;
828 }
829 let frac = pos as f64 / glint_ms as f64;
830 let bump = 0.5 * (1.0 - (frac * std::f64::consts::TAU).cos());
831 floor + (1.0 - floor) * bump as f32
832 }
833
834 /// Stateless per-crossing travel direction. Direction only ever changes
835 /// between cycles — while the school is fully off-screen — so a turn is
836 /// never visible as an in-place flip.
837 #[must_use]
838 fn school_swims_right(cycle_index: u128) -> bool {
839 (cycle_index.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 7) & 1 == 0
840 }
841
842 /// Rows of clear air the composition keeps on each side of every line it
843 /// writes. One row is enough: it is the difference between a fish swimming
844 /// *behind* a block of text and a fish surfacing in the gap between two of its
845 /// lines, which is what the 80×24 frame showed between the caption and the
846 /// invitation.
847 const TEXT_CLEARANCE_ROWS: u16 = 1;
848
849 /// True when the horizontal span at `(x, y)` — and the same span on every row
850 /// within [`TEXT_CLEARANCE_ROWS`] — carries no rendered text. One column of
851 /// horizontal air is reserved on both sides so a fish never touches the prose,
852 /// while short left-aligned transcript lines still leave real water to their
853 /// right.
854 #[must_use]
855 fn is_open_water(lines: &[Line<'_>], x: u16, y: u16, width: u16) -> bool {
856 let first = usize::from(y.saturating_sub(TEXT_CLEARANCE_ROWS));
857 let last = usize::from(y.saturating_add(TEXT_CLEARANCE_ROWS));
858 !(first..=last).any(|row| {
859 lines
860 .get(row)
861 .and_then(occupied_text_bounds)
862 .is_some_and(|(start, end)| span_touches_text(x, width, start, end))
863 })
864 }
865
866 #[must_use]
867 fn span_touches_text(x: u16, width: u16, start: usize, end: usize) -> bool {
868 usize::from(x) < end.saturating_add(1)
869 && usize::from(x).saturating_add(usize::from(width)) > start.saturating_sub(1)
870 }
871
872 /// Unbroken open-water rows measured up from the bottom of the field: how much
873 /// deep water the composition has left for the aquarium to live in.
874 #[must_use]
875 fn deep_water_rows(area: Rect, lines: &[Line<'_>], x: u16, width: u16) -> u16 {
876 let mut rows = 0u16;
877 let mut y = area.height;
878 while y > 0 {
879 y -= 1;
880 if !is_open_water(lines, x, y, width) {
881 break;
882 }
883 rows = rows.saturating_add(1);
884 }
885 rows
886 }
887
888 fn paint_marks(
889 area: Rect,
890 buf: &mut Buffer,
891 inks: (Color, Color),
892 lines: &[Line<'static>],
893 frame: &FrameMarks,
894 presence: f32,
895 stats: &mut AmbientFrameStats,
896 ) {
897 if presence <= 0.0 {
898 // Fully static water: nothing to paint (all marks invisible).
899 return;
900 }
901 let presence = presence.clamp(0.0, 1.0);
902 #[derive(Clone, Copy)]
903 enum SkipReason {
904 Text,
905 Clipped,
906 }
907
908 #[derive(Clone, Copy)]
909 enum Placement {
910 Anchor { original: u16, placed: u16 },
911 Skip(SkipReason),
912 }
913 let mut placements: [Option<Placement>; 2] = [None, None];
914 let population_overflow = frame
915 .marks
916 .iter()
917 .filter_map(|mark| mark.jellyfish)
918 .any(|jellyfish| jellyfish >= placements.len());
919 debug_assert!(
920 !population_overflow,
921 "jellyfish population exceeded its bound"
922 );
923 for (jellyfish, placement) in placements.iter_mut().enumerate() {
924 let marks = || {
925 frame
926 .marks
927 .iter()
928 .filter(move |mark| mark.jellyfish == Some(jellyfish))
929 };
930 let Some(original) = marks().map(|mark| mark.x).min() else {
931 continue;
932 };
933 let mut group_end = 0u16;
934 for mark in marks() {
935 let offset = mark.x.saturating_sub(original);
936 let width = u16::try_from(UnicodeWidthStr::width(mark.glyph)).unwrap_or(u16::MAX);
937 group_end = group_end.max(offset.saturating_add(width));
938 }
939 let Some(right_edge) = area.width.checked_sub(group_end) else {
940 *placement = Some(Placement::Skip(SkipReason::Clipped));
941 continue;
942 };
943
944 let mut best: Option<(u16, u16)> = None;
945 let mut consider = |candidate: i64| {
946 let Ok(candidate) = u16::try_from(candidate) else {
947 return;
948 };
949 // Bounded dodge. Anything further than the cap is a relocation
950 // rather than a drift, so it is refused here and the silhouette
951 // is withheld instead — see [`JELLY_MAX_TEXT_DODGE_COLS`].
952 let dodge = candidate.abs_diff(original);
953 if dodge > JELLY_MAX_TEXT_DODGE_COLS {
954 return;
955 }
956 let fits = candidate <= right_edge
957 && marks().all(|mark| {
958 let x = candidate.saturating_add(mark.x.saturating_sub(original));
959 let width =
960 u16::try_from(UnicodeWidthStr::width(mark.glyph)).unwrap_or(u16::MAX);
961 is_open_water(lines, x, mark.y, width)
962 });
963 if fits {
964 let ranked = (dodge, candidate);
965 if best.is_none_or(|current| ranked < current) {
966 best = Some(ranked);
967 }
968 }
969 };
970 consider(i64::from(original));
971 consider(0);
972 consider(i64::from(right_edge));
973 for mark in marks() {
974 let offset = mark.x.saturating_sub(original);
975 let mark_end = offset.saturating_add(
976 u16::try_from(UnicodeWidthStr::width(mark.glyph)).unwrap_or(u16::MAX),
977 );
978 let first = usize::from(mark.y.saturating_sub(TEXT_CLEARANCE_ROWS));
979 let last = usize::from(mark.y.saturating_add(TEXT_CLEARANCE_ROWS));
980 for (start, end) in
981 (first..=last).filter_map(|row| lines.get(row).and_then(occupied_text_bounds))
982 {
983 if let Ok(start) = i64::try_from(start) {
984 consider(start - 1 - i64::from(mark_end));
985 }
986 if let Ok(end) = i64::try_from(end) {
987 consider(end + 1 - i64::from(offset));
988 }
989 }
990 }
991 *placement = Some(match best {
992 Some((_, placed)) => Placement::Anchor { original, placed },
993 None => Placement::Skip(SkipReason::Text),
994 });
995 }
996
997 for mark in &frame.marks {
998 let mark_placement = mark
999 .jellyfish
1000 .map(|index| placements.get(index).copied().flatten());
1001 let (mark_x, preflighted) = match mark_placement {
1002 Some(None) => {
1003 stats.marks_clipped += 1;
1004 continue;
1005 }
1006 Some(Some(Placement::Anchor { original, placed })) => (
1007 placed
1008 .checked_add(mark.x.saturating_sub(original))
1009 .expect("preflight accepted a clipped jellyfish"),
1010 true,
1011 ),
1012 Some(Some(Placement::Skip(SkipReason::Text))) => {
1013 stats.marks_skipped_text += 1;
1014 continue;
1015 }
1016 Some(Some(Placement::Skip(SkipReason::Clipped))) => {
1017 stats.marks_clipped += 1;
1018 continue;
1019 }
1020 None => (mark.x, false),
1021 };
1022 if !preflighted {
1023 let mark_width = UnicodeWidthStr::width(mark.glyph);
1024 // Clipped is checked before text collision so a mark that fails
1025 // both is charged to the bound it could never satisfy.
1026 if mark_x.saturating_add(mark_width as u16) > area.width {
1027 stats.marks_clipped += 1;
1028 continue;
1029 }
1030 if !is_open_water(
1031 lines,
1032 mark_x,
1033 mark.y,
1034 u16::try_from(mark_width).unwrap_or(u16::MAX),
1035 ) {
1036 stats.marks_skipped_text += 1;
1037 continue;
1038 }
1039 }
1040 stats.marks_painted += 1;
1041 let ink = if mark.depth.ink_index() == 1 {
1042 inks.1
1043 } else {
1044 inks.0
1045 };
1046 for (offset, ch) in mark.glyph.chars().enumerate() {
1047 let cell = &mut buf[(area.x + mark_x + offset as u16, area.y + mark.y)];
1048 // Glow language: lerp the mark's ink up from the water the cell
1049 // already sits in, at the entity's time-varying brightness. The
1050 // overall lerp is additionally scaled by life presence so marks
1051 // fade in/out with the animated/static boundary.
1052 let fg = match (mark.brightness, cell.style().bg) {
1053 (Some(amount), Some(water)) => {
1054 ocean::mix_colors(water, ink, (amount * presence).clamp(0.0, 1.0))
1055 }
1056 (Some(amount), None) => ocean::scale_color(ink, amount.clamp(0.0, 1.0).max(0.4)),
1057 (None, Some(water)) => ocean::mix_colors(water, ink, presence),
1058 (None, None) => ocean::scale_color(ink, presence),
1059 };
1060 let mut style = Style::default().fg(fg);
1061 if let Some(m) = mark.style_mod {
1062 style = style.add_modifier(m);
1063 }
1064 cell.set_symbol(&ch.to_string());
1065 cell.set_style(style);
1066 stats.cells_written += 1;
1067 }
1068 }
1069 }
1070
1071 /// Width-only occupied-text measurement (no per-line String allocation).
1072 #[must_use]
1073 pub fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
1074 if line.spans.is_empty() {
1075 return None;
1076 }
1077 let mut total = 0usize;
1078 let mut leading = 0usize;
1079 let mut seen_non_ws = false;
1080 let mut trailing_run = 0usize;
1081
1082 for span in &line.spans {
1083 for ch in span.content.chars() {
1084 let w = UnicodeWidthChar::width(ch).unwrap_or(0);
1085 total = total.saturating_add(w);
1086 if ch.is_whitespace() {
1087 if !seen_non_ws {
1088 leading = leading.saturating_add(w);
1089 } else {
1090 trailing_run = trailing_run.saturating_add(w);
1091 }
1092 } else {
1093 seen_non_ws = true;
1094 trailing_run = 0;
1095 }
1096 }
1097 }
1098 if !seen_non_ws {
1099 return None;
1100 }
1101 Some((leading, total.saturating_sub(trailing_run)))
1102 }
1103
1104 #[must_use]
1105 fn sine_bob(elapsed_ms: u128, period_ms: u128, amplitude: u16) -> u16 {
1106 if period_ms == 0 || amplitude == 0 {
1107 return 0;
1108 }
1109 let phase = (elapsed_ms % period_ms) as f64 / period_ms as f64;
1110 let s = (phase * std::f64::consts::TAU).sin();
1111 // Map [-1,1] → [0, amplitude]
1112 (((s + 1.0) * 0.5) * f64::from(amplitude)).round() as u16
1113 }
1114
1115 /// One-shot flee arc keyed to Working transition / pointer motion.
1116 #[must_use]
1117 pub fn fish_flee_offset(elapsed_ms: u128) -> u16 {
1118 let progress = elapsed_ms.min(800) as f32 / 800.0;
1119 let excursion = (progress * std::f32::consts::PI).sin() * 9.0;
1120 excursion.round().clamp(0.0, 9.0) as u16
1121 }
1122
1123 /// One fish silhouette family for the whole school: the lead carries an eye
1124 /// (`><o>`), members are plain `><>`. Never mix lone `>` arrows in — that
1125 /// reads as broken punctuation. All bodies are ASCII so `len() == width`.
1126 #[must_use]
1127 fn fish_body(facing_right: bool, lead: bool) -> &'static str {
1128 match (facing_right, lead) {
1129 (true, true) => LEAD_FISH_RIGHT,
1130 (true, false) => "><>",
1131 (false, true) => LEAD_FISH_LEFT,
1132 (false, false) => "<><",
1133 }
1134 }
1135
1136 /// Subtle caustic shimmer applied to empty water cells when the field would
1137 /// otherwise read as a static ramp. Cheap: one phase lookup per cell, only
1138 /// when `animated` and density allows.
1139 pub fn apply_caustic_shimmer(
1140 area: Rect,
1141 buf: &mut Buffer,
1142 column: &OceanColumn,
1143 elapsed_ms: u128,
1144 animated: bool,
1145 lines: &[Line<'static>],
1146 ) {
1147 if !animated || area.width < AMBIENT_MIN_WIDTH || area.height < AMBIENT_MIN_HEIGHT {
1148 return;
1149 }
1150 // Sparse sampling: every 3rd column on every other row near the surface.
1151 //
1152 // The light stops where the composition starts. Sunlight raking across
1153 // the rows a wordmark is sitting in is the same failure as a fish
1154 // swimming through them, just quieter, and it costs nothing to measure:
1155 // the surface band is clipped to the first row that carries text.
1156 let ceiling = (0..area.height)
1157 .find(|row| {
1158 lines
1159 .get(usize::from(*row))
1160 .and_then(occupied_text_bounds)
1161 .is_some()
1162 })
1163 .unwrap_or(area.height);
1164 let band = (area.height / 3).max(2).min(ceiling);
1165 for local_y in 0..band {
1166 let protected = lines
1167 .get(usize::from(local_y))
1168 .and_then(occupied_text_bounds);
1169 let ramp = frame_ocean_ramp(
1170 column,
1171 area.height,
1172 area.y,
1173 elapsed_ms,
1174 column.phase_tag(),
1175 column.ramp_fingerprint(),
1176 );
1177 let row_bg = ramp
1178 .get(usize::from(local_y))
1179 .copied()
1180 .unwrap_or_else(|| column.color_at_y(area.y.saturating_add(local_y)));
1181 for local_x in (0..area.width).step_by(3) {
1182 if protected.is_some_and(|(start, end)| {
1183 usize::from(local_x) >= start && usize::from(local_x) < end
1184 }) {
1185 continue;
1186 }
1187 let cell = &mut buf[(area.x + local_x, area.y + local_y)];
1188 // Soften toward ambient ink without replacing semantic glyphs.
1189 if cell.symbol() == " " || cell.symbol().is_empty() {
1190 // Sunlight dissolves with depth instead of stopping: full
1191 // amplitude at the surface easing to zero at the band's
1192 // floor. The former hard cutoff at `band` drew a visible
1193 // horizontal line across tall windows.
1194 let depth_fade = 1.0 - f32::from(local_y) / f32::from(band.max(1));
1195 let shimmer = ocean::scale_color(
1196 row_bg,
1197 caustic_brightness(elapsed_ms, local_x, local_y, depth_fade * depth_fade),
1198 );
1199 cell.set_bg(shimmer);
1200 }
1201 }
1202 }
1203 }
1204
1205 /// Continuous travelling caustic. The former `(elapsed / 80) % 12` mask
1206 /// toggled cells fully on/off at 12.5 Hz; truecolor made that quantization look
1207 /// like dropped frames. A narrow cosine crest preserves the same sparse light
1208 /// band while cross-fading every sampled cell between frames.
1209 fn caustic_brightness(elapsed_ms: u128, local_x: u16, local_y: u16, depth_fade: f32) -> f32 {
1210 const CYCLE_MS: f64 = 960.0;
1211 const SPATIAL_SLOTS: f64 = 4.0;
1212 let time = (elapsed_ms % CYCLE_MS as u128) as f64 / CYCLE_MS;
1213 // The sampled grid advances by three terminal columns. Four grid phases
1214 // therefore preserve the old 12-column repeat instead of stretching the
1215 // caustic topology while changing only its temporal interpolation.
1216 let slot = (u32::from(local_x / 3) + u32::from(local_y)) % 4;
1217 let phase = (time + f64::from(slot) / SPATIAL_SLOTS) * std::f64::consts::TAU;
1218 let crest = ((phase.cos() + 1.0) * 0.5).powi(8);
1219 1.0 + 0.08 * (crest as f32) * depth_fade.clamp(0.0, 1.0)
1220 }
1221
1222 /// Cached ocean row colors invalidated only when phase/dimensions/palette/breath tick.
1223 /// Shared across widgets that paint the same [`OceanColumn`] within a frame.
1224 #[derive(Debug, Clone, Default)]
1225 pub struct OceanRampCache {
1226 colors: Vec<Color>,
1227 height: u16,
1228 top: u16,
1229 elapsed_bucket: u128,
1230 phase_tag: u8,
1231 ramp_fingerprint: u64,
1232 }
1233
1234 impl OceanRampCache {
1235 /// Return a per-row color ramp, recomputing only when inputs change.
1236 pub fn colors_for(
1237 &mut self,
1238 column: &OceanColumn,
1239 height: u16,
1240 top: u16,
1241 elapsed_ms: u128,
1242 phase_tag: u8,
1243 ramp_fingerprint: u64,
1244 ) -> &[Color] {
1245 // The breath and completion fade are continuous. Bucket at a 60 FPS
1246 // floor so Ghostty's smooth-motion lane is not quantized back to the
1247 // old 80 ms atmosphere cadence; slower terminals still call this only
1248 // when they actually draw.
1249 let bucket = elapsed_ms / 16;
1250 if self.colors.len() == usize::from(height)
1251 && self.height == height
1252 && self.top == top
1253 && self.elapsed_bucket == bucket
1254 && self.phase_tag == phase_tag
1255 && self.ramp_fingerprint == ramp_fingerprint
1256 {
1257 return &self.colors;
1258 }
1259 self.colors.clear();
1260 self.colors.reserve(usize::from(height));
1261 for local_y in 0..height {
1262 self.colors
1263 .push(column.color_at_y(top.saturating_add(local_y)));
1264 }
1265 self.height = height;
1266 self.top = top;
1267 self.elapsed_bucket = bucket;
1268 self.phase_tag = phase_tag;
1269 self.ramp_fingerprint = ramp_fingerprint;
1270 &self.colors
1271 }
1272 }
1273
1274 thread_local! {
1275 static FRAME_RAMP: std::cell::RefCell<OceanRampCache> =
1276 const { std::cell::RefCell::new(OceanRampCache {
1277 colors: Vec::new(),
1278 height: 0,
1279 top: 0,
1280 elapsed_bucket: 0,
1281 phase_tag: 0,
1282 ramp_fingerprint: 0,
1283 }) };
1284 }
1285
1286 /// Process-local per-frame ocean ramp shared by chat field, caustics, and
1287 /// other widgets that paint the same column.
1288 #[must_use]
1289 pub fn frame_ocean_ramp(
1290 column: &OceanColumn,
1291 height: u16,
1292 top: u16,
1293 elapsed_ms: u128,
1294 phase_tag: u8,
1295 ramp_fingerprint: u64,
1296 ) -> Vec<Color> {
1297 FRAME_RAMP.with(|cache| {
1298 cache
1299 .borrow_mut()
1300 .colors_for(column, height, top, elapsed_ms, phase_tag, ramp_fingerprint)
1301 .to_vec()
1302 })
1303 }
1304
1305 #[cfg(test)]
1306 #[path = "ambient_life/tests.rs"]
1307 mod tests;
1308
1308 lines RUST