239 lines
8.0 KiB
Rust
239 lines
8.0 KiB
Rust
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::Indexed(0)));
|
||
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::Indexed(0)))
|
||
.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::Indexed(0)))
|
||
.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::Indexed(0))).percent(lp.min(100)).label("L"), la);
|
||
f.render_widget(Gauge::default().gauge_style(Style::default().fg(vc(rp)).bg(Color::Indexed(0))).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::Indexed(0)));
|
||
|
||
f.render_widget(help, area);
|
||
}
|
||
|
||
fn k(s: &str) -> Span<'_> {
|
||
Span::styled(
|
||
format!("[{}] ", s),
|
||
Style::default().fg(Color::White).add_modifier(Modifier::BOLD),
|
||
)
|
||
}
|