zeph_tui/widgets/wave.rs
1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! Deterministic wave animation and equalizer widget for the TUI dashboard (#5096).
5//!
6//! All renderers are pure functions of `(state, width, t)` where `t` is a monotonic
7//! `u64` tick counter owned by `App`. No wall-clock reads happen here — all
8//! time-dependence flows through the explicit `t` argument so snapshot tests
9//! stay bit-identical.
10//!
11//! # Architecture
12//!
13//! - [`WaveState`] — discriminates the 6 visual modes; derived in `App::wave_state()`.
14//! - [`band_value`] — pure math: maps `(state, band, t)` to a normalised `[0.0, 1.0]` amplitude.
15//! - [`sample`] — maps `(state, x, t)` to a glyph bucket `0..=7` (delegates to [`band_value`]).
16//! - [`glyphs`] — single-row span builder used in compact-motion paths.
17//! - [`EqualizerWidget`] — full ratatui [`Widget`] for the side-panel slot; draws a braille
18//! waveform (mirrored about the centre axis) that jerks in time to a sharp beat envelope.
19
20use std::f32::consts::TAU;
21
22use ratatui::buffer::Buffer;
23use ratatui::layout::Rect;
24use ratatui::style::{Color, Style};
25use ratatui::text::Span;
26use ratatui::widgets::Widget;
27
28use crate::theme::{EffectiveColorMode, Theme};
29
30// ---------------------------------------------------------------------------
31// Glyph ramps
32// ---------------------------------------------------------------------------
33
34/// Equalizer bar ramp from silent (▁) to full (█).
35///
36/// Each column's bar height is determined by [`sample`] (sine math per `WaveState`).
37/// Different states produce visually distinct patterns:
38/// Swell → slow tall columns; Streaming → medium ripple; Tool → choppy spikes;
39/// Network → complex superposed pattern. Colour is a vertical gradient (see
40/// [`bucket_to_rgb`]): teal for foreground work, violet for `Network`.
41const WAVE_GLYPHS: [&str; 8] = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
42
43/// ASCII fallback for `TERM=dumb` terminals.
44const ASCII_GLYPHS: [&str; 8] = [".", ".", "-", "-", "~", "~", "=", "="];
45
46// ---------------------------------------------------------------------------
47// WaveState
48// ---------------------------------------------------------------------------
49
50/// Discriminates the wave animation mode for the input separator row.
51///
52/// Derived once per render frame by [`crate::app::App::wave_state`] from live
53/// agent state. The renderer receives a `WaveState` value — it never inspects
54/// wall-clock time directly so snapshot tests remain deterministic.
55///
56/// # Variants
57///
58/// | Variant | When shown | Colour |
59/// |---------|-----------|--------|
60/// | `Idle` | Agent is not busy — flat `▁` baseline | teal |
61/// | `Swell` | Busy, awaiting first token — high amplitude, slow roll | teal |
62/// | `Streaming` | Token stream active — medium amplitude, medium ω | teal |
63/// | `Tool` | Tool execution in progress — choppy short-λ wave | teal |
64/// | `Network` | External/background requests inflight — superposed sines | violet |
65/// | `Stalled` | No progress for >`stall_threshold` — flat + error tint | red |
66///
67/// Foreground agent work (`Swell`/`Streaming`/`Tool`) renders in the teal accent;
68/// [`WaveState::Network`] — background/external requests run by the task supervisor
69/// (memory enrichment, telemetry, MCP, egress, background shell) — renders in a distinct
70/// **violet** gradient so concurrent background activity is visually separable from the
71/// agent's own turn.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub enum WaveState {
74 /// Agent idle — renders a static thin baseline.
75 Idle,
76 /// Busy, waiting for the first token.
77 Swell,
78 /// Token stream active (fixed ω; tps modulation deferred to v2).
79 ///
80 /// `// TODO(#5096-tps): modulate omega by live tok/s once a per-turn rate metric exists`
81 Streaming,
82 /// Tool execution in progress.
83 Tool,
84 /// External/background requests inflight (task-supervisor work: enrichment,
85 /// telemetry, MCP, egress, background shell). Rendered in violet to set it
86 /// apart from foreground agent work. `sines` is clamped to `1..=3`.
87 Network {
88 /// Number of superposed sine waves; clamped to `1..=3` by concurrency.
89 sines: u8,
90 },
91 /// No progress for longer than the stall threshold.
92 Stalled,
93}
94
95// ---------------------------------------------------------------------------
96// Wave parameters
97// ---------------------------------------------------------------------------
98
99/// Per-state equalizer parameters passed to [`sample`].
100///
101/// Tuned for the 250 ms tick rate (4 fps). `omega` is the phase advance per tick
102/// in radians; one full vertical cycle takes `2π / omega` ticks.
103#[derive(Debug, Clone, Copy)]
104struct WaveParams {
105 /// Peak displacement in `[0, 1]`.
106 amplitude: f32,
107 /// Phase advance per tick (radians). Higher → faster vertical oscillation.
108 omega: f32,
109 /// Whether to superpose a secondary component for erratic spikes (Tool state).
110 choppy: bool,
111 /// Number of superposed sines (only for `Network`).
112 sines: u8,
113}
114
115impl WaveState {
116 /// Return the render parameters for this state.
117 fn params(self) -> WaveParams {
118 match self {
119 // Idle and Stalled both render a flat baseline (amplitude=0).
120 WaveState::Idle | WaveState::Stalled => WaveParams {
121 amplitude: 0.0,
122 omega: 0.0,
123 choppy: false,
124 sines: 1,
125 },
126 // Slow breathing: bars rise and fall lazily.
127 WaveState::Swell => WaveParams {
128 amplitude: 0.9,
129 omega: 0.35,
130 choppy: false,
131 sines: 1,
132 },
133 // Medium pace: energetic activity during token streaming.
134 WaveState::Streaming => WaveParams {
135 amplitude: 0.85,
136 omega: 1.1,
137 choppy: false,
138 sines: 1,
139 },
140 // Fast erratic: short spikes during tool execution.
141 WaveState::Tool => WaveParams {
142 amplitude: 0.7,
143 omega: 2.3,
144 choppy: true,
145 sines: 1,
146 },
147 // Background/external requests: superposed sines create a complex pattern,
148 // distinct violet colour applied in `wave_color` / `bucket_to_rgb`.
149 WaveState::Network { sines } => WaveParams {
150 amplitude: 0.75,
151 omega: 0.95,
152 choppy: false,
153 sines: sines.clamp(1, 3),
154 },
155 }
156 }
157}
158
159/// Terminal columns per equalizer band. One column = one independent bar.
160const BAND_W: u32 = 1;
161
162// ---------------------------------------------------------------------------
163// Core math
164// ---------------------------------------------------------------------------
165
166/// Return the normalised amplitude `[0.0, 1.0]` for equalizer band `band_idx` at tick `t`.
167///
168/// This function is the shared oscillation core used by both [`sample`] and [`EqualizerWidget`].
169/// Given identical `(state, band_idx, t)` it always returns the same value.
170///
171/// # Band-oscillation model
172///
173/// Each band receives a unique phase offset via the golden ratio (`0.618034`), so
174/// adjacent bands oscillate independently — producing the classic audio equalizer
175/// aesthetic where every bar moves up and down on its own schedule.
176///
177/// A squaring step (`y_norm²`) concentrates energy near the trough: bars spend
178/// most time low and spike briefly to full height ("резко поднимаются").
179///
180/// # `u64 → f32` note
181///
182/// f32 mantissa is 24 bits; the cast is exact below t ≈ 16.7 M (≈48 days at
183/// 4 fps). Beyond that the phase drifts slowly — visually imperceptible.
184#[must_use]
185pub fn band_value(state: WaveState, band_idx: u32, t: u64) -> f32 {
186 let p = state.params();
187
188 if p.amplitude < f32::EPSILON {
189 return 0.0; // Idle / Stalled → flat baseline
190 }
191
192 #[allow(clippy::cast_precision_loss)]
193 let tf = (t % 65536) as f32; // harmless wrap after ≈4.5 h
194 #[allow(clippy::cast_precision_loss)]
195 let bar_phase = (band_idx as f32 * 0.618_034).fract() * TAU;
196
197 let y = if p.sines <= 1 {
198 let mut v = p.amplitude * (p.omega * tf + bar_phase).sin();
199 if p.choppy {
200 // Secondary component (tribonacci ratio) for erratic Tool spikes.
201 #[allow(clippy::cast_precision_loss)]
202 let bar_phase2 = (band_idx as f32 * 1.324_718).fract() * TAU;
203 v = (v + 0.4 * p.amplitude * (p.omega * 1.7 * tf + bar_phase2).sin())
204 .clamp(-p.amplitude, p.amplitude);
205 }
206 v
207 } else {
208 // Network: superpose sines at golden-ratio omega multiples.
209 let omegas: [f32; 3] = [1.0, 1.618_034, 2.414_214];
210 let count = p.sines as usize;
211 let mut sum = 0.0_f32;
212 for (i, &om) in omegas[..count].iter().enumerate() {
213 #[allow(clippy::cast_precision_loss)]
214 let phase = (band_idx as f32 * (0.618_034 + i as f32 * 0.381_966)).fract() * TAU;
215 sum += (p.omega * om * tf + phase).sin();
216 }
217 #[allow(clippy::cast_precision_loss)]
218 {
219 p.amplitude * (sum / count as f32).clamp(-1.0, 1.0)
220 }
221 };
222
223 // Normalise to [0, 1] then square: bars mostly low, spiking sharply to peak.
224 let y_norm = f32::midpoint(y.clamp(-p.amplitude, p.amplitude) / p.amplitude, 1.0);
225 y_norm.powi(2)
226}
227
228/// Return the glyph bucket index `0..=7` for column `x` at tick `t`.
229///
230/// Delegates to [`band_value`] — `x` is mapped to a band via `x / BAND_W`.
231#[must_use]
232pub fn sample(state: WaveState, x: u32, t: u64) -> usize {
233 let band = x / BAND_W;
234 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
235 let bucket = (band_value(state, band, t) * 7.0).round() as usize;
236 bucket.clamp(0, 7)
237}
238
239// ---------------------------------------------------------------------------
240// Glyph row builder
241// ---------------------------------------------------------------------------
242
243/// Render a wave row of `width` columns into `buf`, then return the spans.
244///
245/// The caller passes a reused `buf: &mut Vec<Span<'static>>` that is cleared
246/// (not freed) each frame so capacity is amortized to zero after the first render.
247///
248/// Returns an empty vec when `width == 0` — never panics on narrow terminals.
249///
250/// # Parameters
251///
252/// - `state` — wave mode.
253/// - `width` — number of terminal columns available for the wave.
254/// - `t` — monotonic tick counter from `App::wave_tick()`.
255/// - `color_mode` — resolved terminal colour capability.
256/// - `ascii_only` — when `true`, uses ASCII glyph ramp regardless of state.
257/// - `buf` — reused span buffer; cleared on entry.
258/// - `theme` — used for colour styling.
259#[allow(clippy::too_many_arguments)]
260pub fn glyphs<'a>(
261 state: WaveState,
262 width: u32,
263 t: u64,
264 color_mode: EffectiveColorMode,
265 ascii_only: bool,
266 buf: &'a mut Vec<Span<'static>>,
267 theme: &Theme,
268) -> &'a [Span<'static>] {
269 buf.clear();
270 if width == 0 {
271 return buf.as_slice();
272 }
273
274 let ramp: &[&'static str; 8] = if ascii_only {
275 &ASCII_GLYPHS
276 } else {
277 &WAVE_GLYPHS
278 };
279
280 match color_mode {
281 EffectiveColorMode::Truecolor => {
282 // Per-column gradient: colour derived from bucket height.
283 for x in 0..width {
284 let b = sample(state, x, t);
285 let glyph = ramp[b];
286 let color = bucket_to_rgb(state, b);
287 buf.push(Span::styled(glyph, Style::default().fg(color)));
288 }
289 }
290 EffectiveColorMode::Ansi256 | EffectiveColorMode::Ansi16 => {
291 // Flat accent colour — single span for the whole row (no per-cell alloc).
292 let style = if matches!(state, WaveState::Stalled) {
293 theme.error
294 } else {
295 theme.highlight
296 };
297 let mut row = String::with_capacity(width as usize * 3); // 3 bytes per block glyph
298 for x in 0..width {
299 let b = sample(state, x, t);
300 row.push_str(ramp[b]);
301 }
302 // Owned String satisfies the 'static bound — dropped with the Span next frame.
303 buf.push(Span::styled(std::borrow::Cow::Owned(row), style));
304 }
305 EffectiveColorMode::Never => {
306 // Modifiers only — no colour. Single span.
307 let mut row = String::with_capacity(width as usize * 3);
308 for x in 0..width {
309 let b = sample(state, x, t);
310 row.push_str(ramp[b]);
311 }
312 buf.push(Span::raw(std::borrow::Cow::Owned(row)));
313 }
314 }
315
316 buf.as_slice()
317}
318
319/// Animated braille waveform rendered in the dashboard side panel during active inference.
320///
321/// Instead of discrete bars, the widget draws a single continuous waveform mirrored about
322/// the horizontal centre axis — like an audio waveform display. It is rendered with braille
323/// characters (U+2800 range), giving 2× horizontal and 4× vertical sub-pixel resolution.
324///
325/// The outline is a travelling superposition of sines (so it ripples across the width),
326/// multiplied by a sharp beat envelope (instant attack, cubic decay) so the whole wave
327/// jerks up and down "in time to the music". A teal gradient brightens toward the wave
328/// peaks (`#1FB9A8`), staying dim near the quiet centre axis.
329///
330/// `Idle` and `Stalled` collapse the wave to a flat centre line (`Stalled` tinted red).
331///
332/// Inspired by the [`tui-equalizer`](https://github.com/ratatui/tui-widgets/tree/main/tui-equalizer)
333/// reference widget, adapted for the Zeph teal design language.
334///
335/// # Examples
336///
337/// ```no_run
338/// use ratatui::layout::Rect;
339/// use zeph_tui::widgets::wave::{EqualizerWidget, WaveState};
340/// use zeph_tui::theme::{EffectiveColorMode, Theme};
341///
342/// let widget = EqualizerWidget {
343/// state: WaveState::Streaming,
344/// tick: 42,
345/// theme: &Theme::default(),
346/// color_mode: EffectiveColorMode::Truecolor,
347/// ascii_only: false,
348/// };
349/// // frame.render_widget(widget, area);
350/// ```
351pub struct EqualizerWidget<'a> {
352 /// Current wave animation state.
353 pub state: WaveState,
354 /// Monotonic tick counter from `App::wave_tick()`.
355 pub tick: u64,
356 /// Theme reference for ANSI colour fallback.
357 pub theme: &'a Theme,
358 /// Resolved terminal colour capability.
359 pub color_mode: EffectiveColorMode,
360 /// When `true`, renders the wave with ASCII density characters instead of braille.
361 pub ascii_only: bool,
362}
363
364/// Braille dot bit for each `(sub_col, sub_row)`, where `sub_row = 0` is the top.
365///
366/// Unicode braille (`U+2800` base) dot numbering:
367///
368/// ```text
369/// (1)(4)
370/// (2)(5)
371/// (3)(6)
372/// (7)(8)
373/// ```
374const BRAILLE_DOT: [[u8; 4]; 2] = [
375 [0x01, 0x02, 0x04, 0x40], // left column → dots 1, 2, 3, 7
376 [0x08, 0x10, 0x20, 0x80], // right column → dots 4, 5, 6, 8
377];
378
379impl Widget for EqualizerWidget<'_> {
380 fn render(self, area: Rect, buf: &mut Buffer) {
381 if area.width == 0 || area.height == 0 {
382 return;
383 }
384
385 let w = usize::from(area.width);
386 let sub_w = area.width * 2; // 2 braille dot columns per terminal cell
387 let sub_h = area.height * 4; // 4 braille dot rows per terminal cell
388 let center = f32::from(sub_h) / 2.0;
389 let max_half = (center - 1.0).max(0.0);
390
391 // Accumulate braille dot bits per terminal cell, then write once.
392 let mut cells = vec![0u8; w * usize::from(area.height)];
393 for sx in 0..sub_w {
394 let amp = wave_profile(self.state, sx, sub_w, self.tick);
395 let half = amp * max_half;
396 // `center ± half` is bounded to `[0, sub_h]`, which fits u16.
397 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
398 let top = (center - half).round().clamp(0.0, f32::from(sub_h - 1)) as u16;
399 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
400 let bot = (center + half).round().clamp(0.0, f32::from(sub_h - 1)) as u16;
401 let col = usize::from(sx / 2);
402 let sub_col = usize::from(sx % 2);
403 for sy in top..=bot {
404 let row = usize::from(sy / 4);
405 let sub_row = usize::from(sy % 4);
406 cells[row * w + col] |= BRAILLE_DOT[sub_col][sub_row];
407 }
408 }
409
410 // Brightness rises toward the wave extremes (peaks = brightest accent).
411 let mid_row = (f32::from(area.height) - 1.0) / 2.0;
412 let mut utf8 = [0u8; 4];
413 for row in 0..area.height {
414 for col in 0..area.width {
415 let bits = cells[usize::from(row) * w + usize::from(col)];
416 if bits == 0 {
417 continue;
418 }
419 let intensity = if mid_row <= 0.0 {
420 1.0
421 } else {
422 ((f32::from(row) - mid_row).abs() / mid_row).clamp(0.0, 1.0)
423 };
424 let color = wave_color(intensity, self.state, self.color_mode, self.theme);
425 let symbol = if self.ascii_only {
426 ascii_density(bits)
427 } else {
428 // 0x2800..=0x28FF are all valid braille code points.
429 char::from_u32(0x2800 + u32::from(bits)).unwrap_or(' ')
430 };
431 buf[(area.left() + col, area.top() + row)]
432 .set_fg(color)
433 .set_symbol(symbol.encode_utf8(&mut utf8));
434 }
435 }
436 }
437}
438
439/// Vertical half-amplitude (`0.0..=1.0` of the half-height) of the braille
440/// waveform at sub-column `sx` and tick `t`.
441///
442/// The outline is a travelling superposition of sines (so it ripples across the
443/// width), multiplied by a sharp beat envelope (instant attack, cubic decay,
444/// floored so it pulses without fully dying). `Idle` / `Stalled` return `0.0`,
445/// collapsing the wave to a flat centre line.
446fn wave_profile(state: WaveState, sx: u16, sub_w: u16, t: u64) -> f32 {
447 let p = state.params();
448 if p.amplitude < f32::EPSILON {
449 return 0.0;
450 }
451
452 #[allow(clippy::cast_precision_loss)]
453 let tf = (t % 65536) as f32; // harmless wrap after ≈4.5 h
454 #[allow(clippy::cast_precision_loss)]
455 let u = if sub_w <= 1 {
456 0.0
457 } else {
458 f32::from(sx) / f32::from(sub_w - 1)
459 };
460
461 // Travelling waveform: superposed sines drifting across the width.
462 let mut s = (u * TAU * 1.5 + p.omega * tf).sin();
463 let mut denom = 1.0_f32;
464 s += 0.6 * (u * TAU * 3.0 - p.omega * 1.6 * tf).sin();
465 denom += 0.6;
466 if p.choppy {
467 s += 0.4 * (u * TAU * 5.0 + p.omega * 2.3 * tf).sin();
468 denom += 0.4;
469 }
470 if p.sines > 1 {
471 s += 0.5 * (u * TAU * 2.3 + p.omega * 1.27 * tf).sin();
472 denom += 0.5;
473 }
474 let shape = (s / denom).abs();
475
476 // Sharp beat envelope: instant attack, cubic decay, floored at 0.3.
477 let beat_phase = (p.omega * 0.18 * tf).fract();
478 let energy = 0.3 + 0.7 * (1.0 - beat_phase).powi(3);
479
480 (p.amplitude * energy * shape).clamp(0.0, 1.0)
481}
482
483/// ASCII density glyph for a braille cell, chosen by how many dots are lit.
484///
485/// Used when the terminal cannot render braille (`ascii_only`).
486fn ascii_density(bits: u8) -> char {
487 match bits.count_ones() {
488 0 => ' ',
489 1..=2 => '.',
490 3..=4 => ':',
491 5..=6 => '+',
492 _ => '#',
493 }
494}
495
496/// Foreground colour for a braille wave cell.
497///
498/// `intensity` (`0.0..=1.0`) is the cell's distance from the centre axis: peaks
499/// (`1.0`) get the full accent, the quiet centre (`0.0`) stays dim. In Truecolor
500/// mode a smooth gradient is applied — teal for foreground agent work, **violet**
501/// for [`WaveState::Network`] (background/external requests), red for `Stalled`.
502/// ANSI modes fall back to theme colours (magenta for `Network`).
503fn wave_color(
504 intensity: f32,
505 state: WaveState,
506 color_mode: EffectiveColorMode,
507 theme: &Theme,
508) -> Color {
509 match color_mode {
510 EffectiveColorMode::Truecolor => {
511 let v = intensity.clamp(0.0, 1.0);
512 if matches!(state, WaveState::Stalled) {
513 // Error tint: dark red at centre → bright red at the peaks.
514 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
515 return Color::Rgb((80.0 + v * 175.0) as u8, 10, 10);
516 }
517 if matches!(state, WaveState::Network { .. }) {
518 // Violet gradient: #14102C (centre) → #8B5CF6 (peaks).
519 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
520 return Color::Rgb(
521 (20.0 + v * 119.0) as u8, // 20 → 139
522 (16.0 + v * 76.0) as u8, // 16 → 92
523 (44.0 + v * 202.0) as u8, // 44 → 246
524 );
525 }
526 // Teal gradient: #0A191E (centre) → #1FB9A8 (peaks / accent).
527 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
528 Color::Rgb(
529 (10.0 + v * 21.0) as u8, // 10 → 31
530 (25.0 + v * 160.0) as u8, // 25 → 185
531 (30.0 + v * 138.0) as u8, // 30 → 168
532 )
533 }
534 EffectiveColorMode::Ansi256 | EffectiveColorMode::Ansi16 => match state {
535 WaveState::Stalled => theme.error.fg.unwrap_or(Color::Red),
536 WaveState::Network { .. } => Color::Magenta,
537 _ => theme.highlight.fg.unwrap_or(Color::Yellow),
538 },
539 EffectiveColorMode::Never => Color::Reset,
540 }
541}
542
543/// Map a bucket index `0..=7` to an RGB colour for the Truecolor thin-line wave.
544///
545/// Trough (bucket 0) → near-invisible on dark bg. Crest (bucket 7) → full
546/// accent `#1FB9A8`, matching the CSS gradient in the design mock.
547/// Quadratic curve keeps low buckets dark so the peak stands out.
548fn bucket_to_rgb(state: WaveState, bucket: usize) -> Color {
549 if matches!(state, WaveState::Stalled) {
550 // Error tint: low-to-mid red gradient along the flat line.
551 #[allow(clippy::cast_possible_truncation)]
552 let v = (80 + bucket * 22) as u8;
553 return Color::Rgb(v, 15, 15);
554 }
555 // Quadratic fade keeps low buckets dark so the peak stands out.
556 #[allow(clippy::cast_precision_loss)]
557 let t = (bucket as f32 / 7.0).powi(2);
558 if matches!(state, WaveState::Network { .. }) {
559 // Violet fade: 0 → dark (#14102C), 7 → #8B5CF6.
560 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
561 return Color::Rgb(
562 (20.0_f32 + t * 119.0) as u8,
563 (16.0_f32 + t * 76.0) as u8,
564 (44.0_f32 + t * 202.0) as u8,
565 );
566 }
567 // Teal fade: 0 → dark (#0A191E), 7 → accent (#1FB9A8).
568 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
569 let r = (10.0_f32 + t * 21.0) as u8; // 10..=31
570 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
571 let g = (25.0_f32 + t * 160.0) as u8; // 25..=185
572 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
573 let b = (30.0_f32 + t * 138.0) as u8; // 30..=168
574 Color::Rgb(r, g, b)
575}
576
577// ---------------------------------------------------------------------------
578// Tests
579// ---------------------------------------------------------------------------
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 /// All bucket values must stay in 0..=7 for any input combination.
586 #[test]
587 fn sample_bucket_always_in_range() {
588 for t in [0u64, 1, 63, 127, 255, 65535, 65536, 1_000_000] {
589 for x in [0u32, 1, 5, 10, 40, 80, 160] {
590 for state in [
591 WaveState::Idle,
592 WaveState::Swell,
593 WaveState::Streaming,
594 WaveState::Tool,
595 WaveState::Network { sines: 2 },
596 WaveState::Network { sines: 3 },
597 WaveState::Stalled,
598 ] {
599 let b = sample(state, x, t);
600 assert!(b <= 7, "bucket {b} out of range for {state:?} x={x} t={t}");
601 }
602 }
603 }
604 }
605
606 /// Idle and Stalled always return bucket 0 (flat baseline).
607 #[test]
608 fn idle_and_stalled_are_flat() {
609 for t in [0u64, 100, 999] {
610 for x in 0u32..40 {
611 assert_eq!(sample(WaveState::Idle, x, t), 0, "Idle must be flat");
612 assert_eq!(sample(WaveState::Stalled, x, t), 0, "Stalled must be flat");
613 }
614 }
615 }
616
617 /// Determinism: identical (state, x, t) → identical output.
618 #[test]
619 fn sample_is_deterministic() {
620 let states = [
621 WaveState::Swell,
622 WaveState::Streaming,
623 WaveState::Tool,
624 WaveState::Network { sines: 2 },
625 ];
626 for state in states {
627 for x in [0u32, 7, 13, 40] {
628 for t in [0u64, 42, 1024] {
629 let a = sample(state, x, t);
630 let b = sample(state, x, t);
631 assert_eq!(
632 a, b,
633 "sample must be deterministic for {state:?} x={x} t={t}"
634 );
635 }
636 }
637 }
638 }
639
640 /// `glyphs(width=0)` is a hard no-op — never panics, returns empty slice.
641 #[test]
642 fn glyphs_width_zero_returns_empty() {
643 let theme = Theme::default();
644 let mut buf = Vec::new();
645 let spans = glyphs(
646 WaveState::Streaming,
647 0,
648 42,
649 EffectiveColorMode::Truecolor,
650 false,
651 &mut buf,
652 &theme,
653 );
654 assert!(spans.is_empty(), "width=0 must return empty spans");
655 }
656
657 /// Buffer reuse: calling `glyphs` twice reuses the allocation (capacity ≥ prev len).
658 #[test]
659 fn glyphs_buffer_reuse() {
660 let theme = Theme::default();
661 let mut buf: Vec<Span<'static>> = Vec::new();
662 glyphs(
663 WaveState::Streaming,
664 40,
665 0,
666 EffectiveColorMode::Truecolor,
667 false,
668 &mut buf,
669 &theme,
670 );
671 let cap_after_first = buf.capacity();
672 assert!(
673 cap_after_first >= 40,
674 "buffer should have capacity for 40 spans"
675 );
676 glyphs(
677 WaveState::Streaming,
678 40,
679 1,
680 EffectiveColorMode::Truecolor,
681 false,
682 &mut buf,
683 &theme,
684 );
685 assert_eq!(
686 buf.capacity(),
687 cap_after_first,
688 "second call must not reallocate"
689 );
690 }
691
692 /// Truecolor output has one span per column (gradient).
693 #[test]
694 fn glyphs_truecolor_one_span_per_column() {
695 let theme = Theme::default();
696 let mut buf = Vec::new();
697 let spans = glyphs(
698 WaveState::Streaming,
699 20,
700 5,
701 EffectiveColorMode::Truecolor,
702 false,
703 &mut buf,
704 &theme,
705 );
706 assert_eq!(
707 spans.len(),
708 20,
709 "Truecolor must produce one span per column"
710 );
711 }
712
713 /// Ansi256 output is a single span for the whole row.
714 #[test]
715 fn glyphs_ansi256_single_span() {
716 let theme = Theme::default();
717 let mut buf = Vec::new();
718 let spans = glyphs(
719 WaveState::Streaming,
720 20,
721 5,
722 EffectiveColorMode::Ansi256,
723 false,
724 &mut buf,
725 &theme,
726 );
727 assert_eq!(spans.len(), 1, "Ansi256 must produce a single flat span");
728 }
729
730 /// motion=Off: holding state and motion fixed, varying t produces identical output.
731 /// (The actual Off gate is in `input::render`, but we verify the pure layer here
732 /// by checking that Idle is always byte-identical regardless of t.)
733 #[test]
734 fn idle_output_invariant_across_ticks() {
735 let theme = Theme::default();
736 let mut buf_a = Vec::new();
737 let mut buf_b = Vec::new();
738 let spans_a = glyphs(
739 WaveState::Idle,
740 40,
741 0,
742 EffectiveColorMode::Truecolor,
743 false,
744 &mut buf_a,
745 &theme,
746 );
747 let spans_b = glyphs(
748 WaveState::Idle,
749 40,
750 999,
751 EffectiveColorMode::Truecolor,
752 false,
753 &mut buf_b,
754 &theme,
755 );
756 let text_a: String = spans_a.iter().map(|s| s.content.as_ref()).collect();
757 let text_b: String = spans_b.iter().map(|s| s.content.as_ref()).collect();
758 assert_eq!(text_a, text_b, "Idle output must be tick-invariant");
759 }
760
761 /// ASCII fallback uses the ASCII ramp, not block glyphs.
762 #[test]
763 fn ascii_fallback_uses_ascii_ramp() {
764 let theme = Theme::default();
765 let mut buf = Vec::new();
766 let spans = glyphs(
767 WaveState::Streaming,
768 20,
769 5,
770 EffectiveColorMode::Truecolor,
771 true, // ascii_only
772 &mut buf,
773 &theme,
774 );
775 let text: String = spans.iter().map(|s| s.content.as_ref()).collect();
776 // ASCII ramp characters — no block glyphs should appear.
777 assert!(
778 !text.contains('▁') && !text.contains('█'),
779 "ASCII mode must not contain block glyphs: {text:?}"
780 );
781 }
782
783 /// Stalled state has error tint (Rgb with high R) in Truecolor.
784 #[test]
785 fn stalled_uses_error_tint_in_truecolor() {
786 let theme = Theme::default();
787 let mut buf = Vec::new();
788 let spans = glyphs(
789 WaveState::Stalled,
790 10,
791 0,
792 EffectiveColorMode::Truecolor,
793 false,
794 &mut buf,
795 &theme,
796 );
797 for span in spans {
798 if let Some(Color::Rgb(r, _g, _b)) = span.style.fg {
799 assert!(
800 r >= 80,
801 "Stalled must have elevated R channel for error tint, got r={r}"
802 );
803 }
804 }
805 }
806
807 // --- EqualizerWidget (braille waveform) ---------------------------------
808
809 /// Count terminal rows that contain at least one non-blank cell.
810 fn non_blank_rows(buf: &Buffer, area: Rect) -> usize {
811 (0..area.height)
812 .filter(|&row| {
813 (0..area.width).any(|col| {
814 let s = buf[(area.left() + col, area.top() + row)].symbol();
815 !s.trim().is_empty()
816 })
817 })
818 .count()
819 }
820
821 /// Idle collapses the wave to a single flat centre line — exactly one row lit.
822 #[test]
823 fn wave_widget_idle_is_flat_line() {
824 let theme = Theme::default();
825 let area = Rect::new(0, 0, 12, 4);
826 let mut buf = Buffer::empty(area);
827 EqualizerWidget {
828 state: WaveState::Idle,
829 tick: 123,
830 theme: &theme,
831 color_mode: EffectiveColorMode::Truecolor,
832 ascii_only: false,
833 }
834 .render(area, &mut buf);
835 assert_eq!(
836 non_blank_rows(&buf, area),
837 1,
838 "Idle must render a single flat centre line"
839 );
840 }
841
842 /// A busy state spreads the wave across more than one terminal row for at
843 /// least one tick (mirrored amplitude above/below the centre axis).
844 #[test]
845 fn wave_widget_busy_spreads_vertically() {
846 let theme = Theme::default();
847 let area = Rect::new(0, 0, 16, 4);
848 let spread = (0u64..40).any(|tick| {
849 let mut buf = Buffer::empty(area);
850 EqualizerWidget {
851 state: WaveState::Streaming,
852 tick,
853 theme: &theme,
854 color_mode: EffectiveColorMode::Truecolor,
855 ascii_only: false,
856 }
857 .render(area, &mut buf);
858 non_blank_rows(&buf, area) > 1
859 });
860 assert!(spread, "busy wave must span >1 row for some tick");
861 }
862
863 /// Rendering into a degenerate 1×1 area must never panic.
864 #[test]
865 fn wave_widget_tiny_area_no_panic() {
866 let theme = Theme::default();
867 let area = Rect::new(0, 0, 1, 1);
868 let mut buf = Buffer::empty(area);
869 EqualizerWidget {
870 state: WaveState::Tool,
871 tick: 7,
872 theme: &theme,
873 color_mode: EffectiveColorMode::Truecolor,
874 ascii_only: false,
875 }
876 .render(area, &mut buf);
877 }
878
879 /// ASCII mode emits only density characters, never braille code points.
880 #[test]
881 fn wave_widget_ascii_has_no_braille() {
882 let theme = Theme::default();
883 let area = Rect::new(0, 0, 16, 4);
884 let mut buf = Buffer::empty(area);
885 EqualizerWidget {
886 state: WaveState::Swell,
887 tick: 11,
888 theme: &theme,
889 color_mode: EffectiveColorMode::Ansi256,
890 ascii_only: true,
891 }
892 .render(area, &mut buf);
893 for row in 0..area.height {
894 for col in 0..area.width {
895 let s = buf[(area.left() + col, area.top() + row)].symbol();
896 assert!(
897 s.chars().all(|c| !('\u{2800}'..='\u{28FF}').contains(&c)),
898 "ASCII mode must not emit braille: {s:?}"
899 );
900 }
901 }
902 }
903
904 /// `ascii_density` maps dot-count buckets to increasing ink density.
905 #[test]
906 fn ascii_density_buckets() {
907 assert_eq!(ascii_density(0x00), ' ');
908 assert_eq!(ascii_density(0x01), '.'); // 1 dot
909 assert_eq!(ascii_density(0x0F), ':'); // 4 dots
910 assert_eq!(ascii_density(0x3F), '+'); // 6 dots
911 assert_eq!(ascii_density(0xFF), '#'); // 8 dots
912 }
913
914 /// Network peaks are violet (blue-dominant) while foreground work is teal
915 /// (green-dominant) — the two activity classes must be colour-separable.
916 #[test]
917 fn network_wave_color_is_violet_distinct_from_teal() {
918 let theme = Theme::default();
919 let net = wave_color(
920 1.0,
921 WaveState::Network { sines: 2 },
922 EffectiveColorMode::Truecolor,
923 &theme,
924 );
925 let teal = wave_color(
926 1.0,
927 WaveState::Streaming,
928 EffectiveColorMode::Truecolor,
929 &theme,
930 );
931 let Color::Rgb(nr, ng, nb) = net else {
932 panic!("expected Rgb for Network peak, got {net:?}");
933 };
934 let Color::Rgb(_tr, tg, tb) = teal else {
935 panic!("expected Rgb for Streaming peak, got {teal:?}");
936 };
937 assert!(
938 nb > ng && nb > nr,
939 "Network peak must be blue-dominant (violet); got r={nr} g={ng} b={nb}"
940 );
941 assert!(tg > tb, "Streaming (teal) peak must be green-dominant");
942 assert_ne!(net, teal, "Network and foreground colours must differ");
943 }
944
945 /// ANSI mode maps `Network` to magenta, distinct from the foreground highlight.
946 #[test]
947 fn network_wave_color_ansi_is_magenta() {
948 let theme = Theme::default();
949 let net = wave_color(
950 1.0,
951 WaveState::Network { sines: 1 },
952 EffectiveColorMode::Ansi16,
953 &theme,
954 );
955 assert_eq!(net, Color::Magenta);
956 }
957}