Skip to main content

rust_synth/tui/
life.rs

1//! Game of Life — chunky pixel-art renderer.
2//!
3//! One text line per grid row, each cell rendered as a solid 2-character
4//! block `██` in the track's preset colour. Dead cells are near-invisible
5//! `··`. The current beat column gets a subtle backlight so you can see
6//! the "playhead" crawl across.
7
8use ratatui::layout::Rect;
9use ratatui::style::{Color, Modifier, Style};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Paragraph};
12use ratatui::Frame;
13
14use crate::audio::engine::EngineHandle;
15use crate::audio::preset::PresetKind;
16
17use super::app::AppState;
18
19const CELL: &str = "██";
20const DEAD: &str = "··";
21
22pub fn render(f: &mut Frame, area: Rect, engine: &EngineHandle, app: &AppState) {
23    let life = &app.life;
24    let tracks = engine.tracks.lock();
25
26    let bpm = engine.global.bpm.value();
27    let t = engine.phase_clock.value();
28    let beat = (t * bpm / 60.0).floor() as i64;
29    let cur_col = beat.rem_euclid(life.cols as i64) as usize;
30
31    let mut lines: Vec<Line> = Vec::with_capacity(life.rows);
32    for r in 0..life.rows {
33        let (color, label, muted) = match tracks.get(r) {
34            Some(track) => {
35                let snap_muted = track.params.mute.value() > 0.5;
36                (color_for(track.kind), track.kind.label(), snap_muted)
37            }
38            None => (Color::DarkGray, "—", true),
39        };
40
41        let mut spans: Vec<Span<'static>> = Vec::with_capacity(life.cols + 3);
42        spans.push(Span::styled(
43            format!(" {:>9} ", label),
44            Style::default().fg(if muted { Color::DarkGray } else { color }),
45        ));
46
47        for c in 0..life.cols {
48            let alive = life.alive(r, c);
49            let on_cursor = c == cur_col;
50            let base_style = if on_cursor {
51                Style::default().bg(Color::Rgb(25, 25, 40))
52            } else {
53                Style::default()
54            };
55            let span = if alive {
56                let fg = if muted {
57                    dim(color)
58                } else {
59                    color
60                };
61                Span::styled(
62                    CELL,
63                    base_style.fg(fg).add_modifier(Modifier::BOLD),
64                )
65            } else if on_cursor {
66                Span::styled("▕▏", base_style.fg(Color::Rgb(80, 80, 100)))
67            } else {
68                Span::styled(DEAD, Style::default().fg(Color::Rgb(28, 28, 32)))
69            };
70            spans.push(span);
71        }
72
73        lines.push(Line::from(spans));
74    }
75
76    drop(tracks);
77
78    let title = format!(
79        " life · gen {} · density {:>5.1}% ",
80        life.generation,
81        life.density() * 100.0,
82    );
83    let block = Block::default()
84        .borders(Borders::ALL)
85        .title(title)
86        .title_style(Style::default().add_modifier(Modifier::BOLD));
87    let para = Paragraph::new(lines).block(block);
88    f.render_widget(para, area);
89}
90
91fn color_for(kind: PresetKind) -> Color {
92    match kind {
93        PresetKind::PadZimmer => Color::Cyan,
94        PresetKind::DroneSub => Color::Magenta,
95        PresetKind::Shimmer => Color::LightYellow,
96        PresetKind::Heartbeat => Color::Red,
97        PresetKind::BassPulse => Color::Green,
98        PresetKind::Bell => Color::LightBlue,
99    }
100}
101
102fn dim(c: Color) -> Color {
103    match c {
104        Color::Cyan => Color::Rgb(40, 80, 80),
105        Color::Magenta => Color::Rgb(80, 40, 80),
106        Color::LightYellow => Color::Rgb(80, 80, 40),
107        Color::Red => Color::Rgb(80, 30, 30),
108        _ => Color::DarkGray,
109    }
110}