Skip to main content

ripl/
aura.rs

1use std::time::Duration;
2
3use rand::{rngs::StdRng, Rng, SeedableRng};
4use ratatui::{
5    layout::Rect,
6    style::Style,
7    Frame,
8};
9
10use crate::theme::aura_color;
11
12/// Slow breathing phase of the aura.
13#[derive(Debug, Clone, Copy, PartialEq, Eq)]
14pub enum BreathPhase {
15    Inhale,
16    Exhale,
17}
18
19/// Glyph palette used by the aura renderer.
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum AuraGlyphMode {
22    Braille,
23    Taz,
24    Math,
25    Mahjong,
26    Dominoes,
27    Cards,
28}
29
30/// An expanding ripple started when the Priestess speaks.
31#[derive(Debug, Clone)]
32pub struct Ripple {
33    pub t0: f32,
34    pub speed: f32,
35    pub width: f32,
36    pub strength: f32,
37    pub center: Option<(f32, f32)>,
38    pub start_radius: f32,
39    pub direction: RippleDir,
40}
41
42/// Direction of a ripple — outward from center or inward toward it.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum RippleDir {
45    Outward,
46    Inward,
47}
48
49/// Aura state: breathing field + ripples + RNG for puffs.
50pub struct Aura {
51    frame: u64,
52    time_s: f32,
53    phase: BreathPhase,
54    breath_t: f32,
55    breath_duration: f32,
56    pub ripples: Vec<Ripple>,
57    rng: StdRng,
58    glyph_mode: AuraGlyphMode,
59    braille_by_dots: [Vec<u8>; 9],
60}
61
62impl Aura {
63    pub fn new() -> Self {
64        let mut braille_by_dots = std::array::from_fn(|_| Vec::new());
65        for pattern in 0u16..=255 {
66            let dots = (pattern as u8).count_ones() as usize;
67            braille_by_dots[dots].push(pattern as u8);
68        }
69        Aura {
70            frame: 0,
71            time_s: 0.0,
72            phase: BreathPhase::Inhale,
73            breath_t: 0.0,
74            breath_duration: 60.0 / 7.0,
75            ripples: Vec::new(),
76            rng: StdRng::from_entropy(),
77            glyph_mode: AuraGlyphMode::Braille,
78            braille_by_dots,
79        }
80    }
81
82    pub fn set_glyph_mode(&mut self, mode: AuraGlyphMode) {
83        self.glyph_mode = mode;
84    }
85
86    pub fn glyph_mode(&self) -> AuraGlyphMode {
87        self.glyph_mode
88    }
89
90    /// Advance time and breath phase.
91    pub fn tick(&mut self, dt: Duration) {
92        self.frame = self.frame.wrapping_add(1);
93        let dt_s = dt.as_secs_f32();
94        self.time_s += dt_s;
95
96        self.breath_t += dt_s / self.breath_duration;
97        if self.breath_t >= 1.0 {
98            self.breath_t -= 1.0;
99            self.phase = match self.phase {
100                BreathPhase::Inhale => BreathPhase::Exhale,
101                BreathPhase::Exhale => BreathPhase::Inhale,
102            };
103        }
104
105        let max_age = self.breath_duration * 3.0;
106        self.ripples.retain(|r| self.time_s - r.t0 <= max_age);
107    }
108
109    /// Launch one or more ripples when the Priestess begins speaking.
110    pub fn launch_ripples(&mut self, base_strength: f32, pace: f32) {
111        let now = self.time_s;
112        for i in 0..3 {
113            let jitter = (i as f32) * 0.1;
114            let strength = (base_strength * (0.8 + 0.4 * self.rng.r#gen::<f32>())).clamp(0.0, 1.0);
115            let speed = (0.35 + 0.2 * self.rng.r#gen::<f32>()) * pace.clamp(0.6, 2.4);
116            let width = 0.1 + 0.05 * self.rng.r#gen::<f32>();
117            self.ripples.push(Ripple {
118                t0: now + jitter,
119                speed,
120                width,
121                strength,
122                center: None,
123                start_radius: 1.0,
124                direction: RippleDir::Outward,
125            });
126        }
127    }
128
129    /// Launch a ripple centered at a mouse click, outside the hole.
130    pub fn launch_ripple_at(&mut self, x: u16, y: u16, area: Rect, pace: f32) {
131        let (hx, hy, cx, cy) = hole_geometry(area);
132        let dx = x as f32 - cx;
133        let dy = y as f32 - cy;
134        let nx = dx / hx;
135        let ny = dy / hy;
136        let e = (nx * nx + ny * ny).sqrt();
137        if e < 1.0 {
138            return;
139        }
140
141        for i in 0..3 {
142            let jitter = (i as f32) * 0.06;
143            let strength = 0.75 + 0.25 * self.rng.r#gen::<f32>();
144            let speed = (0.55 + 0.35 * self.rng.r#gen::<f32>()) * pace.clamp(0.6, 2.4);
145            let width = 0.10 + 0.06 * self.rng.r#gen::<f32>();
146            self.ripples.push(Ripple {
147                t0: self.time_s + jitter,
148                speed,
149                width,
150                strength,
151                center: Some((nx, ny)),
152                start_radius: 0.0,
153                direction: RippleDir::Outward,
154            });
155        }
156    }
157
158    /// Launch an inward-contracting ripple — used during STT recording to
159    /// signal "listening / gathering". Enters the visible ring band ~1s after
160    /// launch and reaches the hole edge at ~2.4s.
161    pub fn launch_inward_ripple(&mut self) {
162        self.ripples.push(Ripple {
163            t0: self.time_s,
164            speed: 0.25,
165            width: 0.12,
166            strength: 0.4,
167            center: None,
168            start_radius: 1.6,
169            direction: RippleDir::Inward,
170        });
171    }
172
173    /// Main render entry. `voice_intensity` is 0..1.
174    pub fn render(&mut self, frame: &mut Frame, area: Rect, voice_intensity: f32) {
175        let width = area.width as usize;
176        let height = area.height as usize;
177
178        if width == 0 || height == 0 {
179            return;
180        }
181
182        let breath_env = self.breath_envelope();
183
184        let cx = area.x as f32 + (area.width as f32 / 2.0);
185        let cy = area.y as f32 + (area.height as f32 / 2.0);
186        let max_dist = ((area.width as f32).hypot(area.height as f32)) / 2.0;
187        let (hx, hy, _, _) = hole_geometry(area);
188
189        let buf = frame.buffer_mut();
190
191        for row in 0..height {
192            for col in 0..width {
193                let x = area.x as u16 + col as u16;
194                let y = area.y as u16 + row as u16;
195
196                let dx = x as f32 - cx;
197                let dy = y as f32 - cy;
198                let dist = (dx * dx + dy * dy).sqrt();
199                let _r = (dist / max_dist).clamp(0.0, 1.0);
200
201                let nx = dx / hx;
202                let ny = dy / hy;
203                let e = (nx * nx + ny * ny).sqrt();
204
205                let ring_start: f32 = 1.0;
206                let ring_width: f32 = 0.35;
207                let ring_end = ring_start + ring_width;
208
209                let noise = self.noise3(col as u32, row as u32, self.frame / 2) * 0.06;
210                let shimmer = self.noise3(col as u32, row as u32, self.frame / 5);
211
212                if e < ring_start {
213                    let cell = buf.get_mut(x, y);
214                    // Clear the hole every frame to avoid residual artifacts.
215                    cell.set_symbol(" ");
216                    cell.set_style(Style::default());
217
218                    // Very light mist in the hole.
219                    let mist = (noise * 0.7 + breath_env * 0.03 + (shimmer - 0.5) * 0.05).clamp(0.0, 1.0);
220                    if mist < 0.18 {
221                        continue;
222                    }
223                    let (ch, _) = self.glyph_for_energy_stochastic(mist, noise, col as u32, row as u32);
224                    let mut symbol_buf = [0u8; 4];
225                    let symbol = ch.encode_utf8(&mut symbol_buf);
226                    cell.set_symbol(symbol);
227                    let color = aura_color(mist, noise, shimmer);
228                    cell.set_style(Style::default().fg(color));
229                    continue;
230                }
231
232                let ring_t = ((e - ring_start) / (ring_end - ring_start)).clamp(0.0, 1.0);
233                let ring_env = smoothstep(0.0, 1.0, ring_t);
234
235                let base_energy = match self.phase {
236                    BreathPhase::Inhale => breath_env * (1.0 - ring_t),
237                    BreathPhase::Exhale => breath_env * ring_t,
238                };
239
240                let ripple_energy = self.ripple_energy(e, nx, ny, shimmer, noise);
241
242                let ripple_mod = 0.45 + 0.35 * shimmer;
243                let mut energy = (base_energy * 0.55 + ripple_energy * ripple_mod + noise + 0.05) * ring_env;
244                energy *= 0.55 + 0.35 * voice_intensity;
245                let mut energy = energy.clamp(0.0, 1.0);
246
247                let jitter = (self.noise3(col as u32, row as u32, self.frame.wrapping_add(17) / 3) - 0.5) * 0.14;
248                energy = (energy + jitter).clamp(0.0, 1.0);
249
250                let blank_gate = self.noise3(col as u32, row as u32, self.frame / 4);
251                let blank_thresh = if ripple_energy > 0.04 { 0.46 } else { 0.36 };
252                if blank_gate < blank_thresh && energy < 0.7 {
253                    continue;
254                }
255
256                let (ch, _tier) = self.glyph_for_energy_stochastic(energy, noise, col as u32, row as u32);
257                let cell = buf.get_mut(x, y);
258                let mut symbol_buf = [0u8; 4];
259                let symbol = ch.encode_utf8(&mut symbol_buf);
260                cell.set_symbol(symbol);
261                let color = aura_color(energy, noise, shimmer);
262                cell.set_style(Style::default().fg(color));
263            }
264        }
265    }
266
267    fn breath_envelope(&self) -> f32 {
268        let s = (std::f32::consts::PI * self.breath_t).sin().max(0.0);
269        match self.phase {
270            BreathPhase::Inhale => s,
271            BreathPhase::Exhale => s,
272        }
273    }
274
275    fn ripple_energy(&self, r: f32, nx: f32, ny: f32, shimmer: f32, noise: f32) -> f32 {
276        let mut acc = 0.0;
277        for ripple in &self.ripples {
278            let age = self.time_s - ripple.t0;
279            if age < 0.0 {
280                continue;
281            }
282            let wobble = (shimmer - 0.5) * 0.08 + (noise - 0.5) * 0.06;
283            let center_r = match ripple.direction {
284                RippleDir::Outward => ripple.start_radius + ripple.speed * age * (1.0 + wobble),
285                RippleDir::Inward => (ripple.start_radius - ripple.speed * age).max(0.0),
286            };
287            let dist = if let Some((cx, cy)) = ripple.center {
288                let dx = nx - cx;
289                let dy = ny - cy;
290                (dx * dx + dy * dy).sqrt()
291            } else {
292                r
293            };
294            let dr = (dist - center_r).abs();
295            let ring_env = (1.0 - (dr / ripple.width)).clamp(0.0, 1.0);
296            let max_age = self.breath_duration * 3.0;
297            let time_env = (1.0 - age / max_age).clamp(0.0, 1.0);
298            acc += ripple.strength * ring_env * time_env;
299        }
300        acc
301    }
302
303    fn noise3(&self, x: u32, y: u32, z: u64) -> f32 {
304        let mut h = x.wrapping_mul(374761393)
305            ^ y.wrapping_mul(668265263)
306            ^ (z as u32).wrapping_mul(2246822519);
307        h = (h ^ (h >> 13)).wrapping_mul(1274126177);
308        let v = (h ^ (h >> 16)) & 0xffff;
309        (v as f32) / 65535.0
310    }
311
312    fn glyph_for_energy_stochastic(&self, e: f32, _noise: f32, col: u32, row: u32) -> (char, u8) {
313        const ENERGY_FLOOR: f32 = 0.12;
314        const TIERS: usize = 4;
315
316        if e < ENERGY_FLOOR {
317            return (' ', 0);
318        }
319
320        let t = ((e - ENERGY_FLOOR) / (1.0 - ENERGY_FLOOR)).clamp(0.0, 1.0);
321        let tier = (t * TIERS as f32).floor().min((TIERS - 1) as f32) as usize;
322
323        match self.glyph_mode {
324            AuraGlyphMode::Braille => {
325                let t_skew = t.powf(2.2);
326                let mut dots = 1 + (t_skew * 7.999).floor() as u8;
327                // Bias toward lighter densities (0–2 dots).
328                let roll = self.noise3(col, row, self.frame.wrapping_add(100));
329                if roll < 0.28 {
330                    dots = dots.saturating_sub(1);
331                }
332                if roll < 0.12 {
333                    dots = dots.saturating_sub(1);
334                }
335                if dots == 0 {
336                    return (' ', 0);
337                }
338                let list = &self.braille_by_dots[dots as usize];
339                let idx_noise = self.noise3(col, row, self.frame.wrapping_add(200));
340                let idx = (idx_noise * list.len() as f32) as usize % list.len().max(1);
341                let pattern = list[idx];
342                (braille_char(pattern), dots)
343            }
344            AuraGlyphMode::Taz => {
345                let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), TAZ_TIERS[tier]);
346                (ch, (tier + 1) as u8)
347            }
348            AuraGlyphMode::Math => {
349                let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), MATH_TIERS[tier]);
350                (ch, (tier + 1) as u8)
351            }
352            AuraGlyphMode::Mahjong => {
353                let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), MAHJONG_TIERS[tier]);
354                (ch, (tier + 1) as u8)
355            }
356            AuraGlyphMode::Dominoes => {
357                let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), DOMINOES_TIERS[tier]);
358                (ch, (tier + 1) as u8)
359            }
360            AuraGlyphMode::Cards => {
361                let ch = sample_tier(self.noise3(col, row, self.frame.wrapping_add(300)), CARDS_TIERS[tier]);
362                (ch, (tier + 1) as u8)
363            }
364        }
365    }
366}
367
368fn braille_char(pattern: u8) -> char {
369    char::from_u32(0x2800 + pattern as u32).unwrap_or(' ')
370}
371
372fn sample_tier(noise: f32, tier: &[char]) -> char {
373    if tier.is_empty() {
374        return ' ';
375    }
376    let idx = (noise * tier.len() as f32) as usize % tier.len();
377    tier[idx]
378}
379
380const TAZ_TIERS: [&[char]; 4] = [
381    &['.', ',', ':', '\''],
382    &['!', '?', ';', '~', '^'],
383    &['@', '#', '$', '%', '&'],
384    &['*', '¶', '§', '†', '‽', '∅'],
385];
386
387const MATH_TIERS: [&[char]; 4] = [
388    &['+', '-', '=', '·', '×', '÷'],
389    &['±', '≈', '≠', '≤', '≥', '∝', '√'],
390    &['∑', '∏', '∫', '∂', '∞', '∇'],
391    &['∮', '∴', '∵', '∃', '∀', '∘', '⊕', '⊗'],
392];
393
394const MAHJONG_TIERS: [&[char]; 4] = [
395    &['🀇', '🀈', '🀉', '🀊', '🀋', '🀌', '🀍', '🀎', '🀏'],
396    &['🀐', '🀑', '🀒', '🀓', '🀔', '🀕', '🀖', '🀗', '🀘'],
397    &['🀀', '🀁', '🀂', '🀃', '🀄', '🀅', '🀆'],
398    &['🀙', '🀚', '🀛', '🀜', '🀝', '🀞', '🀟', '🀠', '🀡', '🀢', '🀣', '🀤', '🀥', '🀦', '🀧', '🀨', '🀩', '🀪', '🀫'],
399];
400
401const DOMINOES_TIERS: [&[char]; 4] = [
402    &['🀰', '🀱', '🀲', '🀳', '🀴', '🀵', '🀶', '🀷'],
403    &['🀸', '🀹', '🀺', '🀻', '🀼', '🀽', '🀾', '🀿'],
404    &['🁀', '🁁', '🁂', '🁃', '🁄', '🁅', '🁆', '🁇'],
405    &['🁈', '🁉', '🁊', '🁋', '🁌', '🁍', '🁎', '🁏'],
406];
407
408const CARDS_TIERS: [&[char]; 4] = [
409    &['🂡', '🂢', '🂣', '🂤', '🂥', '🂦', '🂧', '🂨', '🂩'],
410    &['🂱', '🂲', '🂳', '🂴', '🂵', '🂶', '🂷', '🂸', '🂹'],
411    &['🃁', '🃂', '🃃', '🃄', '🃅', '🃆', '🃇', '🃈', '🃉'],
412    &['🃑', '🃒', '🃓', '🃔', '🃕', '🃖', '🃗', '🃘', '🃙'],
413];
414
415fn hole_geometry(area: Rect) -> (f32, f32, f32, f32) {
416    let target_half_w: f32 = 40.0;
417    let target_half_h: f32 = 12.0;
418    let half_w = (area.width as f32 / 2.0).max(1.0);
419    let half_h = (area.height as f32 / 2.0).max(1.0);
420    let hx = target_half_w.min(half_w - 1.0).max(1.0);
421    let hy = target_half_h.min(half_h - 1.0).max(1.0);
422    let cx = area.x as f32 + (area.width as f32 / 2.0);
423    let cy = area.y as f32 + (area.height as f32 / 2.0);
424    (hx, hy, cx, cy)
425}
426
427fn smoothstep(edge0: f32, edge1: f32, x: f32) -> f32 {
428    let t = ((x - edge0) / (edge1 - edge0)).clamp(0.0, 1.0);
429    t * t * (3.0 - 2.0 * t)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435    use ratatui::layout::Rect;
436
437    #[test]
438    fn hole_geometry_is_deterministic() {
439        let area = Rect { x: 0, y: 0, width: 120, height: 40 };
440        let (hx1, hy1, cx1, cy1) = hole_geometry(area);
441        let (hx2, hy2, cx2, cy2) = hole_geometry(area);
442        assert_eq!(hx1, hx2);
443        assert_eq!(hy1, hy2);
444        assert_eq!(cx1, cx2);
445        assert_eq!(cy1, cy2);
446    }
447
448    #[test]
449    fn noise3_range() {
450        let aura = Aura::new();
451        for x in 0..10u32 {
452            for y in 0..10u32 {
453                for z in 0..10u64 {
454                    let v = aura.noise3(x, y, z);
455                    assert!(v >= 0.0 && v <= 1.0, "noise3({x},{y},{z}) = {v}");
456                }
457            }
458        }
459    }
460
461    #[test]
462    fn sample_tier_noise_bounds() {
463        let tier = &['a', 'b', 'c', 'd'];
464        // noise 0.0 → index 0
465        assert_eq!(sample_tier(0.0, tier), 'a');
466        // noise 0.999 → last index
467        let idx = (0.999_f32 * tier.len() as f32) as usize % tier.len();
468        assert_eq!(sample_tier(0.999, tier), tier[idx]);
469        // never panics with empty tier
470        assert_eq!(sample_tier(0.5, &[]), ' ');
471    }
472
473    #[test]
474    fn inward_ripple_energy_at_center_r() {
475        let mut aura = Aura::new();
476        // Launch at time_s=0. Advance 400ms → center_r = 1.6 - 0.25*0.4 = 1.5.
477        aura.launch_inward_ripple();
478        aura.tick(std::time::Duration::from_millis(400));
479        // At r=1.5, dr=0, ring_env=1, energy ≈ strength*1*time_env ≈ 0.4.
480        let energy = aura.ripple_energy(1.5, 0.0, 0.0, 0.5, 0.5);
481        assert!(energy > 0.3, "expected energy > 0.3 at center of inward ripple, got {energy}");
482    }
483
484    #[test]
485    fn inward_ripple_zero_after_contraction() {
486        let mut aura = Aura::new();
487        aura.launch_inward_ripple();
488        // Advance 7s: center_r = (1.6 - 0.25*7).max(0) = 0.
489        // At r=1.5, dr=1.5 >> width=0.12 → ring_env=0 → energy=0.
490        aura.tick(std::time::Duration::from_secs(7));
491        let energy = aura.ripple_energy(1.5, 0.0, 0.0, 0.5, 0.5);
492        assert!(energy < 0.001, "expected ~0 after inward ripple contracts, got {energy}");
493    }
494
495    #[test]
496    fn outward_ripples_all_have_outward_direction() {
497        let mut aura = Aura::new();
498        aura.launch_ripples(0.7, 1.0);
499        assert!(!aura.ripples.is_empty());
500        for r in &aura.ripples {
501            assert_eq!(r.direction, RippleDir::Outward);
502        }
503        let area = ratatui::layout::Rect { x: 0, y: 0, width: 120, height: 40 };
504        aura.launch_ripple_at(0, 0, area, 1.0); // click outside hole
505        for r in &aura.ripples {
506            assert_eq!(r.direction, RippleDir::Outward);
507        }
508    }
509}