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), Constraint::Min(1), Constraint::Length(4), ]) .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::DarkGray), ), Span::styled( "PipeWire graph: each deck → rubberband filter-chain → sink", Style::default().fg(Color::DarkGray), ), ])); f.render_widget(header, chunks[0]); // 2×2 deck grid let rows = Layout::default() .direction(Direction::Vertical) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(chunks[1]); for row in 0..2usize { let cols = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .split(rows[row]); for col in 0..2usize { let idx = row * 2 + col; if idx < app.decks.len() { draw_deck(f, &app.decks[idx], cols[col], app.focused_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) }; // Truncate track name to fit let max_name = area.width.saturating_sub(18) 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() }; // Node ID status let graph_status = match (deck.stream_node_id, deck.filter_node_id) { (Some(s), Some(f)) => format!(" [pw:{}/{}]", s, f), (Some(s), None) => format!(" [pw:{}/--]", s), _ => " [pw:--]".to_string(), }; let title = format!( " Deck {} {} {} {} ", deck.id.label(), deck.play_state.icon(), track_display, graph_status, ); let outer = Block::default() .borders(Borders::ALL) .title(title) .border_style(border_style) .style(Style::default().bg(Color::Black)); f.render_widget(outer, area); let inner = area.inner(ratatui::layout::Margin { horizontal: 1, vertical: 1 }); let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(1), // timecode Constraint::Length(1), // progress Constraint::Length(1), // gap Constraint::Length(1), // fader Constraint::Length(1), // pitch + tempo Constraint::Length(1), // gap Constraint::Length(1), // VU ]) .split(inner); // 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::DarkGray)), ])), rows[0], ); // Progress f.render_widget( Gauge::default() .gauge_style(Style::default().fg(color).bg(Color::Black)) .percent((deck.progress() * 100.0) as u16) .label(""), rows[1], ); // Fader let fader_pct = (deck.fader * 100.0) as u16; f.render_widget( Gauge::default() .gauge_style(Style::default() .fg(if fader_pct > 10 { Color::White } else { Color::DarkGray }) .bg(Color::Black)) .percent(fader_pct.min(100)) .label(format!("VOL {:3}%", fader_pct)), rows[3], ); // Pitch + Tempo (both live in rubberband filter-chain node) f.render_widget( Paragraph::new(Line::from(vec![ Span::styled("PITCH ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{:+.1}st", deck.pitch), Style::default().fg(if deck.pitch.abs() > 0.05 { Color::Yellow } else { Color::White }), ), Span::styled(" TEMPO ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{:.2}×", deck.tempo), Style::default().fg(if (deck.tempo - 1.0).abs() > 0.01 { Color::Yellow } else { Color::White }), ), ])), rows[4], ); // VU draw_vu(f, deck.vu, rows[6], color); } fn draw_vu(f: &mut Frame, vu: (f32, f32), area: Rect, color: Color) { let half = area.width / 2; let la = Rect { x: area.x, y: area.y, width: half.saturating_sub(1), height: 1 }; let ra = Rect { x: area.x + half, y: area.y, width: half, height: 1 }; let vc = |p: u16| if p > 90 { Color::Red } else if p > 70 { Color::Yellow } else { color }; let lp = (vu.0 * 100.0) as u16; let rp = (vu.1 * 100.0) as u16; f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(lp)).bg(Color::Black)).percent(lp.min(100)).label("L"), la); f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp)).bg(Color::Black)).percent(rp.min(100)).label("R"), ra); } fn draw_help(f: &mut Frame, area: Rect) { let help = Paragraph::new(vec![ Line::from(vec![ Span::styled(" Focus: ", Style::default().fg(Color::DarkGray)), k("a"), k("z"), k("s"), k("x"), Span::styled(" Load: ", Style::default().fg(Color::DarkGray)), k("Shift+A/Z/S/X"), Span::styled(" Play/Pause: ", Style::default().fg(Color::DarkGray)), k("Space"), Span::styled(" Stop: ", Style::default().fg(Color::DarkGray)), k("Enter"), ]), Line::from(vec![ Span::styled(" Fader: ", Style::default().fg(Color::DarkGray)), k("↑/↓"), Span::styled(" Pitch: ", Style::default().fg(Color::DarkGray)), k("[/]"), Span::styled(" Tempo: ", Style::default().fg(Color::DarkGray)), k(",/."), Span::styled(" Cue: ", Style::default().fg(Color::DarkGray)), k("c"), Span::raw(" set "), k("v"), Span::raw(" goto"), Span::styled(" Seek: ", Style::default().fg(Color::DarkGray)), k("←/→"), Span::styled(" Quit: ", Style::default().fg(Color::DarkGray)), k("q"), ]), ]) .block(Block::default() .borders(Borders::TOP) .border_style(Style::default().fg(Color::DarkGray))) .style(Style::default().bg(Color::Black)); f.render_widget(help, area); } fn k(s: &str) -> Span<'_> { Span::styled( format!("[{}] ", s), Style::default().fg(Color::White).add_modifier(Modifier::BOLD), ) }