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 ]) .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), ), Span::styled( "in-proc DSP: signalsmith-stretch(pitch+tempo) → biquad(cutoff) → schroeder(reverb)", Style::default().fg(Color::Gray), ), ])); 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]]; 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() { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(rows[row_i]); 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); } } } 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; 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(); let title = format!( " Deck {} {} {}{}", deck.id.label(), deck.play_state.icon(), track_display, bpm ); let outer = Block::default() .borders(Borders::ALL) .title(title) .border_style(border_style); 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); 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 ]) .split(right_area); // ── Left strip: V · L · R (full height) ───────────────────────────────── if let Some(left) = left_area { draw_vu_strip(f, deck, left, color); } // ── Timecode ───────────────────────────────────────────────────────────── 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), ), Span::styled(" / ", Style::default().fg(Color::DarkGray)), Span::styled(format!("-{:02}:{:02}", rm, rs), Style::default().fg(Color::White)), ])), rows[0], ); // ── Progress bar ───────────────────────────────────────────────────────── f.render_widget( Gauge::default() .gauge_style(Style::default().fg(color)) .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) ─────────────────────────────────────────── 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 }), ), ])), 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. 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; let bars: [(f32, Color, &str); 3] = [ (deck.fader, Color::White, "V"), (deck.vu.0, color, "L"), (deck.vu.1, color, "R"), ]; 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 }, ); } } } // ── 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 = 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!("{: 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 = 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 ───────────────────────────────────────────────────────────────── 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)), k("Space"), Span::styled(" Stop: ", Style::default().fg(Color::Gray)), k("Enter"), Span::styled(" Seek: ", Style::default().fg(Color::Gray)), k("←/→"), Span::styled(" Quit: ", Style::default().fg(Color::Gray)), k("q"), ]), 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"), ]), ]) .block(Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray))); f.render_widget(help, area); } fn k(s: &str) -> Span<'_> { Span::styled( format!("[{}] ", s), Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ) }