use std::io::{self, Stdout};
use std::time::{Duration, Instant};
use crossterm::event::{self, Event, KeyCode, KeyEventKind};
use crossterm::execute;
use crossterm::terminal::{
EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode,
};
use ratatui::Terminal;
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Stylize;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Clear, Paragraph};
use unicode_width::UnicodeWidthStr;
use crate::RuntimeState;
use crate::engine_actor::{EngineCmd, EngineHandle};
use crate::validate::ValidatedConfig;
const TICK_RATE: Duration = Duration::from_millis(50);
const COLOR_BORDER: Color = Color::Cyan;
const COLOR_IN: Color = Color::Green;
const COLOR_OUT: Color = Color::Magenta;
const COLOR_ROUTE: Color = Color::LightBlue;
const COLOR_GAIN: Color = Color::Yellow;
const COLOR_CLIP: Color = Color::Red;
pub fn run(
handle: EngineHandle,
config_path: &std::path::Path,
warnings: &[String],
) -> Result<(), crate::error::AppError> {
enable_raw_mode().map_err(term_err)?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen).map_err(term_err)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend).map_err(term_err)?;
let start_time = Instant::now();
let mut loop_state = LoopState {
log_lines: warnings.to_vec(),
last_tick: Instant::now(),
show_disconnected_devices: false,
show_missing_devices: true,
show_help: false,
config_path: config_path.to_path_buf(),
};
let result = run_loop(&mut terminal, &handle, start_time, &mut loop_state);
disable_raw_mode().ok();
execute!(terminal.backend_mut(), LeaveAlternateScreen).ok();
terminal.show_cursor().ok();
result
}
struct LoopState {
log_lines: Vec<String>,
last_tick: Instant,
show_disconnected_devices: bool,
show_missing_devices: bool,
show_help: bool,
config_path: std::path::PathBuf,
}
const LOG_PANEL_HEIGHT: u16 = 7;
const LOG_VISIBLE_LINES: u16 = LOG_PANEL_HEIGHT.saturating_sub(2);
fn run_loop(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
handle: &EngineHandle,
start_time: Instant,
st: &mut LoopState,
) -> Result<(), crate::error::AppError> {
let mut scroll: u16 = 0;
loop {
let timeout = TICK_RATE
.checked_sub(st.last_tick.elapsed())
.unwrap_or(Duration::from_millis(0));
if event::poll(timeout).map_err(term_err)?
&& let Event::Key(key) = event::read().map_err(term_err)?
{
if key.kind != KeyEventKind::Press {
continue;
}
match key.code {
KeyCode::Char('q') => {
handle.cmd_tx.try_send(EngineCmd::Stop).ok();
break;
}
KeyCode::Esc => {
if st.show_help {
st.show_help = false;
} else {
handle.cmd_tx.try_send(EngineCmd::Stop).ok();
break;
}
}
KeyCode::Char('?') => {
st.show_help = !st.show_help;
}
KeyCode::Char('r') => {
handle.cmd_tx.try_send(EngineCmd::Reload).ok();
}
KeyCode::Char('l')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
handle.cmd_tx.try_send(EngineCmd::ResetPeaks).ok();
st.log_lines.push(format!(
"[{}] peak-hold / clip reset",
timestamp(start_time)
));
}
KeyCode::Char('h') => {
st.show_disconnected_devices = !st.show_disconnected_devices;
st.log_lines.push(format!(
"[{}] disconnected devices {}",
timestamp(start_time),
if st.show_disconnected_devices {
"shown"
} else {
"hidden"
},
));
}
KeyCode::Char('H') => {
st.show_missing_devices = !st.show_missing_devices;
st.log_lines.push(format!(
"[{}] missing devices {}",
timestamp(start_time),
if st.show_missing_devices {
"shown"
} else {
"hidden"
},
));
}
KeyCode::Char('c')
if key
.modifiers
.contains(crossterm::event::KeyModifiers::CONTROL) =>
{
handle.cmd_tx.try_send(EngineCmd::Stop).ok();
break;
}
KeyCode::Char('g') => {
scroll = max_log_scroll(st.log_lines.len());
}
KeyCode::Char('G') => {
scroll = 0;
}
KeyCode::Up => {
scroll = clamp_log_scroll(scroll.saturating_add(1), st.log_lines.len());
}
KeyCode::PageUp => {
scroll = clamp_log_scroll(
scroll.saturating_add(LOG_VISIBLE_LINES),
st.log_lines.len(),
);
}
KeyCode::Down => {
scroll = scroll.saturating_sub(1);
}
KeyCode::PageDown => {
scroll = scroll.saturating_sub(LOG_VISIBLE_LINES);
}
_ => {}
}
}
if st.last_tick.elapsed() >= TICK_RATE {
st.last_tick = Instant::now();
}
scroll = clamp_log_scroll(scroll, st.log_lines.len());
while let Ok(msg) = handle.log_rx.try_recv() {
st.log_lines
.push(format!("[{}] {msg}", timestamp(start_time)));
}
for line in crate::log_buffer::drain() {
st.log_lines
.push(format!("[{}] {line}", timestamp(start_time)));
}
let (plan_arc, resolved_arc, meter_bank_arc, engine_state) = {
let view = handle.shared.read().unwrap();
(
view.plan.clone(),
view.resolved.clone(),
view.meter_bank.clone(),
view.snapshot.state.clone(),
)
};
match engine_state {
RuntimeState::FatalError => {
st.log_lines.push(format!(
"[{}] fatal audio error — exiting",
timestamp(start_time)
));
draw(
terminal,
&plan_arc,
&resolved_arc,
&meter_bank_arc,
start_time,
&st.log_lines,
scroll,
st.show_disconnected_devices,
st.show_missing_devices,
st.show_help,
&st.config_path,
)?;
std::thread::sleep(Duration::from_secs(2));
return Err(crate::error::AppError::runtime("fatal audio stream error"));
}
RuntimeState::Stopped => break,
_ => {}
}
draw(
terminal,
&plan_arc,
&resolved_arc,
&meter_bank_arc,
start_time,
&st.log_lines,
scroll,
st.show_disconnected_devices,
st.show_missing_devices,
st.show_help,
&st.config_path,
)?;
}
Ok(())
}
fn max_log_scroll(total_lines: usize) -> u16 {
total_lines
.saturating_sub(LOG_VISIBLE_LINES as usize)
.min(u16::MAX as usize) as u16
}
fn clamp_log_scroll(scroll: u16, total_lines: usize) -> u16 {
scroll.min(max_log_scroll(total_lines))
}
#[allow(clippy::too_many_arguments)]
fn draw(
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
plan: &ValidatedConfig,
resolved: &crate::devices::ResolvedAudioDevices,
meter_bank: &crate::meter::MeterBank,
start_time: Instant,
log_lines: &[String],
scroll: u16,
show_disconnected: bool,
show_missing: bool,
show_help: bool,
config_path: &std::path::Path,
) -> Result<(), crate::error::AppError> {
terminal
.draw(|f| {
let area = f.area();
if area.height < 16 {
draw_compact(f, area, start_time, plan, resolved, meter_bank);
return;
}
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Min(16), Constraint::Length(LOG_PANEL_HEIGHT), Constraint::Length(1), ])
.split(area);
draw_status_bar(f, chunks[0], plan, resolved, start_time, config_path);
draw_routing_graph(
f,
chunks[1],
plan,
resolved,
meter_bank,
show_disconnected,
show_missing,
);
draw_log(f, chunks[2], log_lines, scroll);
draw_help(f, chunks[3]);
if show_help {
draw_help_popup(f, area);
}
})
.map_err(term_err)?;
Ok(())
}
const APP_VERSION: &str = match option_env!("APP_VERSION") {
Some(v) => v,
None => env!("CARGO_PKG_VERSION"),
};
fn draw_status_bar(
f: &mut ratatui::Frame<'_>,
area: Rect,
plan: &ValidatedConfig,
resolved: &crate::devices::ResolvedAudioDevices,
start_time: Instant,
config_path: &std::path::Path,
) {
let elapsed = start_time.elapsed();
let mins = elapsed.as_secs() / 60;
let secs = elapsed.as_secs() % 60;
let app_label = format!("audiorouter v{APP_VERSION}");
let stats = format!(
" \u{2502} \u{1f3b5} {}kHz \u{2502} \u{1f39a} buf {} \u{2502} \u{23f1} {}m{:02}s \u{2502} \u{1f517} {}/{} ",
plan.config.engine.sample_rate / 1000,
plan.config.engine.buffer_size,
mins,
secs,
resolved.active_route_count(plan),
plan.routes.len(),
);
let label_w = app_label.width() as u16;
let stats_w = stats.width() as u16;
f.buffer_mut().set_string(
area.x,
area.y,
&app_label,
Style::default().add_modifier(Modifier::REVERSED | Modifier::BOLD),
);
f.buffer_mut().set_string(
area.x + label_w,
area.y,
&stats,
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
let path_str = abbreviate_home(config_path);
let path_w = path_str.width() as u16;
let used = label_w + stats_w;
if area.width > used + path_w {
f.buffer_mut().set_string(
area.x + area.width - path_w,
area.y,
&path_str,
Style::default().fg(Color::DarkGray),
);
}
}
fn abbreviate_home(path: &std::path::Path) -> String {
if let Some(home) = dirs::home_dir()
&& let Ok(rel) = path.strip_prefix(&home)
{
return format!("~/{}", rel.display());
}
path.display().to_string()
}
fn draw_compact(
f: &mut ratatui::Frame<'_>,
area: Rect,
start_time: Instant,
plan: &ValidatedConfig,
resolved: &crate::devices::ResolvedAudioDevices,
meter_bank: &crate::meter::MeterBank,
) {
let mut lines = vec![Line::from(Span::styled(
format!(
"audiorouter · {}/{} routes · {} Hz · ↑{}s",
resolved.active_route_count(plan),
plan.routes.len(),
plan.config.engine.sample_rate,
start_time.elapsed().as_secs()
),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
))];
for (i, route) in plan.routes.iter().enumerate() {
let meter = meter_bank.get(&route.from, 1);
let level = meter.map(|m| m.snapshot().peak).unwrap_or(0.0);
let bar_len = ((level * 10.0) as usize).min(10);
let bar: String = "█".repeat(bar_len);
let prefix = if resolved.route_enabled(i) {
""
} else {
"OFF "
};
lines.push(Line::from(format!(
"{}{} → {} {:>6.1}dB {}",
prefix, route.from, route.to, route.gain_db, bar
)));
}
let para = Paragraph::new(lines);
f.render_widget(para, area);
}
fn draw_routing_graph(
f: &mut ratatui::Frame<'_>,
area: Rect,
plan: &ValidatedConfig,
resolved: &crate::devices::ResolvedAudioDevices,
meter_bank: &crate::meter::MeterBank,
show_disconnected: bool,
show_missing: bool,
) {
let mut title_parts = vec!["\u{1f500} Routing Graph".to_string()]; if !show_disconnected {
title_parts.push("\u{26d3}\u{fe0f}\u{200d}\u{1f4a5} disconnected hidden".to_string()); }
if !show_missing {
title_parts.push("\u{1f6ab} missing hidden".to_string()); }
let title = format!(" {} ", title_parts.join(" \u{2502} "));
let block = Block::default()
.borders(Borders::ALL)
.title_top(title.bold());
f.render_widget(&block, area);
let inner = block.inner(area);
if inner.height < 5 || inner.width < 20 {
return;
}
let disconnected = crate::graph::disconnected_device_names(plan);
let disconnected_area_height = if show_disconnected && !disconnected.is_empty() {
(disconnected.len() as u16 + 1).min(inner.height / 3)
} else {
0
};
let graph_area = Rect {
height: inner.height.saturating_sub(disconnected_area_height),
..inner
};
if plan.routes.is_empty() {
let msg =
Paragraph::new("No routes to display").style(Style::default().fg(Color::DarkGray));
f.render_widget(msg, graph_area);
return;
}
let missing = resolved.missing_device_aliases();
let exclude: std::collections::HashSet<String> = if show_missing {
std::collections::HashSet::new()
} else {
crate::graph::cascade_hidden(plan, &missing)
};
let layout = crate::graph::compute_layout(plan, &exclude);
if layout.is_empty() {
return;
}
let max_layer = layout.iter().map(|n| n.layer).max().unwrap_or(0);
let max_row = layout.iter().map(|n| n.row).max().unwrap_or(0);
const NODE_W: u16 = 24;
const NODE_H: u16 = 6;
const ROW_GAP: u16 = 1;
const MIN_PANEL_PAD: u16 = 2;
const MIN_ROUTE_GAP: u16 = 10;
const MAX_ROUTE_GAP: u16 = 24;
let node_w = NODE_W.min(graph_area.width.saturating_sub(2)).max(12);
let node_h = NODE_H.min(graph_area.height.saturating_sub(1)).max(5);
let layer_count = max_layer as u16 + 1;
let row_count = max_row as u16 + 1;
let required_route_gap = plan
.routes
.iter()
.enumerate()
.filter(|(_, r)| !exclude.contains(&r.from) && !exclude.contains(&r.to))
.map(|(i, r)| {
let src = channel_label(&r.from_channels).width() as u16;
let dst = channel_label(&r.to_channels).width() as u16;
let gain = if !resolved.route_enabled(i) {
3 } else if r.mute {
1 } else if r.gain_db == 0.0 {
6 } else {
format!(" {:+.1}dB", r.gain_db).width() as u16
};
src + dst + gain + 6
})
.max()
.unwrap_or(MIN_ROUTE_GAP)
.clamp(MIN_ROUTE_GAP, MAX_ROUTE_GAP);
let available_w = graph_area.width.saturating_sub(MIN_PANEL_PAD * 2);
let natural_graph_w = layer_count * node_w + max_layer as u16 * required_route_gap;
let graph_w = natural_graph_w.min(available_w).max(node_w);
let route_gap = if max_layer == 0 {
0
} else {
graph_w
.saturating_sub(layer_count * node_w)
.checked_div(max_layer as u16)
.unwrap_or(0)
};
let graph_left = graph_area.x + (graph_area.width.saturating_sub(graph_w) / 2);
let col_x = |layer: usize| -> u16 { graph_left + layer as u16 * (node_w + route_gap) };
let row_spacing = node_h + ROW_GAP;
let natural_graph_h = row_count * node_h + max_row as u16 * ROW_GAP;
let graph_h = natural_graph_h.min(graph_area.height).max(node_h);
let graph_top = graph_area.y + (graph_area.height.saturating_sub(graph_h) / 2);
let mut nodes: Vec<NodeInfo> = Vec::new();
for placed in &layout {
nodes.push(NodeInfo {
alias: placed.alias.clone(),
x: col_x(placed.layer),
y: graph_top + placed.row as u16 * row_spacing,
});
}
let visible_edges: Vec<(usize, usize, usize)> = plan
.routes
.iter()
.enumerate()
.filter_map(|(ri, route)| {
let si = nodes.iter().position(|n| n.alias == route.from)?;
let di = nodes.iter().position(|n| n.alias == route.to)?;
Some((ri, si, di))
})
.collect();
let ne = visible_edges.len();
let (src_ys, dst_ys) = assign_connection_rows(&visible_edges, &nodes, node_h);
let mut mid_xs = vec![0u16; ne];
{
let mut groups: std::collections::HashMap<(u16, u16), Vec<usize>> =
std::collections::HashMap::new();
for (ei, &(_, si, di)) in visible_edges.iter().enumerate() {
let sr = nodes[si].x + node_w;
let dl = nodes[di].x;
groups.entry((sr, dl)).or_default().push(ei);
}
for ((sr, dl), mut indices) in groups {
indices.sort_unstable();
let n = indices.len() as i32;
let base = ((sr + dl) / 2) as i32;
let lo = sr as i32 + 1;
let hi = (dl as i32 - 1).max(lo);
for (i, ei) in indices.into_iter().enumerate() {
let offset = i as i32 - (n - 1) / 2;
mid_xs[ei] = (base + offset).clamp(lo, hi) as u16;
}
}
}
for (ei, &(ri, si, di)) in visible_edges.iter().enumerate() {
draw_edge(
f,
nodes[si].x + node_w,
src_ys[ei],
nodes[di].x,
dst_ys[ei],
&plan.routes[ri],
!resolved.route_enabled(ri),
mid_xs[ei],
);
}
for node in &nodes {
let dev = plan.device_by_name(&node.alias).unwrap();
draw_device_node(
f, node.x, node.y, node_w, node_h, node, dev, plan, resolved, meter_bank,
);
}
if show_disconnected && !disconnected.is_empty() {
draw_disconnected_devices(f, inner, graph_area, &disconnected, plan);
}
}
fn draw_disconnected_devices(
f: &mut ratatui::Frame<'_>,
inner: Rect,
graph_area: Rect,
disconnected: &[String],
plan: &ValidatedConfig,
) {
let label = " Disconnected (no routes) ";
let separator_y = graph_area.y + graph_area.height;
if separator_y >= inner.y + inner.height {
return;
}
f.buffer_mut().set_string(
inner.x,
separator_y,
label,
Style::default()
.fg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
);
let devices_per_line = (inner.width as usize / 24).max(1);
for (i, alias) in disconnected.iter().enumerate() {
let row = i / devices_per_line;
let col = i % devices_per_line;
let y = separator_y + 1 + row as u16;
if y >= inner.y + inner.height {
break;
}
let dev = plan.device_by_name(alias);
let detail = match dev {
Some(d) => format!("⊙ {} ({})", alias, d.device),
None => format!("⊙ {}", alias),
};
let x = inner.x + (col as u16) * 24;
f.buffer_mut()
.set_string(x, y, &detail, Style::default().fg(Color::DarkGray));
}
}
#[derive(Clone)]
struct NodeInfo {
alias: String,
x: u16,
y: u16,
}
fn assign_connection_rows(
visible_edges: &[(usize, usize, usize)],
nodes: &[NodeInfo],
node_h: u16,
) -> (Vec<u16>, Vec<u16>) {
let mut src_ys = vec![0u16; visible_edges.len()];
let mut dst_ys = vec![0u16; visible_edges.len()];
let mut outgoing: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (ei, &(_, si, _)) in visible_edges.iter().enumerate() {
outgoing.entry(si).or_default().push(ei);
}
for (si, mut indices) in outgoing {
indices.sort_by_key(|&ei| {
let (ri, _, di) = visible_edges[ei];
(nodes[di].y, nodes[di].x, ri, ei)
});
assign_rows_for_node(&mut src_ys, indices, nodes[si].y, node_h);
}
let mut incoming: std::collections::HashMap<usize, Vec<usize>> =
std::collections::HashMap::new();
for (ei, &(_, _, di)) in visible_edges.iter().enumerate() {
incoming.entry(di).or_default().push(ei);
}
for (di, mut indices) in incoming {
indices.sort_by_key(|&ei| {
let (ri, si, _) = visible_edges[ei];
(nodes[si].y, nodes[si].x, ri, ei)
});
assign_rows_for_node(&mut dst_ys, indices, nodes[di].y, node_h);
}
(src_ys, dst_ys)
}
fn assign_rows_for_node(rows: &mut [u16], indices: Vec<usize>, node_y: u16, node_h: u16) {
let n = indices.len();
let inner_h = node_h.saturating_sub(2) as usize; for (i, ei) in indices.into_iter().enumerate() {
rows[ei] = if n == 1 {
node_y + node_h / 2
} else {
let offset = i * inner_h.saturating_sub(1) / (n - 1);
node_y + 1 + offset as u16
};
}
}
#[allow(clippy::too_many_arguments)]
fn draw_edge(
f: &mut ratatui::Frame<'_>,
x1: u16,
y1: u16,
x2: u16,
y2: u16,
route: &crate::validate::ValidatedRoute,
disabled: bool,
mid_x: u16,
) {
let dim_route = disabled || route.mute;
let route_style = if dim_route {
Style::default().fg(COLOR_ROUTE).add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_ROUTE)
};
let line_h = if disabled { "┄" } else { "─" };
let line_v = if disabled { "┆" } else { "│" };
let gain_label = if disabled {
"OFF".to_string()
} else if route.mute {
"X".to_string()
} else if route.gain_db == 0.0 {
"──────".to_string()
} else {
format!("{:+.1}dB", route.gain_db)
};
if y1 == y2 {
for x in x1..x2 {
f.buffer_mut().set_string(x, y1, line_h, route_style);
}
} else {
let half1 = mid_x;
let half2 = mid_x + 1;
for x in x1..half1 {
f.buffer_mut().set_string(x, y1, line_h, route_style);
}
let corner1 = if y2 > y1 { "┐" } else { "┘" };
f.buffer_mut().set_string(half1, y1, corner1, route_style);
let (vy_start, vy_end) = if y2 > y1 { (y1 + 1, y2) } else { (y2 + 1, y1) };
for y in vy_start..vy_end {
f.buffer_mut().set_string(half1, y, line_v, route_style);
}
let corner2 = if y2 > y1 { "└" } else { "┌" };
f.buffer_mut().set_string(half1, y2, corner2, route_style);
for x in half2..x2 {
f.buffer_mut().set_string(x, y2, line_h, route_style);
}
}
if x2 > x1 {
let arrow = if disabled { "▷" } else { "▶" };
f.buffer_mut()
.set_string(x2.saturating_sub(1), y2, arrow, route_style);
}
let src_channels = channel_label(&route.from_channels);
let dst_channels = channel_label(&route.to_channels);
let muted = route.mute || disabled;
let src_ch_style = if muted {
Style::default().fg(COLOR_IN).add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_IN).add_modifier(Modifier::BOLD)
};
let dst_ch_style = if muted {
Style::default().fg(COLOR_OUT).add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_OUT).add_modifier(Modifier::BOLD)
};
let gain_style = if dim_route || route.gain_db == 0.0 {
route_style } else {
Style::default().fg(COLOR_GAIN)
};
let gain_text = if disabled || route.mute || route.gain_db == 0.0 {
gain_label.clone()
} else {
format!(" {}", gain_label)
};
let gain_w = gain_text.width() as u16;
let src_w = src_channels.width() as u16;
let dst_w = dst_channels.width() as u16;
if y1 == y2 {
let row = y1;
let src_x = x1.saturating_add(1);
let gain_x = src_x.saturating_add(src_w + 1);
let dst_x = gain_x.saturating_add(gain_w + 1);
let arrow_x = x2.saturating_sub(1);
f.buffer_mut()
.set_string(src_x, row, &src_channels, src_ch_style);
if dst_x.saturating_add(dst_w) < arrow_x {
f.buffer_mut()
.set_string(gain_x, row, &gain_text, gain_style);
f.buffer_mut()
.set_string(dst_x, row, &dst_channels, dst_ch_style);
} else if gain_x.saturating_add(gain_w) < arrow_x {
f.buffer_mut()
.set_string(gain_x, row, &gain_text, gain_style);
}
} else {
let src_x = x1.saturating_add(1);
f.buffer_mut()
.set_string(src_x, y1, &src_channels, src_ch_style);
let dst_x = x2.saturating_sub(dst_w + 2);
f.buffer_mut()
.set_string(dst_x, y2, &dst_channels, dst_ch_style);
let gain_x_src = src_x.saturating_add(src_w + 1);
let gain_fits_src = gain_x_src.saturating_add(gain_w) <= mid_x;
let gain_x_dst = mid_x.saturating_add(2); let gain_fits_dst = gain_x_dst.saturating_add(gain_w) <= dst_x;
if gain_fits_src {
f.buffer_mut()
.set_string(gain_x_src, y1, &gain_text, gain_style);
} else if gain_fits_dst {
f.buffer_mut()
.set_string(gain_x_dst, y2, &gain_text, gain_style);
}
}
}
fn channel_label(channels: &[usize]) -> String {
channels
.iter()
.map(|ch| ch.to_string())
.collect::<Vec<_>>()
.join(",")
}
fn active_channel_counts(
plan: &ValidatedConfig,
resolved: &crate::devices::ResolvedAudioDevices,
alias: &str,
) -> (usize, usize) {
let active_input_channels: std::collections::HashSet<usize> = plan
.routes
.iter()
.enumerate()
.filter(|(i, r)| resolved.route_enabled(*i) && r.from == alias)
.flat_map(|(_, r)| r.from_channels.iter().copied())
.collect();
let active_output_channels: std::collections::HashSet<usize> = plan
.routes
.iter()
.enumerate()
.filter(|(i, r)| resolved.route_enabled(*i) && r.to == alias)
.flat_map(|(_, r)| r.to_channels.iter().copied())
.collect();
(active_input_channels.len(), active_output_channels.len())
}
fn configured_channel_counts(plan: &ValidatedConfig, alias: &str) -> (usize, usize) {
let input_channels: std::collections::HashSet<usize> = plan
.routes
.iter()
.filter(|r| r.from == alias)
.flat_map(|r| r.from_channels.iter().copied())
.collect();
let output_channels: std::collections::HashSet<usize> = plan
.routes
.iter()
.filter(|r| r.to == alias)
.flat_map(|r| r.to_channels.iter().copied())
.collect();
(input_channels.len(), output_channels.len())
}
fn channel_badge_labels(
unavailable: bool,
ch_in: usize,
ch_out: usize,
total_in: usize,
total_out: usize,
) -> (String, String) {
if unavailable {
return (format!("▲{ch_in}"), format!("▼{ch_out}"));
}
let up_str = if total_in > 0 {
format!("▲{ch_in}/{total_in}")
} else if ch_in > 0 {
format!("▲{ch_in}")
} else {
String::new()
};
let down_str = if total_out > 0 {
format!("▼{ch_out}/{total_out}")
} else if ch_out > 0 {
format!("▼{ch_out}")
} else {
String::new()
};
(up_str, down_str)
}
#[allow(clippy::too_many_arguments)]
fn draw_device_node(
f: &mut ratatui::Frame<'_>,
x: u16,
y: u16,
w: u16,
h: u16,
node: &NodeInfo,
dev: &crate::validate::ResolvedDeviceRole,
plan: &ValidatedConfig,
resolved: &crate::devices::ResolvedAudioDevices,
meter_bank: &crate::meter::MeterBank,
) {
let w = w.max(12);
let missing_input = resolved.unavailable_inputs.contains(&node.alias);
let missing_output = resolved.unavailable_outputs.contains(&node.alias);
let unavailable = missing_input || missing_output;
let title_style = if unavailable {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM)
} else {
Style::default()
.fg(Color::White)
.add_modifier(Modifier::BOLD)
};
let h = h.max(5);
let inner_x = x + 1;
let inner_w = w.saturating_sub(2);
let max_name = (inner_w as usize).saturating_sub(2);
let name_display = truncate_display(&node.alias, max_name);
let title = truncate_display(&format!("⊙ {}", name_display), inner_w as usize);
f.buffer_mut()
.set_string(inner_x, y + 1, title, title_style);
if dev.limiter {
let style = if unavailable {
Style::default().fg(COLOR_GAIN).add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_GAIN)
};
f.buffer_mut()
.set_string(x + w.saturating_sub(5), y + 1, "🧱", style);
}
if let (false, Some(meter)) = (unavailable, meter_bank.get(&node.alias, 0)) {
let snap = meter.snapshot();
if snap.clipped {
f.buffer_mut().set_string(
x + w.saturating_sub(3),
y + 1,
"⚡",
Style::default().fg(COLOR_CLIP),
);
}
}
let spectrum_rows = h.saturating_sub(3).max(1);
if unavailable {
let missing_label = "(device missing)";
let label_w = missing_label.len() as u16;
let label_x = inner_x + (inner_w.saturating_sub(label_w)) / 2;
let label_y = y + 2 + spectrum_rows / 2;
f.buffer_mut().set_string(
label_x,
label_y,
truncate_display(missing_label, inner_w as usize),
Style::default()
.fg(Color::White)
.add_modifier(Modifier::DIM | Modifier::ITALIC),
);
} else if let Some(meter) = meter_bank.get(&node.alias, 0) {
let snap = meter.snapshot();
draw_spectrum(f, inner_x, y + 2, inner_w, spectrum_rows, &snap.bands);
}
let border_style = if unavailable {
Style::default()
.fg(COLOR_BORDER)
.add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_BORDER)
};
let top_bottom = "─".repeat(inner_w as usize);
f.buffer_mut()
.set_string(x, y, format!("╭{}╮", top_bottom), border_style);
f.buffer_mut()
.set_string(x, y + h - 1, format!("╰{}╯", top_bottom), border_style);
for ry in 1..h.saturating_sub(1) {
f.buffer_mut().set_string(x, y + ry, "│", border_style);
f.buffer_mut()
.set_string(x + w - 1, y + ry, "│", border_style);
}
{
let phys = resolved.devices.get(&node.alias);
let total_in = phys.map(|d| d.max_input_channels as usize).unwrap_or(0);
let total_out = phys.map(|d| d.max_output_channels as usize).unwrap_or(0);
let (ch_in, ch_out) = if unavailable {
configured_channel_counts(plan, &node.alias)
} else {
active_channel_counts(plan, resolved, &node.alias)
};
let (up_str, down_str) =
channel_badge_labels(unavailable, ch_in, ch_out, total_in, total_out);
let up_style = if unavailable || ch_in == 0 {
Style::default().fg(COLOR_IN).add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_IN).add_modifier(Modifier::BOLD)
};
let down_style = if unavailable || ch_out == 0 {
Style::default().fg(COLOR_OUT).add_modifier(Modifier::DIM)
} else {
Style::default().fg(COLOR_OUT).add_modifier(Modifier::BOLD)
};
let mut pos = x + 2;
if !up_str.is_empty() {
f.buffer_mut().set_string(pos, y, &up_str, up_style);
pos += up_str.width() as u16;
}
if !up_str.is_empty() && !down_str.is_empty() {
f.buffer_mut().set_string(pos, y, " ", border_style);
pos += 1;
}
if !down_str.is_empty() {
f.buffer_mut().set_string(pos, y, &down_str, down_style);
}
}
}
fn draw_spectrum(f: &mut ratatui::Frame<'_>, x: u16, y: u16, w: u16, rows: u16, bands: &[f32]) {
if bands.is_empty() || w == 0 || rows == 0 {
return;
}
let cols = w as usize;
let band_count = bands.len();
let total_dot_rows = rows as usize * 4;
for col in 0..cols {
let left_idx = ((col * 2) as f32 / (cols * 2) as f32 * band_count as f32) as usize;
let right_idx = (((col * 2 + 1) as f32 / (cols * 2) as f32) * band_count as f32) as usize;
let left = bands[left_idx.min(band_count - 1)].clamp(0.0, 1.0);
let right = bands[right_idx.min(band_count - 1)].clamp(0.0, 1.0);
let color = Style::default().fg(spectrum_color(left.max(right)));
let left_level = (left * total_dot_rows as f32).round() as usize;
let right_level = (right * total_dot_rows as f32).round() as usize;
for row in 0..rows as usize {
let mut mask = 0u8;
for dot_row in 0..4usize {
let global_row = row * 4 + dot_row;
let filled_from_bottom = total_dot_rows.saturating_sub(global_row);
if left_level >= filled_from_bottom {
mask |= braille_dot_mask(false, dot_row);
}
if right_level >= filled_from_bottom {
mask |= braille_dot_mask(true, dot_row);
}
}
let ch = char::from_u32(0x2800 + mask as u32).unwrap_or(' ');
f.buffer_mut()
.set_string(x + col as u16, y + row as u16, ch.to_string(), color);
}
}
}
fn braille_dot_mask(right_column: bool, dot_row: usize) -> u8 {
match (right_column, dot_row) {
(false, 0) => 0x01,
(false, 1) => 0x02,
(false, 2) => 0x04,
(false, _) => 0x40,
(true, 0) => 0x08,
(true, 1) => 0x10,
(true, 2) => 0x20,
(true, _) => 0x80,
}
}
fn spectrum_color(val: f32) -> Color {
if val > 0.85 {
Color::Red
} else if val > 0.65 {
Color::LightRed
} else if val > 0.45 {
Color::Yellow
} else if val > 0.25 {
Color::LightGreen
} else {
Color::Green
}
}
fn draw_log(f: &mut ratatui::Frame<'_>, area: Rect, log_lines: &[String], scroll: u16) {
let block = Block::default().borders(Borders::ALL).title(" Log ");
f.render_widget(&block, area);
let inner = block.inner(area);
let visible = inner.height as usize;
let total = log_lines.len();
let scroll = clamp_log_scroll(scroll, total) as usize;
let start = if total > visible {
total.saturating_sub(visible)
} else {
0
};
let start = start.saturating_sub(scroll);
let end = (start + visible).min(total);
let lines: Vec<Line<'_>> = log_lines[start..end]
.iter()
.map(|s| log_line_with_icon(s))
.collect();
let para = Paragraph::new(lines);
f.render_widget(para, inner);
}
fn log_line_with_icon(s: &str) -> Line<'_> {
let (icon, style) = log_icon_style(s);
Line::from(vec![
Span::styled(format!("{icon} "), style.add_modifier(Modifier::BOLD)),
Span::styled(s.to_string(), style),
])
}
fn log_icon_style(s: &str) -> (&'static str, Style) {
let lower = s.to_ascii_lowercase();
if contains_log_level(s, "ERROR")
|| lower.contains("failed")
|| lower.contains("error")
|| lower.contains("fatal")
{
("✖", Style::default().fg(Color::Red))
} else if contains_log_level(s, "WARN") || lower.contains("warning") {
("⚠", Style::default().fg(Color::Yellow))
} else if contains_log_level(s, "INFO")
|| lower.contains("connected")
|| lower.contains("succeeded")
{
("●", Style::default().fg(Color::Cyan))
} else if contains_log_level(s, "DEBUG") {
("◆", Style::default().fg(Color::Magenta))
} else if contains_log_level(s, "TRACE") {
("◇", Style::default().fg(Color::DarkGray))
} else if lower.contains("reload") || lower.contains("changed") {
("↻", Style::default().fg(Color::Yellow))
} else {
("·", Style::default().fg(Color::DarkGray))
}
}
fn contains_log_level(s: &str, level: &str) -> bool {
s.split(|c: char| !c.is_ascii_alphabetic())
.any(|word| word == level)
}
fn draw_help(f: &mut ratatui::Frame<'_>, area: Rect) {
let key_style = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let items: &[(&str, &str)] = &[
("[q/Esc]", "quit"),
("[r]", "reload"),
("[↑↓]", "scroll log"),
("[?]", "help"),
];
let total_content: u16 = items
.iter()
.map(|(k, d)| (k.width() + 1 + d.width()) as u16)
.sum();
let n = items.len() as u16;
let remaining = area.width.saturating_sub(total_content);
let gap = remaining / (n + 1);
let mut extra = remaining % (n + 1);
let mut spans: Vec<Span<'_>> = Vec::new();
for (k, d) in items {
let g = if extra > 0 {
extra -= 1;
gap + 1
} else {
gap
};
spans.push(Span::raw(" ".repeat(g as usize)));
spans.push(Span::styled(k.to_string(), key_style));
spans.push(Span::raw(format!(" {d}")));
}
f.render_widget(Paragraph::new(Line::from(spans)), area);
}
fn centered_rect(width: u16, height: u16, area: Rect) -> Rect {
Rect {
x: area.x + area.width.saturating_sub(width) / 2,
y: area.y + area.height.saturating_sub(height) / 2,
width: width.min(area.width),
height: height.min(area.height),
}
}
fn draw_help_popup(f: &mut ratatui::Frame<'_>, area: Rect) {
const W: u16 = 52;
const H: u16 = 12;
let popup = centered_rect(W, H, area);
f.render_widget(Clear, popup);
let k = Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD);
let s = Style::default().fg(Color::DarkGray);
let lines: Vec<Line<'_>> = vec![
Line::from(vec![
Span::raw(" "),
Span::styled("[q]", k),
Span::styled(" / ", s),
Span::styled("[Esc]", k),
Span::raw(" "),
Span::raw("quit"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[r]", k),
Span::raw(" "),
Span::raw("reload config"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[^L]", k),
Span::raw(" "),
Span::raw("reset peak-hold / clip"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[h]", k),
Span::raw(" "),
Span::raw("toggle disconnected devices"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[H]", k),
Span::raw(" "),
Span::raw("toggle missing devices"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[↑]", k),
Span::styled(" / ", s),
Span::styled("[↓]", k),
Span::raw(" "),
Span::raw("scroll log"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[PgUp]", k),
Span::styled(" / ", s),
Span::styled("[PgDn]", k),
Span::raw(" "),
Span::raw("scroll log by page"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[g]", k),
Span::raw(" "),
Span::raw("scroll to top (oldest)"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[G]", k),
Span::raw(" "),
Span::raw("scroll to bottom (newest)"),
]),
Line::from(vec![
Span::raw(" "),
Span::styled("[?]", k),
Span::raw(" "),
Span::raw("toggle this help"),
]),
];
let block = Block::default()
.borders(Borders::ALL)
.title(" Keybindings ")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
);
f.render_widget(Paragraph::new(lines).block(block), popup);
}
fn truncate_display(s: &str, max_width: usize) -> String {
use unicode_width::UnicodeWidthChar;
const ELLIPSIS: &str = "…"; let full_width: usize = s
.chars()
.map(|c| UnicodeWidthChar::width(c).unwrap_or(0))
.sum();
if full_width <= max_width {
return s.to_string();
}
let mut col = 0usize;
let mut out = String::new();
for ch in s.chars() {
let w = UnicodeWidthChar::width(ch).unwrap_or(0);
if col + w + 1 > max_width {
break;
}
out.push(ch);
col += w;
}
out.push_str(ELLIPSIS);
out
}
fn timestamp(start: Instant) -> String {
let s = start.elapsed().as_secs();
format!("{}:{:02}", s / 60, s % 60)
}
fn term_err(e: std::io::Error) -> crate::error::AppError {
crate::error::AppError::runtime(format!("terminal error: {e}"))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::Config;
use crate::validate::validate_config;
#[test]
fn missing_channel_badges_show_configured_counts_without_totals() {
let (up, down) = channel_badge_labels(true, 2, 0, 0, 0);
assert_eq!(up, "▲2");
assert_eq!(down, "▼0");
}
#[test]
fn available_channel_badges_keep_existing_total_behavior() {
let (up, down) = channel_badge_labels(false, 2, 0, 4, 2);
assert_eq!(up, "▲2/4");
assert_eq!(down, "▼0/2");
}
#[test]
fn configured_channel_counts_include_routes_disabled_by_missing_device() {
let config: Config = toml::from_str(
r#"
[engine]
sample_rate = 48000
buffer_size = 256
[[devices]]
name = "missing"
device = "Missing Device"
[[devices]]
name = "out"
device = "BlackHole 2ch"
[[routes]]
from = "missing"
to = "out"
from_channels = [3, 4]
to_channels = [1, 2]
"#,
)
.unwrap();
let plan = validate_config(config).unwrap();
assert_eq!(configured_channel_counts(&plan, "missing"), (2, 0));
}
#[test]
fn connection_rows_follow_peer_vertical_order_not_route_order() {
let nodes = vec![
NodeInfo {
alias: "src".to_string(),
x: 0,
y: 10,
},
NodeInfo {
alias: "lower".to_string(),
x: 40,
y: 40,
},
NodeInfo {
alias: "upper".to_string(),
x: 40,
y: 0,
},
];
let visible_edges = vec![(0, 0, 1), (1, 0, 2)];
let (src_ys, dst_ys) = assign_connection_rows(&visible_edges, &nodes, 6);
assert!(
src_ys[1] < src_ys[0],
"upper destination must use upper source row"
);
assert_eq!(dst_ys, vec![43, 3]);
}
}