Files
djdeck/src/tui.rs

239 lines
8.0 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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),
)
}