use ratatui::{
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::Line,
};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::tui::ocean::{self, OceanColumn};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Depth {
Background,
Midground,
Foreground,
}
impl Depth {
#[must_use]
fn ink_index(self) -> usize {
match self {
Self::Background => 1,
Self::Midground | Self::Foreground => 0,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LifeDensity {
Sparse,
Normal,
Rich,
}
impl LifeDensity {
#[must_use]
pub fn from_area(area: Rect) -> Self {
if area.width < 56 || area.height < 12 {
Self::Sparse
} else if area.width < 88 || area.height < 20 {
Self::Normal
} else {
Self::Rich
}
}
#[must_use]
fn school_size(self) -> usize {
match self {
Self::Sparse => 3,
Self::Normal => 5,
Self::Rich => 7,
}
}
#[must_use]
fn jellyfish_count(self) -> usize {
match self {
Self::Sparse => 1,
Self::Normal => 2,
Self::Rich => 2,
}
}
#[must_use]
fn bubble_streams(self) -> usize {
match self {
Self::Sparse => 1,
Self::Normal => 2,
Self::Rich => 2,
}
}
}
pub const AMBIENT_MIN_WIDTH: u16 = crate::tui::ocean::AMBIENT_MIN_WIDTH;
pub const AMBIENT_MIN_HEIGHT: u16 = crate::tui::ocean::AMBIENT_MIN_HEIGHT;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WhaleCameoPhase {
Hidden,
Breach,
Spout,
Fluke,
Submerge,
}
#[derive(Debug, Clone)]
struct FrameMarks {
marks: Vec<AmbientMark>,
}
#[derive(Debug, Clone, Copy)]
struct AmbientMark {
x: u16,
y: u16,
glyph: &'static str,
jellyfish: Option<usize>,
depth: Depth,
style_mod: Option<Modifier>,
brightness: Option<f32>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct AmbientFrameStats {
pub marks_built: u32,
pub marks_painted: u32,
pub marks_skipped_text: u32,
pub marks_clipped: u32,
pub cells_written: u32,
}
#[allow(dead_code)]
pub const MAX_FRAME_MARKS: u32 = 24;
#[derive(Debug, Clone, Copy, Default)]
pub struct AmbientCursor {
pub column: u16,
pub row: u16,
pub flee_elapsed_ms: Option<u128>,
}
#[derive(Debug, Clone, Copy, Default)]
pub struct WhaleCameo {
pub elapsed_ms: Option<u128>,
pub anchor_x: u16,
pub anchor_y: u16,
}
const WHALE_CAMEO_MS: u128 = 2_400;
#[allow(clippy::too_many_arguments)]
pub fn render_ambient_life(
area: Rect,
buf: &mut Buffer,
inks: (Color, Color),
lines: &[Line<'static>],
elapsed_ms: u128,
animated: bool,
cursor: AmbientCursor,
whale: WhaleCameo,
) -> AmbientFrameStats {
if area.width < AMBIENT_MIN_WIDTH || area.height < AMBIENT_MIN_HEIGHT {
return AmbientFrameStats::default();
}
let density = LifeDensity::from_area(area);
let mut stats = AmbientFrameStats::default();
let frame = build_frame_marks(
area, elapsed_ms, animated, density, cursor, whale, &mut stats,
);
paint_marks(area, buf, inks, lines, &frame, &mut stats);
stats
}
fn build_frame_marks(
area: Rect,
elapsed_ms: u128,
animated: bool,
density: LifeDensity,
cursor: AmbientCursor,
whale: WhaleCameo,
stats: &mut AmbientFrameStats,
) -> FrameMarks {
let mut marks = Vec::with_capacity(48);
let t = if animated { elapsed_ms } else { 0 };
let quiet_top = (area.height / 5).max(2);
let quiet_mid_lo = area.height.saturating_mul(2) / 5;
let quiet_mid_hi = area.height.saturating_mul(3) / 5;
let school_size = density.school_size().min(SCHOOL_WEDGE.len());
let school_span = SCHOOL_WEDGE
.iter()
.take(school_size)
.map(|(_, dx)| *dx)
.max()
.unwrap_or(0)
.saturating_add(LEAD_FISH_RIGHT.len() as u16);
let travel = u128::from(area.width.saturating_add(school_span).max(1));
let cycle_ms = travel.saturating_mul(SCHOOL_CELL_MS);
let school_clock = t.saturating_add(cycle_ms / 2);
let (cycle_index, cycle_step) = if animated {
(
school_clock / cycle_ms,
((school_clock % cycle_ms) / SCHOOL_CELL_MS) as i32,
)
} else {
(0, (travel / 2) as i32)
};
let swims_right = !animated || school_swims_right(cycle_index);
let swims_low = school_swims_low(cycle_index);
let anchor_y = if swims_low {
area.height.saturating_mul(3) / 4
} else {
quiet_top.saturating_add(1)
};
let ptr = cursor.column.saturating_sub(area.x);
let ptr_y = cursor.row.saturating_sub(area.y);
for (m, (dy, dx)) in SCHOOL_WEDGE.iter().take(school_size).enumerate() {
let body = fish_body(swims_right, m == 0);
let body_w = body.len() as u16; let mut x_i32 = if swims_right {
cycle_step - 1 - i32::from(*dx) - (i32::from(body_w) - 1)
} else {
i32::from(area.width) - cycle_step + i32::from(*dx)
};
let bob = if animated {
sine_bob(t, 3_400 + (m as u128) * 640, 1)
} else {
0
};
let mut y_i32 = i32::from(anchor_y) + i32::from(*dy) + i32::from(bob);
if let Some(flee_ms) = cursor.flee_elapsed_ms {
let flee = i32::from(fish_flee_offset(flee_ms));
if x_i32.abs_diff(i32::from(ptr)) < 16 && y_i32.abs_diff(i32::from(ptr_y)) < 6 {
if x_i32 >= i32::from(ptr) {
x_i32 += flee;
} else {
x_i32 -= flee;
}
if y_i32 >= i32::from(ptr_y) {
y_i32 += 1;
} else {
y_i32 -= 1;
}
}
}
let max_x = i32::from(area.width.saturating_sub(body_w));
let max_y = i32::from(area.height.saturating_sub(1));
if x_i32 < 0 || x_i32 > max_x || y_i32 < 0 || y_i32 > max_y {
continue; }
let y = y_i32 as u16;
if y > quiet_mid_lo && y < quiet_mid_hi {
continue;
}
let brightness = if animated {
FISH_BRIGHTNESS_FLOOR
+ (1.0 - FISH_BRIGHTNESS_FLOOR)
* wave01(t, FISH_WAVE_MS, (m as u128).saturating_mul(320))
} else {
0.7
};
marks.push(AmbientMark {
x: x_i32 as u16,
y,
glyph: body,
jellyfish: None,
depth: if m == 0 {
Depth::Foreground
} else {
Depth::Midground
},
style_mod: None,
brightness: Some(brightness),
});
}
let in_band = |row: u16| row > quiet_mid_lo && row < quiet_mid_hi;
for j in 0..density.jellyfish_count() {
let phase = 3_100u128.saturating_add((j as u128) * 4_700);
let lane_x = if j % 2 == 0 {
area.width.saturating_mul(5) / 6
} else {
area.width / 6
};
let wobble = if animated {
sine_bob(t, 5_200 + phase, 1)
} else {
0
};
let compact = density == LifeDensity::Sparse;
let (dome_top, dome_skirt, tentacle_cols): (&[&str], &[&str], &[u16]) = if compact {
(JELLY_DOME_TOP_COMPACT, JELLY_DOME_SKIRT_COMPACT, &[0, 2])
} else {
(JELLY_DOME_TOP_FRAMES, JELLY_DOME_SKIRT_FRAMES, &[1, 2, 3])
};
let dome_w = dome_top[0].len() as u16; let x = lane_x
.saturating_add(wobble)
.min(area.width.saturating_sub(dome_w + 1));
let rise_rows = u128::from(area.height.saturating_sub(4).max(1));
let rise_period = 8_600u128.saturating_add((j as u128) * 1_400);
let y = if animated {
let risen = ((t.saturating_add(phase) / rise_period) % rise_rows) as u16;
area.height.saturating_sub(3).saturating_sub(risen)
} else {
quiet_mid_hi
.saturating_add(2)
.min(area.height.saturating_sub(3))
};
if y == 0 || in_band(y) {
continue;
}
let dome_brightness = if animated {
JELLY_BRIGHTNESS_FLOOR
+ (1.0 - JELLY_BRIGHTNESS_FLOOR) * wave01(t, JELLY_PULSE_MS, phase)
} else {
0.6
};
let tentacle_brightness = if animated {
JELLY_BRIGHTNESS_FLOOR
+ (1.0 - JELLY_BRIGHTNESS_FLOOR)
* wave01(
t.saturating_sub(JELLY_TENTACLE_LAG_MS),
JELLY_PULSE_MS,
phase,
)
} else {
0.45
};
let pulse_frame = if animated {
usize::from(wave01(t, JELLY_PULSE_MS, phase) > 0.5)
} else {
1
};
let skirt_row = y.saturating_add(1);
let tentacle_row = y.saturating_add(2);
if tentacle_row >= area.height || [y, skirt_row, tentacle_row].into_iter().any(in_band) {
continue;
}
for (row, glyph) in [
(y, dome_top[pulse_frame]),
(skirt_row, dome_skirt[pulse_frame]),
] {
marks.push(AmbientMark {
x,
y: row,
glyph,
jellyfish: Some(j),
depth: Depth::Midground,
style_mod: None,
brightness: Some(dome_brightness),
});
}
for (col, &dx) in tentacle_cols.iter().enumerate() {
let sway = if animated {
let frame = t
.saturating_add(phase)
.saturating_add((col as u128) * JELLY_TENTACLE_PHASE_STEP_MS)
/ JELLY_TENTACLE_SWAY_MS;
JELLY_TENTACLE_FRAMES[(frame as usize) % JELLY_TENTACLE_FRAMES.len()]
} else {
JELLY_TENTACLE_FRAMES[1]
};
marks.push(AmbientMark {
x: x.saturating_add(dx),
y: tentacle_row,
glyph: sway,
jellyfish: Some(j),
depth: Depth::Background,
style_mod: None,
brightness: Some(tentacle_brightness),
});
}
}
for b in 0..density.bubble_streams() {
let phase = (b as u128).saturating_mul(1_900);
let column = if b % 2 == 0 {
area.width / 8
} else {
area.width.saturating_mul(7) / 8
};
let rise_period = 3_200u128.saturating_add(phase % 900);
let rise = if animated {
let cycle = (t.saturating_add(phase) % rise_period) as f64 / rise_period as f64;
let max_rise = area.height.saturating_sub(3) as f64;
(cycle * max_rise) as u16
} else {
area.height / 4
};
let boost = if cursor.flee_elapsed_ms.is_some() && column.abs_diff(ptr) < 10 {
2
} else {
0
};
let y = area
.height
.saturating_sub(2)
.saturating_sub(rise.saturating_add(boost))
.max(quiet_top);
if y > quiet_mid_lo && y < quiet_mid_hi {
continue;
}
let glyph = if animated {
["·", "˚", "·", "°"][((t.saturating_add(phase)) / 320) as usize % 4]
} else {
"·"
};
let brightness = if animated {
glint01(t, 2_600 + phase % 700, 600, BUBBLE_BRIGHTNESS_FLOOR, phase)
} else {
BUBBLE_BRIGHTNESS_FLOOR
};
marks.push(AmbientMark {
x: column.min(area.width.saturating_sub(1)),
y,
glyph,
jellyfish: None,
depth: Depth::Foreground,
style_mod: None,
brightness: Some(brightness),
});
}
if let Some(cameo_ms) = whale.elapsed_ms.filter(|ms| *ms < WHALE_CAMEO_MS) {
let phase = whale_cameo_phase(cameo_ms);
if phase != WhaleCameoPhase::Hidden {
let ax = whale
.anchor_x
.saturating_sub(area.x)
.min(area.width.saturating_sub(4));
let ay = whale
.anchor_y
.saturating_sub(area.y)
.min(area.height.saturating_sub(2));
let (glyph, y_off) = match phase {
WhaleCameoPhase::Breach => ("≈≈>", 0u16),
WhaleCameoPhase::Spout => ("≈≈>", 0),
WhaleCameoPhase::Fluke => ("~", 1),
WhaleCameoPhase::Submerge => ("·", 1),
WhaleCameoPhase::Hidden => ("", 0),
};
if !glyph.is_empty() {
marks.push(AmbientMark {
x: ax,
y: ay.saturating_add(y_off).min(area.height.saturating_sub(1)),
glyph,
jellyfish: None,
depth: Depth::Foreground,
style_mod: None,
brightness: None,
});
if phase == WhaleCameoPhase::Spout && ay > 0 {
marks.push(AmbientMark {
x: ax.saturating_add(1).min(area.width.saturating_sub(1)),
y: ay.saturating_sub(1),
glyph: "˚",
jellyfish: None,
depth: Depth::Foreground,
style_mod: Some(Modifier::DIM),
brightness: None,
});
}
}
}
}
stats.marks_built = marks.len() as u32;
FrameMarks { marks }
}
const SCHOOL_WEDGE: &[(i16, u16)] = &[(0, 0), (-1, 4), (1, 6), (-2, 9), (2, 11), (0, 14), (-1, 17)];
const SCHOOL_CELL_MS: u128 = 380;
const FISH_WAVE_MS: u128 = 2_200;
const FISH_BRIGHTNESS_FLOOR: f32 = 0.45;
const LEAD_FISH_RIGHT: &str = "><o>";
const LEAD_FISH_LEFT: &str = "<o><";
const JELLY_DOME_TOP_FRAMES: &[&str] = &[".-~-.", ".'-.'"];
const JELLY_DOME_SKIRT_FRAMES: &[&str] = &["\\___/", "(___)"];
const JELLY_DOME_TOP_COMPACT: &[&str] = &[".-.", "'.'"];
const JELLY_DOME_SKIRT_COMPACT: &[&str] = &["\\_/", "(_)"];
const JELLY_TENTACLE_FRAMES: &[&str] = &["|", "/", "|", "\\"];
const JELLY_PULSE_MS: u128 = 2_900;
const JELLY_TENTACLE_LAG_MS: u128 = 350;
const JELLY_TENTACLE_SWAY_MS: u128 = 1_400;
const JELLY_TENTACLE_PHASE_STEP_MS: u128 = 450;
const JELLY_BRIGHTNESS_FLOOR: f32 = 0.35;
const BUBBLE_BRIGHTNESS_FLOOR: f32 = 0.55;
#[must_use]
fn wave01(elapsed_ms: u128, period_ms: u128, phase_ms: u128) -> f32 {
if period_ms == 0 {
return 1.0;
}
let frac = (elapsed_ms.saturating_add(phase_ms) % period_ms) as f64 / period_ms as f64;
let s = (frac * std::f64::consts::PI).sin();
(s * s) as f32
}
#[must_use]
fn glint01(elapsed_ms: u128, period_ms: u128, glint_ms: u128, floor: f32, phase_ms: u128) -> f32 {
if period_ms == 0 || glint_ms == 0 {
return floor;
}
let pos = elapsed_ms.saturating_add(phase_ms) % period_ms;
if pos >= glint_ms {
return floor;
}
let frac = pos as f64 / glint_ms as f64;
let bump = 0.5 * (1.0 - (frac * std::f64::consts::TAU).cos());
floor + (1.0 - floor) * bump as f32
}
#[must_use]
fn school_swims_right(cycle_index: u128) -> bool {
(cycle_index.wrapping_mul(0x9E37_79B9_7F4A_7C15) >> 7) & 1 == 0
}
#[must_use]
fn school_swims_low(cycle_index: u128) -> bool {
(cycle_index.wrapping_mul(0xC2B2_AE3D_27D4_EB4F) >> 9) & 1 == 0
}
fn paint_marks(
area: Rect,
buf: &mut Buffer,
inks: (Color, Color),
lines: &[Line<'static>],
frame: &FrameMarks,
stats: &mut AmbientFrameStats,
) {
#[derive(Clone, Copy)]
enum SkipReason {
Text,
Clipped,
}
let mut jellyfish_skip: [Option<SkipReason>; 2] = [None, None];
for mark in frame.marks.iter().filter(|mark| mark.jellyfish.is_some()) {
let Some(slot) = mark
.jellyfish
.and_then(|index| jellyfish_skip.get_mut(index))
else {
continue;
};
let mark_width = UnicodeWidthStr::width(mark.glyph);
if mark.x.saturating_add(mark_width as u16) > area.width {
*slot = Some(SkipReason::Clipped);
continue;
}
let protected = lines
.get(usize::from(mark.y))
.and_then(occupied_text_bounds);
let collides = protected.is_some_and(|(start, end)| {
usize::from(mark.x) < end.saturating_add(1)
&& usize::from(mark.x) + mark_width > start.saturating_sub(1)
});
if collides && !matches!(slot, Some(SkipReason::Clipped)) {
*slot = Some(SkipReason::Text);
}
}
for mark in &frame.marks {
if let Some(reason) = mark
.jellyfish
.and_then(|index| jellyfish_skip.get(index))
.copied()
.flatten()
{
match reason {
SkipReason::Text => stats.marks_skipped_text += 1,
SkipReason::Clipped => stats.marks_clipped += 1,
}
continue;
}
let mark_width = UnicodeWidthStr::width(mark.glyph);
if mark.x.saturating_add(mark_width as u16) > area.width {
stats.marks_clipped += 1;
continue;
}
let protected = lines
.get(usize::from(mark.y))
.and_then(occupied_text_bounds);
let collides = protected.is_some_and(|(start, end)| {
usize::from(mark.x) < end.saturating_add(1)
&& usize::from(mark.x) + mark_width > start.saturating_sub(1)
});
if collides {
stats.marks_skipped_text += 1;
continue;
}
stats.marks_painted += 1;
let ink = if mark.depth.ink_index() == 1 {
inks.1
} else {
inks.0
};
for (offset, ch) in mark.glyph.chars().enumerate() {
let cell = &mut buf[(area.x + mark.x + offset as u16, area.y + mark.y)];
let fg = match (mark.brightness, cell.style().bg) {
(Some(amount), Some(water)) => {
ocean::mix_colors(water, ink, amount.clamp(0.0, 1.0))
}
(Some(amount), None) => ocean::scale_color(ink, amount.clamp(0.0, 1.0).max(0.4)),
(None, _) => ink,
};
let mut style = Style::default().fg(fg);
if let Some(m) = mark.style_mod {
style = style.add_modifier(m);
}
cell.set_symbol(&ch.to_string());
cell.set_style(style);
stats.cells_written += 1;
}
}
}
#[must_use]
pub fn occupied_text_bounds(line: &Line<'_>) -> Option<(usize, usize)> {
if line.spans.is_empty() {
return None;
}
let mut total = 0usize;
let mut leading = 0usize;
let mut seen_non_ws = false;
let mut trailing_run = 0usize;
for span in &line.spans {
for ch in span.content.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
total = total.saturating_add(w);
if ch.is_whitespace() {
if !seen_non_ws {
leading = leading.saturating_add(w);
} else {
trailing_run = trailing_run.saturating_add(w);
}
} else {
seen_non_ws = true;
trailing_run = 0;
}
}
}
if !seen_non_ws {
return None;
}
Some((leading, total.saturating_sub(trailing_run)))
}
#[must_use]
fn sine_bob(elapsed_ms: u128, period_ms: u128, amplitude: u16) -> u16 {
if period_ms == 0 || amplitude == 0 {
return 0;
}
let phase = (elapsed_ms % period_ms) as f64 / period_ms as f64;
let s = (phase * std::f64::consts::TAU).sin();
(((s + 1.0) * 0.5) * f64::from(amplitude)).round() as u16
}
#[must_use]
pub fn fish_flee_offset(elapsed_ms: u128) -> u16 {
let progress = elapsed_ms.min(800) as f32 / 800.0;
let excursion = (progress * std::f32::consts::PI).sin() * 9.0;
excursion.round().clamp(0.0, 9.0) as u16
}
#[must_use]
fn fish_body(facing_right: bool, lead: bool) -> &'static str {
match (facing_right, lead) {
(true, true) => LEAD_FISH_RIGHT,
(true, false) => "><>",
(false, true) => LEAD_FISH_LEFT,
(false, false) => "<><",
}
}
#[must_use]
pub fn whale_cameo_phase(elapsed_ms: u128) -> WhaleCameoPhase {
match elapsed_ms {
0..400 => WhaleCameoPhase::Breach,
400..1_000 => WhaleCameoPhase::Spout,
1_000..1_700 => WhaleCameoPhase::Fluke,
1_700..WHALE_CAMEO_MS => WhaleCameoPhase::Submerge,
_ => WhaleCameoPhase::Hidden,
}
}
pub fn apply_caustic_shimmer(
area: Rect,
buf: &mut Buffer,
column: &OceanColumn,
elapsed_ms: u128,
animated: bool,
lines: &[Line<'static>],
) {
if !animated || area.width < AMBIENT_MIN_WIDTH || area.height < AMBIENT_MIN_HEIGHT {
return;
}
let band = (area.height / 3).max(2);
for local_y in 0..band {
let protected = lines
.get(usize::from(local_y))
.and_then(occupied_text_bounds);
let ramp = frame_ocean_ramp(
column,
area.height,
area.y,
elapsed_ms,
column.phase_tag(),
column.ramp_fingerprint(),
);
let row_bg = ramp
.get(usize::from(local_y))
.copied()
.unwrap_or_else(|| column.color_at_y(area.y.saturating_add(local_y)));
for local_x in (0..area.width).step_by(3) {
if protected.is_some_and(|(start, end)| {
usize::from(local_x) >= start && usize::from(local_x) < end
}) {
continue;
}
let phase = ((elapsed_ms / 80)
.wrapping_add(u128::from(local_x))
.wrapping_add(u128::from(local_y) * 3))
% 12;
if phase > 2 {
continue;
}
let cell = &mut buf[(area.x + local_x, area.y + local_y)];
if cell.symbol() == " " || cell.symbol().is_empty() {
let shimmer = ocean::scale_color(row_bg, 1.08);
cell.set_bg(shimmer);
}
}
}
}
#[derive(Debug, Clone, Default)]
pub struct OceanRampCache {
colors: Vec<Color>,
height: u16,
top: u16,
elapsed_bucket: u128,
phase_tag: u8,
ramp_fingerprint: u64,
}
impl OceanRampCache {
pub fn colors_for(
&mut self,
column: &OceanColumn,
height: u16,
top: u16,
elapsed_ms: u128,
phase_tag: u8,
ramp_fingerprint: u64,
) -> &[Color] {
let bucket = elapsed_ms / 80;
if self.colors.len() == usize::from(height)
&& self.height == height
&& self.top == top
&& self.elapsed_bucket == bucket
&& self.phase_tag == phase_tag
&& self.ramp_fingerprint == ramp_fingerprint
{
return &self.colors;
}
self.colors.clear();
self.colors.reserve(usize::from(height));
for local_y in 0..height {
self.colors
.push(column.color_at_y(top.saturating_add(local_y)));
}
self.height = height;
self.top = top;
self.elapsed_bucket = bucket;
self.phase_tag = phase_tag;
self.ramp_fingerprint = ramp_fingerprint;
&self.colors
}
}
thread_local! {
static FRAME_RAMP: std::cell::RefCell<OceanRampCache> =
const { std::cell::RefCell::new(OceanRampCache {
colors: Vec::new(),
height: 0,
top: 0,
elapsed_bucket: 0,
phase_tag: 0,
ramp_fingerprint: 0,
}) };
}
#[must_use]
pub fn frame_ocean_ramp(
column: &OceanColumn,
height: u16,
top: u16,
elapsed_ms: u128,
phase_tag: u8,
ramp_fingerprint: u64,
) -> Vec<Color> {
FRAME_RAMP.with(|cache| {
cache
.borrow_mut()
.colors_for(column, height, top, elapsed_ms, phase_tag, ramp_fingerprint)
.to_vec()
})
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::text::Span;
#[test]
fn ambient_min_dimensions_allow_small_windows() {
assert!(AMBIENT_MIN_WIDTH < 68);
assert!(AMBIENT_MIN_HEIGHT < 15);
}
#[test]
fn occupied_text_bounds_skips_string_join() {
let line = Line::from(vec![Span::raw(" hello "), Span::raw("world ")]);
let (start, end) = occupied_text_bounds(&line).expect("bounds");
assert_eq!(start, 2);
assert!(end > start);
}
#[test]
fn whale_cameo_is_brief() {
assert_eq!(whale_cameo_phase(0), WhaleCameoPhase::Breach);
assert_eq!(whale_cameo_phase(500), WhaleCameoPhase::Spout);
assert_eq!(whale_cameo_phase(1_200), WhaleCameoPhase::Fluke);
assert_eq!(whale_cameo_phase(2_000), WhaleCameoPhase::Submerge);
assert_eq!(whale_cameo_phase(3_000), WhaleCameoPhase::Hidden);
}
#[test]
fn density_scales_with_area() {
assert_eq!(
LifeDensity::from_area(Rect::new(0, 0, 40, 10)),
LifeDensity::Sparse
);
assert_eq!(
LifeDensity::from_area(Rect::new(0, 0, 100, 30)),
LifeDensity::Rich
);
}
#[test]
fn fish_school_uses_one_silhouette_family() {
assert_eq!(fish_body(true, false), "><>");
assert_eq!(fish_body(false, false), "<><");
assert_eq!(fish_body(true, true), "><o>");
assert_eq!(fish_body(false, true), "<o><");
}
fn frame_at(t: u128) -> FrameMarks {
let area = Rect::new(0, 0, 100, 30);
let mut stats = AmbientFrameStats::default();
build_frame_marks(
area,
t,
true,
LifeDensity::from_area(area),
AmbientCursor::default(),
WhaleCameo::default(),
&mut stats,
)
}
#[test]
fn fish_always_swim_the_way_they_face() {
let area_travel = 100u128 + 21; let cycle_ms = area_travel * SCHOOL_CELL_MS;
for cycle in 0u128..6 {
let t1 = cycle * cycle_ms;
let t2 = t1 + SCHOOL_CELL_MS * 3;
let lead = |t: u128| {
frame_at(t)
.marks
.into_iter()
.find(|mark| mark.glyph.contains('o'))
};
let (Some(a), Some(b)) = (lead(t1), lead(t2)) else {
continue; };
let expect_right = school_swims_right(cycle);
if expect_right {
assert_eq!(a.glyph, "><o>", "cycle {cycle} facing");
assert!(b.x >= a.x, "cycle {cycle}: right-facing fish moved left");
} else {
assert_eq!(a.glyph, "<o><", "cycle {cycle} facing");
assert!(b.x <= a.x, "cycle {cycle}: left-facing fish moved right");
}
}
let dirs: Vec<bool> = (0u128..12).map(school_swims_right).collect();
assert!(
dirs.iter().any(|d| *d) && dirs.iter().any(|d| !*d),
"{dirs:?}"
);
}
#[test]
fn water_holds_only_fish_bubbles_and_jellyfish() {
for t in [0u128, 7_500, 33_000, 61_000, 120_000] {
for mark in frame_at(t).marks {
let ok = matches!(mark.glyph, "><>" | "<><" | "><o>" | "<o><")
|| matches!(mark.glyph, "·" | "˚" | "°")
|| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph)
|| JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph)
|| JELLY_DOME_TOP_COMPACT.contains(&mark.glyph)
|| JELLY_DOME_SKIRT_COMPACT.contains(&mark.glyph)
|| JELLY_TENTACLE_FRAMES.contains(&mark.glyph);
assert!(ok, "unexpected ambient glyph {:?} at t={t}", mark.glyph);
}
}
}
#[test]
fn ambient_glyphs_are_ascii_or_have_fallbacks() {
let mut jellyfish: Vec<&str> = Vec::new();
jellyfish.extend(JELLY_DOME_TOP_FRAMES);
jellyfish.extend(JELLY_DOME_SKIRT_FRAMES);
jellyfish.extend(JELLY_DOME_TOP_COMPACT);
jellyfish.extend(JELLY_DOME_SKIRT_COMPACT);
jellyfish.extend(JELLY_TENTACLE_FRAMES);
for glyph in jellyfish {
assert!(glyph.is_ascii(), "jellyfish glyph {glyph:?} must be ASCII");
}
for glyph in ["><>", "<><", "><o>", "<o><"] {
assert!(glyph.is_ascii(), "fish glyph {glyph:?} must be ASCII");
}
for glyph in ["·", "˚", "°", "≈≈>", "≈", "~"] {
assert!(
glyph.is_ascii() || crate::tui::glyphs::ascii_fallback(glyph).is_some(),
"ambient glyph {glyph:?} lacks an ASCII fallback"
);
}
}
#[test]
fn jellyfish_reads_as_dome_with_lagging_tentacles() {
let mut seen = false;
for probe in 0..240u128 {
let t = probe * 500;
let frame = frame_at(t);
let Some(top) = frame
.marks
.iter()
.find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph))
else {
continue;
};
let dome_w = UnicodeWidthStr::width(top.glyph) as u16;
assert!(dome_w >= 4, "rich dome too narrow: {:?}", top.glyph);
let Some(skirt) = frame.marks.iter().find(|mark| {
JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph)
&& mark.x == top.x
&& mark.y == top.y + 1
}) else {
panic!("visible jellyfish dome lost its skirt: {frame:?}");
};
let tentacles: Vec<&AmbientMark> = frame
.marks
.iter()
.filter(|mark| {
JELLY_TENTACLE_FRAMES.contains(&mark.glyph)
&& mark.y == skirt.y + 1
&& mark.x > top.x
&& mark.x < top.x + dome_w
})
.collect();
assert_eq!(
tentacles.len(),
3,
"visible jellyfish must keep all tentacles: {frame:?}"
);
let dome_glow = top.brightness.expect("dome pulses");
assert!(dome_glow >= JELLY_BRIGHTNESS_FLOOR - f32::EPSILON);
for tentacle in &tentacles {
let glow = tentacle.brightness.expect("tentacle pulses");
assert!(glow >= JELLY_BRIGHTNESS_FLOOR - f32::EPSILON);
}
seen = true;
break;
}
assert!(seen, "no complete jellyfish found in 120s of frames");
}
#[test]
fn jellyfish_tentacles_sway_out_of_phase_and_dome_pulses() {
let mut dome_frames = std::collections::BTreeSet::new();
let mut column_frames: [std::collections::BTreeSet<&str>; 3] = Default::default();
let mut saw_desync = false;
for probe in 0..480u128 {
let frame = frame_at(probe * 100);
let Some(top) = frame
.marks
.iter()
.find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph))
else {
continue;
};
dome_frames.insert(top.glyph);
let mut trio = [""; 3];
let mut found = 0usize;
for (col, slot) in trio.iter_mut().enumerate() {
if let Some(tentacle) = frame.marks.iter().find(|mark| {
JELLY_TENTACLE_FRAMES.contains(&mark.glyph)
&& mark.y == top.y + 2
&& mark.x == top.x + 1 + col as u16
}) {
*slot = tentacle.glyph;
column_frames[col].insert(tentacle.glyph);
found += 1;
}
}
if found == 3 && !(trio[0] == trio[1] && trio[1] == trio[2]) {
saw_desync = true;
}
}
assert_eq!(
dome_frames.len(),
JELLY_DOME_TOP_FRAMES.len(),
"dome pulse should show every frame: {dome_frames:?}"
);
for (col, set) in column_frames.iter().enumerate() {
assert!(set.len() > 1, "tentacle column {col} never swayed");
}
assert!(saw_desync, "tentacle columns strobed in lockstep");
}
#[test]
fn sparse_water_gets_a_compact_jellyfish() {
let area = Rect::new(0, 0, 48, 12);
let mut saw_compact = false;
let mut saw_full = false;
let mut saw_two_tentacles = false;
for probe in 0..240u128 {
let mut stats = AmbientFrameStats::default();
let frame = build_frame_marks(
area,
probe * 500,
true,
LifeDensity::from_area(area),
AmbientCursor::default(),
WhaleCameo::default(),
&mut stats,
);
for mark in &frame.marks {
saw_compact |= JELLY_DOME_TOP_COMPACT.contains(&mark.glyph);
saw_full |= JELLY_DOME_TOP_FRAMES.contains(&mark.glyph);
}
let Some(top) = frame
.marks
.iter()
.find(|mark| JELLY_DOME_TOP_COMPACT.contains(&mark.glyph))
else {
continue;
};
let tentacles = frame
.marks
.iter()
.filter(|mark| JELLY_TENTACLE_FRAMES.contains(&mark.glyph) && mark.y == top.y + 2)
.count();
assert_eq!(
tentacles, 2,
"visible compact jelly must keep both tentacles: {frame:?}"
);
saw_two_tentacles = true;
}
assert!(saw_compact, "sparse water never showed the compact dome");
assert!(!saw_full, "sparse water used the full-size dome");
assert!(saw_two_tentacles, "compact jelly lost its tentacles");
}
#[test]
fn animated_tentacle_frames_never_collapse_to_punctuation_dots() {
assert!(
JELLY_TENTACLE_FRAMES
.iter()
.all(|glyph| matches!(*glyph, "|" | "/" | "\\")),
"every animation frame must retain a legible tentacle stroke"
);
}
#[test]
fn reduced_motion_parks_a_complete_jellyfish() {
let area = Rect::new(0, 0, 100, 30);
let build = || {
let mut stats = AmbientFrameStats::default();
build_frame_marks(
area,
0,
false,
LifeDensity::from_area(area),
AmbientCursor::default(),
WhaleCameo::default(),
&mut stats,
)
};
let first = build();
let second = build();
let pose = |frame: &FrameMarks| {
frame
.marks
.iter()
.map(|mark| (mark.glyph, mark.x, mark.y))
.collect::<Vec<_>>()
};
assert_eq!(pose(&first), pose(&second), "static pose must repeat");
let top = first
.marks
.iter()
.find(|mark| JELLY_DOME_TOP_FRAMES.contains(&mark.glyph))
.expect("parked jellyfish dome");
assert!(
first.marks.iter().any(|mark| {
JELLY_DOME_SKIRT_FRAMES.contains(&mark.glyph) && mark.y == top.y + 1
}),
"parked jellyfish lost its skirt"
);
let tentacles = first
.marks
.iter()
.filter(|mark| JELLY_TENTACLE_FRAMES.contains(&mark.glyph) && mark.y == top.y + 2)
.count();
assert!(tentacles >= 3, "parked jellyfish lost its tentacles");
}
#[test]
fn glow_helpers_stay_bounded_with_floors() {
for t in (0u128..12_000).step_by(97) {
let w = wave01(t, FISH_WAVE_MS, 0);
assert!((0.0..=1.0).contains(&w), "wave01 out of range: {w}");
let g = glint01(t, 2_600, 600, BUBBLE_BRIGHTNESS_FLOOR, 0);
assert!(
(BUBBLE_BRIGHTNESS_FLOOR..=1.0).contains(&g),
"glint01 lost its floor: {g}"
);
}
}
#[test]
fn frame_stats_account_for_every_mark() {
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
let stats = render_ambient_life(
area,
&mut buf,
(Color::Cyan, Color::Blue),
&[],
12_000,
true,
AmbientCursor::default(),
WhaleCameo::default(),
);
assert_eq!(
stats.marks_built,
stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped,
"every built mark is painted, text-skipped, or clipped: {stats:?}"
);
assert!(stats.marks_painted > 0, "empty water should paint life");
assert!(stats.cells_written >= stats.marks_painted);
assert!(
stats.cells_written <= stats.marks_painted * 5,
"cells_written out of proportion: {stats:?}"
);
}
#[test]
fn frame_stats_stay_within_the_render_budget() {
let area = Rect::new(0, 0, 160, 40);
let mut buf = Buffer::empty(area);
let whale = WhaleCameo {
elapsed_ms: Some(500), anchor_x: 80,
anchor_y: 26,
};
let stats = render_ambient_life(
area,
&mut buf,
(Color::Cyan, Color::Blue),
&[],
12_000,
true,
AmbientCursor::default(),
whale,
);
assert!(
stats.marks_built <= MAX_FRAME_MARKS,
"frame budget blown: {stats:?}"
);
assert_eq!(
stats.marks_built,
stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped,
"every built mark is accounted for: {stats:?}"
);
}
#[test]
fn frame_stats_never_overwrite_text() {
let area = Rect::new(0, 0, 100, 30);
let mut buf = Buffer::empty(area);
let lines: Vec<Line<'static>> = (0..usize::from(area.height))
.map(|i| {
Line::from(Span::raw(format!(
"transcript row {i:02} occupies the water"
)))
})
.collect();
for (i, line) in lines.iter().enumerate() {
buf.set_line(area.x, area.y + i as u16, line, area.width);
}
let stats = render_ambient_life(
area,
&mut buf,
(Color::Cyan, Color::Blue),
&lines,
12_000,
true,
AmbientCursor::default(),
WhaleCameo::default(),
);
assert!(
stats.marks_skipped_text > 0,
"text-covered water should skip some marks: {stats:?}"
);
assert_eq!(
stats.marks_built,
stats.marks_painted + stats.marks_skipped_text + stats.marks_clipped,
"every built mark is accounted for: {stats:?}"
);
for (i, line) in lines.iter().enumerate() {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
for (x, ch) in text.chars().enumerate() {
assert_eq!(
buf[(area.x + x as u16, area.y + i as u16)].symbol(),
ch.to_string(),
"ambient life overwrote transcript cell ({x},{i})"
);
}
}
}
}