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