use ratatui::layout::{Alignment, Constraint, Layout, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; use ratatui::Frame; use crate::app::App; use crate::engine::LinkState; use crate::state::AudioFocus; pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let [left_col, _, right_col] = Layout::horizontal([ Constraint::Percentage(52), Constraint::Length(2), Constraint::Percentage(48), ]) .areas(area); render_audio_section(frame, app, left_col); render_link_section(frame, app, link, right_col); } fn truncate_name(name: &str, max_len: usize) -> String { if name.len() > max_len { format!("{}...", &name[..max_len.saturating_sub(3)]) } else { name.to_string() } } fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Audio ") .border_style(Style::new().fg(Color::Magenta)); let inner = block.inner(area); frame.render_widget(block, area); let padded = Rect { x: inner.x + 1, y: inner.y + 1, width: inner.width.saturating_sub(2), height: inner.height.saturating_sub(1), }; let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ Constraint::Length(4), Constraint::Length(1), Constraint::Length(8), Constraint::Length(1), Constraint::Min(3), ]) .areas(padded); render_devices(frame, app, devices_area); render_settings(frame, app, settings_area); render_samples(frame, app, samples_area); } fn render_devices(frame: &mut Frame, app: &App, area: Rect) { let header_style = Style::new() .fg(Color::Rgb(100, 160, 180)) .add_modifier(Modifier::BOLD); let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); let normal = Style::new().fg(Color::White); let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); let output_name = truncate_name(app.audio.current_output_device_name(), 35); let input_name = truncate_name(app.audio.current_input_device_name(), 35); let output_focused = app.audio.focus == AudioFocus::OutputDevice; let input_focused = app.audio.focus == AudioFocus::InputDevice; let rows = vec![ Row::new(vec![ Span::styled("Output", label_style), render_selector(&output_name, output_focused, highlight, normal), ]), Row::new(vec![ Span::styled("Input", label_style), render_selector(&input_name, input_focused, highlight, normal), ]), ]; let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); frame.render_widget(table, content_area); } fn render_settings(frame: &mut Frame, app: &App, area: Rect) { let header_style = Style::new() .fg(Color::Rgb(100, 160, 180)) .add_modifier(Modifier::BOLD); let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); frame.render_widget(Paragraph::new("Settings").style(header_style), header_area); let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); let normal = Style::new().fg(Color::White); let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); let channels_focused = app.audio.focus == AudioFocus::Channels; let buffer_focused = app.audio.focus == AudioFocus::BufferSize; let fps_focused = app.audio.focus == AudioFocus::RefreshRate; let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight; let scope_focused = app.audio.focus == AudioFocus::ShowScope; let spectrum_focused = app.audio.focus == AudioFocus::ShowSpectrum; let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" }; let scope_text = if app.audio.config.show_scope { "On" } else { "Off" }; let spectrum_text = if app.audio.config.show_spectrum { "On" } else { "Off" }; let rows = vec![ Row::new(vec![ Span::styled("Channels", label_style), render_selector( &format!("{}", app.audio.config.channels), channels_focused, highlight, normal, ), ]), Row::new(vec![ Span::styled("Buffer", label_style), render_selector( &format!("{}", app.audio.config.buffer_size), buffer_focused, highlight, normal, ), ]), Row::new(vec![ Span::styled("FPS", label_style), render_selector( app.audio.config.refresh_rate.label(), fps_focused, highlight, normal, ), ]), Row::new(vec![ Span::styled("Highlight", label_style), render_selector(highlight_text, highlight_focused, highlight, normal), ]), Row::new(vec![ Span::styled("Scope", label_style), render_selector(scope_text, scope_focused, highlight, normal), ]), Row::new(vec![ Span::styled("Spectrum", label_style), render_selector(spectrum_text, spectrum_focused, highlight, normal), ]), Row::new(vec![ Span::styled("Rate", label_style), Span::styled( format!("{:.0} Hz", app.audio.config.sample_rate), value_style, ), ]), ]; let table = Table::new(rows, [Constraint::Length(8), Constraint::Fill(1)]); frame.render_widget(table, content_area); } fn render_samples(frame: &mut Frame, app: &App, area: Rect) { let header_style = Style::new() .fg(Color::Rgb(100, 160, 180)) .add_modifier(Modifier::BOLD); let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); let samples_focused = app.audio.focus == AudioFocus::SamplePaths; let header_text = format!( "Samples {} paths · {} indexed", app.audio.config.sample_paths.len(), app.audio.config.sample_count ); let header_line = if samples_focused { Line::from(vec![ Span::styled("Samples ", header_style), Span::styled( format!( "{} paths · {} indexed", app.audio.config.sample_paths.len(), app.audio.config.sample_count ), highlight, ), ]) } else { Line::from(Span::styled(header_text, header_style)) }; frame.render_widget(Paragraph::new(header_line), header_area); let dim = Style::new().fg(Color::Rgb(80, 85, 95)); let path_style = Style::new().fg(Color::Rgb(120, 125, 135)); let mut lines: Vec = Vec::new(); for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { let path_str = path.to_string_lossy(); let display = truncate_name(&path_str, 45); lines.push(Line::from(vec![ Span::styled(format!(" {} ", i + 1), dim), Span::styled(display, path_style), ])); } if lines.is_empty() { lines.push(Line::from(Span::styled( " No sample paths configured", dim, ))); } frame.render_widget(Paragraph::new(lines), content_area); } fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let block = Block::default() .borders(Borders::ALL) .title(" Ableton Link ") .border_style(Style::new().fg(Color::Cyan)); let inner = block.inner(area); frame.render_widget(block, area); let padded = Rect { x: inner.x + 1, y: inner.y + 1, width: inner.width.saturating_sub(2), height: inner.height.saturating_sub(1), }; let [status_area, _, config_area, _, info_area] = Layout::vertical([ Constraint::Length(3), Constraint::Length(1), Constraint::Length(5), Constraint::Length(1), Constraint::Min(1), ]) .areas(padded); render_link_status(frame, link, status_area); render_link_config(frame, app, link, config_area); render_link_info(frame, link, info_area); } fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) { let enabled = link.is_enabled(); let peers = link.peers(); let (status_text, status_color) = if !enabled { ("DISABLED", Color::Rgb(120, 60, 60)) } else if peers > 0 { ("CONNECTED", Color::Rgb(60, 120, 60)) } else { ("LISTENING", Color::Rgb(120, 120, 60)) }; let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD); let peer_text = if enabled { if peers == 0 { "No peers".to_string() } else if peers == 1 { "1 peer".to_string() } else { format!("{peers} peers") } } else { String::new() }; let lines = vec![ Line::from(Span::styled(status_text, status_style)), Line::from(Span::styled( peer_text, Style::new().fg(Color::Rgb(120, 125, 135)), )), ]; frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); } fn render_link_config(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let header_style = Style::new() .fg(Color::Rgb(100, 160, 180)) .add_modifier(Modifier::BOLD); let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); frame.render_widget( Paragraph::new("Configuration").style(header_style), header_area, ); let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); let normal = Style::new().fg(Color::White); let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); let enabled_focused = app.audio.focus == AudioFocus::LinkEnabled; let startstop_focused = app.audio.focus == AudioFocus::StartStopSync; let quantum_focused = app.audio.focus == AudioFocus::Quantum; let enabled_text = if link.is_enabled() { "On" } else { "Off" }; let startstop_text = if link.is_start_stop_sync_enabled() { "On" } else { "Off" }; let quantum_text = format!("{:.0}", link.quantum()); let rows = vec![ Row::new(vec![ Span::styled("Enabled", label_style), render_selector(enabled_text, enabled_focused, highlight, normal), ]), Row::new(vec![ Span::styled("Start/Stop", label_style), render_selector(startstop_text, startstop_focused, highlight, normal), ]), Row::new(vec![ Span::styled("Quantum", label_style), render_selector(&quantum_text, quantum_focused, highlight, normal), ]), ]; let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); frame.render_widget(table, content_area); } fn render_link_info(frame: &mut Frame, link: &LinkState, area: Rect) { let header_style = Style::new() .fg(Color::Rgb(100, 160, 180)) .add_modifier(Modifier::BOLD); let [header_area, content_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); frame.render_widget(Paragraph::new("Session").style(header_style), header_area); let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); let tempo_style = Style::new() .fg(Color::Rgb(220, 180, 100)) .add_modifier(Modifier::BOLD); let tempo = link.tempo(); let beat = link.beat(); let phase = link.phase(); let rows = vec![ Row::new(vec![ Span::styled("Tempo", label_style), Span::styled(format!("{tempo:.1} BPM"), tempo_style), ]), Row::new(vec![ Span::styled("Beat", label_style), Span::styled(format!("{beat:.2}"), value_style), ]), Row::new(vec![ Span::styled("Phase", label_style), Span::styled(format!("{phase:.2}"), value_style), ]), ]; let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); frame.render_widget(table, content_area); } fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { let style = if focused { highlight } else { normal }; if focused { Span::styled(format!("< {value} >"), style) } else { Span::styled(format!(" {value} "), style) } }