Skip to main content

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}