initial code scetch

This commit is contained in:
2026-03-05 15:41:53 +01:00
commit 8a7acd6d5b
9 changed files with 1554 additions and 0 deletions

238
src/tui.rs Normal file
View File

@@ -0,0 +1,238 @@
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),
)
}