Files
djdeck/src/tui.rs
T

535 lines
21 KiB
Rust
Raw Normal View History

2026-03-05 15:41:53 +01:00
use ratatui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Gauge, Paragraph},
Frame,
};
use crate::{app::App, deck::{Deck, DeckId}};
pub fn draw(f: &mut Frame, app: &mut App) {
let area = f.area();
draw_base(f, app, area);
if let Some(sel) = &mut app.file_selector {
sel.render(f, area);
}
}
fn draw_base(f: &mut Frame, app: &App, area: Rect) {
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // header
Constraint::Min(1), // 2×2 deck grid
Constraint::Length(3), // help
2026-03-05 15:41:53 +01:00
])
.split(area);
// Header
let header = Paragraph::new(Line::from(vec![
Span::styled(" ♫ djdeck ", Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD)),
Span::styled(
format!("OSC :{} → :{} ", crate::osc::LISTEN_PORT, crate::osc::SEND_PORT),
Style::default().fg(Color::Gray),
2026-03-05 15:41:53 +01:00
),
Span::styled(
"in-proc DSP: signalsmith-stretch(pitch+tempo) → biquad(cutoff) → schroeder(reverb)",
Style::default().fg(Color::Gray),
2026-03-05 15:41:53 +01:00
),
]));
f.render_widget(header, chunks[0]);
// 2×2 deck grid. Keyboard layout: A S / Z X
// Index mapping: top-left=A(0), top-right=S(2), bottom-left=Z(1), bottom-right=X(3)
let render_order = [[0usize, 2usize], [1usize, 3usize]];
2026-03-05 15:41:53 +01:00
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(chunks[1]);
for (row_i, row_idxs) in render_order.iter().enumerate() {
2026-03-05 15:41:53 +01:00
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(rows[row_i]);
2026-03-05 15:41:53 +01:00
for (col_i, &deck_idx) in row_idxs.iter().enumerate() {
if deck_idx < app.decks.len() {
draw_deck(f, &app.decks[deck_idx], cols[col_i], app.focused_deck == deck_idx);
2026-03-05 15:41:53 +01:00
}
}
}
draw_help(f, chunks[2]);
}
fn deck_color(id: DeckId) -> Color {
match id {
DeckId::A => Color::Cyan,
DeckId::Z => Color::Magenta,
DeckId::S => Color::Green,
DeckId::X => Color::Yellow,
}
}
fn draw_deck(f: &mut Frame, deck: &Deck, area: Rect, focused: bool) {
let color = deck_color(deck.id);
let border_style = if focused {
Style::default().fg(color).add_modifier(Modifier::BOLD)
} else {
Style::default().fg(Color::DarkGray)
};
// Title: deck label + play state + truncated track name + node IDs
let max_name = area.width.saturating_sub(22) as usize;
2026-03-05 15:41:53 +01:00
let track_display = deck.track_name.as_deref().unwrap_or("[no track]");
let track_display = if track_display.len() > max_name {
format!("{}…", &track_display[..max_name.saturating_sub(1)])
} else {
track_display.to_string()
};
let bpm = deck.bpm.map(|b| format!(" {:.1} BPM ", b)).unwrap_or_default();
2026-03-05 15:41:53 +01:00
let title = format!(
" Deck {} {} {}{}",
deck.id.label(), deck.play_state.icon(), track_display, bpm
2026-03-05 15:41:53 +01:00
);
let outer = Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(border_style);
2026-03-05 15:41:53 +01:00
f.render_widget(outer, area);
let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 });
if inner.height < 4 { return; }
// Layout inside the deck:
//
// [V][L][R] timecode + progress bar
// [V][L][R] info / effects
// [V][L][R] waveform (grows to fill space, min 4 rows)
// [V][L][R] timeline (1 row, at the very bottom)
//
// V = volume fader bar (3 chars: bar + space)
// L = left VU bar (1 char)
// R = right VU bar (1 char)
// Total left strip: 3 chars wide
let vu_cols = 3u16; // V·LR
let (left_area, right_area) = if inner.width > vu_cols + 4 {
let cols = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(vu_cols), Constraint::Min(1)])
.split(inner);
(Some(cols[0]), cols[1])
} else {
(None, inner)
};
// Right side: timecode(1) + progress(1) + info(2+) + waveform(4+) + timeline(1)
let fixed = 3u16; // timecode + progress + timeline
let wave_h = ((right_area.height / 2).max(4))
.min(right_area.height.saturating_sub(fixed + 2));
let info_h = right_area.height.saturating_sub(fixed + wave_h).max(2);
2026-03-05 15:41:53 +01:00
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), // timecode
Constraint::Length(1), // progress
Constraint::Length(info_h), // info + effects
Constraint::Length(wave_h), // waveform
Constraint::Length(1), // timeline label
2026-03-05 15:41:53 +01:00
])
.split(right_area);
2026-03-05 15:41:53 +01:00
// ── Left strip: V · L · R (full height) ─────────────────────────────────
if let Some(left) = left_area {
draw_vu_strip(f, deck, left, color);
}
// ── Timecode ─────────────────────────────────────────────────────────────
2026-03-05 15:41:53 +01:00
let (em, es) = deck.elapsed();
let (rm, rs) = deck.remaining();
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("{:02}:{:02}", em, es),
Style::default().fg(color).add_modifier(Modifier::BOLD),
),
2026-03-05 15:41:53 +01:00
Span::styled(" / ", Style::default().fg(Color::DarkGray)),
Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::White)),
2026-03-05 15:41:53 +01:00
])),
rows[0],
);
// ── Progress bar ─────────────────────────────────────────────────────────
2026-03-05 15:41:53 +01:00
f.render_widget(
Gauge::default()
2026-03-07 18:31:08 +01:00
.gauge_style(Style::default().fg(color))
2026-03-05 15:41:53 +01:00
.percent((deck.progress() * 100.0) as u16)
.label(""),
rows[1],
);
// ── Info + effects ────────────────────────────────────────────────────────
draw_info_effects(f, deck, rows[2]);
// ── Waveform ──────────────────────────────────────────────────────────────
draw_waveform(f, deck, rows[3], color);
// ── Timeline label (bottom row) ───────────────────────────────────────────
2026-03-05 15:41:53 +01:00
let fader_pct = (deck.fader * 100.0) as u16;
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("VOL {:3}%", fader_pct),
Style::default().fg(if fader_pct > 10 { Color::White } else { Color::Gray }),
2026-03-05 15:41:53 +01:00
),
])),
rows[4],
);
}
// ── Left VU/fader strip ───────────────────────────────────────────────────────
//
// Three 1-char-wide columns spanning the full deck inner height:
// col 0: V — volume/fader bar (white/gray)
// col 1: L — left channel VU (deck colour → yellow → red)
// col 2: R — right channel VU
// Bottom row of each column shows the label character.
2026-03-05 15:41:53 +01:00
fn draw_vu_strip(f: &mut Frame, deck: &Deck, area: Rect, color: Color) {
if area.width < 3 || area.height < 1 { return; }
let h = area.height as f32;
2026-03-05 15:41:53 +01:00
let bars: [(f32, Color, &str); 3] = [
(deck.fader, Color::White, "V"),
(deck.vu.0, color, "L"),
(deck.vu.1, color, "R"),
];
2026-03-05 15:41:53 +01:00
for (col, (level, base_col, label)) in bars.iter().enumerate() {
let x = area.x + col as u16;
let filled_rows = (*level * (h - 1.0)) as usize; // -1 for label row
let bar_color = if *level > 0.9 {
Color::Red
} else if *level > 0.7 {
Color::Yellow
} else {
*base_col
};
for row in 0..area.height {
let y = area.y + area.height - 1 - row;
let span = if row == 0 {
// Bottom row — label
Span::styled(label.to_string(), Style::default().fg(Color::Gray))
} else {
let ch = if (row as usize) <= filled_rows { '█' } else { ' ' };
Span::styled(ch.to_string(), Style::default().fg(bar_color))
};
f.render_widget(
Paragraph::new(Line::from(vec![span])),
Rect { x, y, width: 1, height: 1 },
);
}
}
2026-03-05 15:41:53 +01:00
}
// ── Info + effects grid ───────────────────────────────────────────────────────
fn draw_info_effects(f: &mut Frame, deck: &Deck, area: Rect) {
if area.height < 1 { return; }
// Artist/album (truncated) on first line, then effects in 2-column grid
let artist_str = deck.artist.as_deref().unwrap_or("");
let album_str = deck.album.as_deref().unwrap_or("");
let max_w = area.width.saturating_sub(1) as usize;
let meta_line = if !artist_str.is_empty() || !album_str.is_empty() {
let full = if !album_str.is_empty() {
format!("{} — {}", artist_str, album_str)
} else {
artist_str.to_string()
};
if full.len() > max_w {
format!("{}…", &full[..max_w.saturating_sub(1)])
} else {
full
}
} else {
String::new()
};
// Effects: pitch, tempo, cutoff, reverb — render in a 2×2 grid of text
let pitch_str = format!("PTCH {:+.1}st", deck.pitch);
let tempo_str = format!("TMPO {:.2}×", deck.tempo);
let cutoff_str = if deck.cutoff < -50.0 {
format!("CTOF LP {:.0}Hz", -deck.cutoff)
} else if deck.cutoff > 50.0 {
format!("CTOF HP {:.0}Hz", deck.cutoff)
} else {
"CTOF flat".to_string()
};
let reverb_str = format!("REVB {:.0}%", deck.reverb * 100.0);
let pitch_active = deck.pitch.abs() > 0.05;
let tempo_active = (deck.tempo - 1.0).abs() > 0.005;
let cutoff_active = deck.cutoff.abs() > 50.0;
let reverb_active = deck.reverb > 0.01;
let eff = |s: &str, active: bool| -> Span {
Span::styled(
s.to_string(),
Style::default().fg(if active { Color::Yellow } else { Color::Gray }),
)
};
let mut lines: Vec<Line> = Vec::new();
if !meta_line.is_empty() {
lines.push(Line::from(vec![
Span::styled(meta_line, Style::default().fg(Color::White)),
]));
}
// Effects grid: two per row
let col_w = (area.width as usize / 2).max(12);
let e0 = format!("{:<width$}", pitch_str, width = col_w);
let e1 = format!("{:<width$}", tempo_str, width = col_w);
let e2 = format!("{:<width$}", cutoff_str, width = col_w);
let e3 = reverb_str.clone();
lines.push(Line::from(vec![eff(&e0, pitch_active), eff(&e1, tempo_active)]));
lines.push(Line::from(vec![eff(&e2, cutoff_active), eff(&e3, reverb_active)]));
f.render_widget(
Paragraph::new(lines),
area,
);
}
// ── Waveform timeline ─────────────────────────────────────────────────────────
//
// Shows a 30-second window centred on the current playback position.
// Each terminal column represents (30s / width) seconds of audio.
// Amplitude encoded using braille dot-matrix: each char = 2 cols × 4 rows,
// giving 8 amplitude levels per 2 horizontal samples.
//
// We use 2 terminal rows → each row covers 4 braille dot rows → amplitude
// is encoded across both rows for 8 distinct levels per column.
const BRAILLE_BASE: u32 = 0x2800;
/// Map an amplitude 0..1 into braille dots for two terminal rows (top, bottom).
/// The braille char encodes a 2-wide × 4-tall grid; we use the left column only
/// (dots 1,2,3,7 = bottom row; dots 4,5,6,8 = hmm… let's use block rows).
/// Simpler: use the 8 vertical block elements for a single row.
fn amplitude_to_braille_char(amp: f32) -> char {
// Fill both columns symmetrically from bottom to top.
// Braille dot layout (standard numbering):
// 1 4 (top)
// 2 5
// 3 6
// 7 8 (bottom)
// Bit positions: dot1=0, dot2=1, dot3=2, dot4=3, dot5=4, dot6=5, dot7=6, dot8=7
//
// Fill order (bottom-first, both halves together):
// L-bot both-bot L-2nd both-2nd L-3rd both-3rd L-top both-top
// 7(6) 8(7) 3(2) 6(5) 2(1) 5(4) 1(0) 4(3)
//
// This makes partial cells look wide (not thin left sticks), so waveform
// peaks are visually clean at any height.
let level = (amp.clamp(0.0, 1.0) * 8.0) as u32;
const BITS: [u32; 9] = [
0x00, // 0 — empty
0x40, // 1 — ⡀ dot7
0xC0, // 2 — ⣀ dot7+dot8
0xC4, // 3 — ⣄ + dot3
0xE4, // 4 — ⣤ + dot6
0xE6, // 5 — ⣦ + dot2
0xF6, // 6 — ⣶ + dot5
0xF7, // 7 — ⣷ + dot1
0xFF, // 8 — ⣿ + dot4 (full)
];
let code = BRAILLE_BASE + BITS[level.min(8) as usize];
char::from_u32(code).unwrap_or('░')
}
/// Stereo-band waveform.
///
/// Each terminal column always represents an integer number of peak buckets,
/// and the viewport's left edge is *snapped* to a bucket boundary. That
/// guarantees a given audio sample always lands in the same column as long as
/// the window width is unchanged, so the bars hold still while the playhead
/// slides across them — no per-frame re-scaling, no dithering.
///
/// The bar height in each column is the max of the three band peaks (overall
/// loudness). The column's color is `(R = bass, G = mid, B = high)`, scaled to
/// the band ratios — bass-heavy kicks come out red, mids green, hats blue, a
/// full mix near-white.
fn draw_waveform(f: &mut Frame, deck: &Deck, area: Rect, focus_color: Color) {
if area.width < 4 || area.height < 1 { return; }
let n_rows = area.height as usize;
let width = area.width as usize;
let sr = deck.sample_rate as f64;
let pos_secs = deck.position as f64 / sr;
let win_secs = 30.0_f64;
// PEAK_BUCKET must match audio.rs's constant.
const PEAK_BUCKET: usize = 4096;
let secs_per_peak = PEAK_BUCKET as f64 / sr;
let bands = &deck.waveform_bands;
let total = bands.len();
// Buckets per column. Snap to integer so the bucket→column mapping is
// stable as `pos_secs` advances by less than one column's worth of audio.
let buckets_per_col = ((win_secs / secs_per_peak) / width as f64)
.round()
.max(1.0) as usize;
let secs_per_col = buckets_per_col as f64 * secs_per_peak;
// Snap viewport left edge to a column-aligned bucket boundary.
let half_win = secs_per_col * width as f64 / 2.0;
let target_left_sec = (pos_secs - half_win).max(0.0);
let left_bucket = ((target_left_sec / secs_per_peak) as usize
/ buckets_per_col)
* buckets_per_col;
let cursor_col = ((pos_secs - left_bucket as f64 * secs_per_peak) / secs_per_col)
.clamp(0.0, width as f64) as usize;
// Per-column band amplitudes (max-of-bucket within column).
let col_bands: Vec<[f32; 3]> = (0..width).map(|col| {
let bs = left_bucket + col * buckets_per_col;
let be = (bs + buckets_per_col).min(total);
if bs >= total { return [0.0, 0.0, 0.0]; }
let mut acc = [0.0f32; 3];
for b in &bands[bs..be] {
if b[0] > acc[0] { acc[0] = b[0]; }
if b[1] > acc[1] { acc[1] = b[1]; }
if b[2] > acc[2] { acc[2] = b[2]; }
}
acc
}).collect();
// Build per-column (height, color) once, reused across rows.
let col_render: Vec<(f32, Color)> = col_bands.iter().map(|b| {
let height = b[0].max(b[1]).max(b[2]); // total loudness
let color = blend_band_color(b[0], b[1], b[2], focus_color);
(height, color)
}).collect();
// Render row by row, top-down. Each row covers a fixed amplitude slice.
for row in 0..n_rows {
let band_top = 1.0 - row as f32 / n_rows as f32;
let band_bottom = 1.0 - (row + 1) as f32 / n_rows as f32;
let band_size = band_top - band_bottom;
let mut spans: Vec<Span> = Vec::with_capacity(width);
for col in 0..width {
let (amp, color) = col_render[col];
// Past columns get the band color directly; future columns get a
// dimmer version so the playhead is visually obvious.
let style_color = if col < cursor_col {
color
} else if col == cursor_col {
Color::White
} else {
dim_color(color)
};
let ch = if col == cursor_col && row == n_rows - 1 {
'┃'
} else {
let local_amp = if amp >= band_top {
1.0
} else if amp <= band_bottom {
0.0
} else {
(amp - band_bottom) / band_size
};
amplitude_to_braille_char(local_amp)
};
spans.push(Span::styled(ch.to_string(), Style::default().fg(style_color)));
}
f.render_widget(
Paragraph::new(Line::from(spans)),
Rect { y: area.y + row as u16, height: 1, ..area },
);
}
}
/// Map per-band peaks (bass, mid, high) ∈ [0,1] to an RGB color via additive
/// mixing: bass=red, mid=green, high=blue. Silence falls back to the deck's
/// focus color so empty regions still look like a deck strip, not garbage.
fn blend_band_color(bass: f32, mid: f32, high: f32, fallback: Color) -> Color {
let total = bass + mid + high;
if total < 0.01 { return fallback; }
// Normalize each band to 0..1 of the strongest band, then map to byte.
let m = bass.max(mid).max(high).max(1e-6);
let r = (bass / m * 255.0).clamp(0.0, 255.0) as u8;
let g = (mid / m * 255.0).clamp(0.0, 255.0) as u8;
let b = (high / m * 255.0).clamp(0.0, 255.0) as u8;
Color::Rgb(r, g, b)
}
/// Darken an RGB color so the not-yet-played future portion of the waveform
/// is visually distinct from the past portion.
fn dim_color(c: Color) -> Color {
match c {
Color::Rgb(r, g, b) => Color::Rgb(r / 3, g / 3, b / 3),
_ => Color::DarkGray,
}
}
// ── Help bar ─────────────────────────────────────────────────────────────────
2026-03-05 15:41:53 +01:00
fn draw_help(f: &mut Frame, area: Rect) {
let help = Paragraph::new(vec![
Line::from(vec![
Span::styled(" Focus: ", Style::default().fg(Color::Gray)),
k("a"), k("s"), k("z"), k("x"),
Span::styled(" Load: ", Style::default().fg(Color::Gray)),
k("Shift+A/S/Z/X"),
Span::styled(" Play/Pause: ", Style::default().fg(Color::Gray)),
2026-03-05 15:41:53 +01:00
k("Space"),
Span::styled(" Stop: ", Style::default().fg(Color::Gray)),
2026-03-05 15:41:53 +01:00
k("Enter"),
Span::styled(" Seek: ", Style::default().fg(Color::Gray)),
k("←/→"),
Span::styled(" Quit: ", Style::default().fg(Color::Gray)),
k("q"),
2026-03-05 15:41:53 +01:00
]),
Line::from(vec![
Span::styled(" Fader: ", Style::default().fg(Color::Gray)), k("↑/↓"),
Span::styled(" Pitch: ", Style::default().fg(Color::Gray)), k("[/]"),
Span::styled(" Tempo: ", Style::default().fg(Color::Gray)), k(",/."),
Span::styled(" Cutoff: ",Style::default().fg(Color::Gray)), k("-/="),
Span::styled(" Reverb: ",Style::default().fg(Color::Gray)), k("o/p"),
Span::styled(" Cue: ", Style::default().fg(Color::Gray)),
k("c"), Span::raw("set "), k("v"), Span::raw("goto"),
2026-03-05 15:41:53 +01:00
]),
])
.block(Block::default()
.borders(Borders::TOP)
.border_style(Style::default().fg(Color::DarkGray)));
2026-03-05 15:41:53 +01:00
f.render_widget(help, area);
}
fn k(s: &str) -> Span<'_> {
Span::styled(
format!("[{}] ", s),
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
)
}