239 lines
7.9 KiB
Rust
239 lines
7.9 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::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),
|
|||
|
|
)
|
|||
|
|
}
|