Skip to main content

rust_synth/tui/
pattern.rs

1//! Step-sequencer grid for the currently-selected drum track.
2//!
3//! Shows the 16-step Euclidean pattern of the selected track (only
4//! meaningful for Heartbeat — other presets ignore pattern_bits, but the
5//! widget stays harmless). Current step is highlighted so you see the
6//! play-head walk the grid in sync with the tempo row.
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 super::app::AppState;
15use crate::audio::engine::EngineHandle;
16use crate::audio::preset::PresetKind;
17use crate::math::rhythm;
18
19const STEPS: u32 = rhythm::STEPS;
20
21pub fn render(f: &mut Frame, area: Rect, engine: &EngineHandle, app: &AppState) {
22    let tracks = engine.tracks.lock();
23    let Some(track) = tracks.get(app.selected_track) else {
24        return;
25    };
26    let snap = track.params.snapshot();
27    let kind = track.kind;
28    let name = track.name.clone();
29    drop(tracks);
30
31    let bpm = engine.global.bpm.value() as f64;
32    let t = engine.phase_clock.value() as f64;
33    let (cur_step_idx, _) = rhythm::step_position(t, bpm, 4.0);
34    let cur_step = (cur_step_idx % STEPS as u64) as u32;
35
36    let is_drum = matches!(kind, PresetKind::Heartbeat);
37    let bits = if is_drum { snap.pattern_bits } else { 0 };
38
39    let title = if is_drum {
40        format!(
41            " pattern · {} · {} hits, rot {} ",
42            name,
43            snap.pattern_hits.round() as u32,
44            snap.pattern_rotation.round() as u32,
45        )
46    } else {
47        format!(" pattern · {} · (non-drum, ignored) ", name)
48    };
49
50    let mut cells: Vec<Span> = Vec::with_capacity(STEPS as usize * 2);
51    for step in 0..STEPS {
52        let active = (bits >> step) & 1 == 1;
53        let is_current = step == cur_step && is_drum;
54        let glyph: &'static str = match (active, is_current) {
55            (true, true) => "██",
56            (true, false) => "▓▓",
57            (false, true) => "▕▏",
58            (false, false) => "··",
59        };
60        let color = match (active, is_current) {
61            (true, true) => Color::Yellow,
62            (true, false) if is_drum => Color::Red,
63            (true, false) => Color::DarkGray,
64            (false, true) => Color::Rgb(120, 120, 140),
65            (false, false) => Color::Rgb(40, 40, 44),
66        };
67        let style = if is_current {
68            Style::default().fg(color).add_modifier(Modifier::BOLD)
69        } else {
70            Style::default().fg(color)
71        };
72        cells.push(Span::styled(glyph, style));
73        // Group visual quarters: put a space every 4 steps.
74        if (step + 1) % 4 == 0 && step + 1 < STEPS {
75            cells.push(Span::raw("  "));
76        } else {
77            cells.push(Span::raw(" "));
78        }
79    }
80
81    // Key hints live in the help line at the bottom — no need for a
82    // second copy inside this pane.  Title carries the useful info
83    // (track name · hits · rot) so the pane is a single row of cells.
84    let body = vec![Line::from(cells)];
85
86    let block = Block::default()
87        .borders(Borders::ALL)
88        .title(title)
89        .title_style(Style::default().add_modifier(Modifier::BOLD));
90    let para = Paragraph::new(body).block(block);
91    f.render_widget(para, area);
92}