use cagire_ratatui::ListSelect; use ratatui::layout::{Constraint, Layout, Rect}; use ratatui::style::{Modifier, Style}; use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; use ratatui::Frame; use crate::app::App; use crate::state::{DeviceKind, EngineSection, SettingKind}; use crate::theme; use crate::widgets::{ render_scroll_indicators, render_section_header, IndicatorAlign, Orientation, Scope, Spectrum, }; pub fn layout(area: Rect) -> [Rect; 3] { Layout::horizontal([ Constraint::Percentage(55), Constraint::Length(2), Constraint::Percentage(45), ]) .areas(area) } pub fn render(frame: &mut Frame, app: &App, area: Rect) { let [left_col, _, right_col] = layout(area); render_settings_section(frame, app, left_col); render_visualizers(frame, app, right_col); } fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let block = Block::default() .borders(Borders::ALL) .title(" Engine ") .border_style(Style::new().fg(theme.engine.border_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), }; // Calculate section heights let devices_lines = devices_section_height(app) as usize; let settings_lines: usize = 8; // header(1) + divider(1) + 6 rows let samples_lines: usize = 6; // header(1) + divider(1) + content(3) + hint(1) let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines; let max_visible = padded.height as usize; // Calculate scroll offset based on focused section let (focus_start, focus_height) = match app.audio.section { EngineSection::Devices => (0, devices_lines), EngineSection::Settings => (devices_lines + 1, settings_lines), EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines), }; let scroll_offset = if total_lines <= max_visible { 0 } else { // Keep focused section in view (top-aligned when possible) let focus_end = focus_start + focus_height; if focus_end <= max_visible { 0 } else { focus_start.min(total_lines.saturating_sub(max_visible)) } }; let viewport_top = padded.y as i32; let viewport_bottom = (padded.y + padded.height) as i32; // Render each section at adjusted position let mut y = viewport_top - scroll_offset as i32; // Devices section let devices_top = y; let devices_bottom = y + devices_lines as i32; if devices_bottom > viewport_top && devices_top < viewport_bottom { let clipped_y = devices_top.max(viewport_top) as u16; let clipped_height = (devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16; let devices_area = Rect { x: padded.x, y: clipped_y, width: padded.width, height: clipped_height, }; render_devices(frame, app, devices_area); } y += devices_lines as i32 + 1; // +1 for blank line // Settings section let settings_top = y; let settings_bottom = y + settings_lines as i32; if settings_bottom > viewport_top && settings_top < viewport_bottom { let clipped_y = settings_top.max(viewport_top) as u16; let clipped_height = (settings_bottom.min(viewport_bottom) - settings_top.max(viewport_top)) as u16; let settings_area = Rect { x: padded.x, y: clipped_y, width: padded.width, height: clipped_height, }; render_settings(frame, app, settings_area); } y += settings_lines as i32 + 1; // Samples section let samples_top = y; let samples_bottom = y + samples_lines as i32; if samples_bottom > viewport_top && samples_top < viewport_bottom { let clipped_y = samples_top.max(viewport_top) as u16; let clipped_height = (samples_bottom.min(viewport_bottom) - samples_top.max(viewport_top)) as u16; let samples_area = Rect { x: padded.x, y: clipped_y, width: padded.width, height: clipped_height, }; render_samples(frame, app, samples_area); } render_scroll_indicators( frame, padded, scroll_offset, max_visible, total_lines, theme.engine.scroll_indicator, IndicatorAlign::Right, ); } fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { let [scope_area, _, spectrum_area] = Layout::vertical([ Constraint::Percentage(50), Constraint::Length(1), Constraint::Percentage(50), ]) .areas(area); render_scope(frame, app, scope_area); render_spectrum(frame, app, spectrum_area); } fn render_scope(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let block = Block::default() .borders(Borders::ALL) .title(" Scope ") .border_style(Style::new().fg(theme.engine.border_green)); let inner = block.inner(area); frame.render_widget(block, area); let scope = Scope::new(&app.metrics.scope) .orientation(Orientation::Horizontal) .color(theme.meter.low); frame.render_widget(scope, inner); } fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let block = Block::default() .borders(Borders::ALL) .title(" Spectrum ") .border_style(Style::new().fg(theme.engine.border_cyan)); let inner = block.inner(area); frame.render_widget(block, area); let spectrum = Spectrum::new(&app.metrics.spectrum); frame.render_widget(spectrum, inner); } 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() } } pub fn list_height(item_count: usize) -> u16 { let visible = item_count.min(5) as u16; if item_count > 5 { visible + 1 } else { visible } } pub fn devices_section_height(app: &App) -> u16 { let output_h = list_height(app.audio.output_devices.len()); let input_h = list_height(app.audio.input_devices.len()); 3 + output_h.max(input_h) } fn render_devices(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let section_focused = app.audio.section == EngineSection::Devices; let [header_area, content_area] = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area); render_section_header(frame, "DEVICES", section_focused, header_area); let [output_col, separator, input_col] = Layout::horizontal([ Constraint::Percentage(48), Constraint::Length(3), Constraint::Percentage(48), ]) .areas(content_area); let output_focused = section_focused && app.audio.device_kind == DeviceKind::Output; let input_focused = section_focused && app.audio.device_kind == DeviceKind::Input; render_device_column( frame, output_col, "Output", &app.audio.output_devices, app.audio.current_output_device_index(), app.audio.output_list.cursor, app.audio.output_list.scroll_offset, output_focused, section_focused, ); let sep_style = Style::new().fg(theme.engine.separator); let sep_lines: Vec = (0..separator.height) .map(|_| Line::from(Span::styled("│", sep_style))) .collect(); frame.render_widget(Paragraph::new(sep_lines), separator); render_device_column( frame, input_col, "Input", &app.audio.input_devices, app.audio.current_input_device_index(), app.audio.input_list.cursor, app.audio.input_list.scroll_offset, input_focused, section_focused, ); } #[allow(clippy::too_many_arguments)] fn render_device_column( frame: &mut Frame, area: Rect, label: &str, devices: &[doux::audio::AudioDeviceInfo], selected_idx: usize, cursor: usize, scroll_offset: usize, focused: bool, section_focused: bool, ) { let theme = theme::get(); let [label_area, list_area] = Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); let label_style = if focused { Style::new() .fg(theme.engine.focused) .add_modifier(Modifier::BOLD) } else if section_focused { Style::new().fg(theme.engine.label_focused) } else { Style::new().fg(theme.engine.label_dim) }; let arrow = if focused { "> " } else { " " }; frame.render_widget( Paragraph::new(format!("{arrow}{label}")).style(label_style), label_area, ); let items: Vec = devices.iter().map(|d| truncate_name(&d.name, 25)).collect(); ListSelect::new(&items, selected_idx, cursor) .focused(focused) .scroll_offset(scroll_offset) .render(frame, list_area); } fn render_settings(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let section_focused = app.audio.section == EngineSection::Settings; let [header_area, content_area] = Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area); render_section_header(frame, "SETTINGS", section_focused, header_area); let highlight = Style::new() .fg(theme.engine.focused) .add_modifier(Modifier::BOLD); let normal = Style::new().fg(theme.engine.normal); let label_style = Style::new().fg(theme.engine.label); let value_style = Style::new().fg(theme.engine.value); let channels_focused = section_focused && app.audio.setting_kind == SettingKind::Channels; let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize; let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony; let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge; let nudge_ms = app.metrics.nudge_ms; let nudge_label = if nudge_ms == 0.0 { "0 ms".to_string() } else { format!("{nudge_ms:+.1} ms") }; let rows = vec![ Row::new(vec![ Span::styled( if channels_focused { "> Channels" } else { " Channels" }, label_style, ), render_selector( &format!("{}", app.audio.config.channels), channels_focused, highlight, normal, ), ]), Row::new(vec![ Span::styled( if buffer_focused { "> Buffer" } else { " Buffer" }, label_style, ), render_selector( &if app.audio.config.host_name.to_lowercase().contains("jack") { "JACK managed".to_string() } else { format!("{}", app.audio.config.buffer_size) }, buffer_focused, highlight, normal, ), ]), Row::new(vec![ Span::styled( if polyphony_focused { "> Voices" } else { " Voices" }, label_style, ), render_selector( &format!("{}", app.audio.config.max_voices), polyphony_focused, highlight, normal, ), ]), Row::new(vec![ Span::styled( if nudge_focused { "> Nudge" } else { " Nudge" }, label_style, ), render_selector(&nudge_label, nudge_focused, highlight, normal), ]), Row::new(vec![ Span::styled(" Sample rate", label_style), Span::styled( format!("{:.0} Hz", app.audio.config.sample_rate), value_style, ), ]), Row::new(vec![ Span::styled(" Audio host", label_style), Span::styled( if app.audio.config.host_name.is_empty() { "-".to_string() } else { app.audio.config.host_name.clone() }, value_style, ), ]), ]; let table = Table::new(rows, [Constraint::Length(14), Constraint::Fill(1)]); frame.render_widget(table, content_area); } fn render_samples(frame: &mut Frame, app: &App, area: Rect) { let theme = theme::get(); let section_focused = app.audio.section == EngineSection::Samples; let [header_area, content_area, _, hint_area] = Layout::vertical([ Constraint::Length(2), Constraint::Min(1), Constraint::Length(1), Constraint::Length(1), ]) .areas(area); let path_count = app.audio.config.sample_paths.len(); let sample_count = app.audio.config.sample_count; let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed"); render_section_header(frame, &header_text, section_focused, header_area); let dim = Style::new().fg(theme.engine.dim); let path_style = Style::new().fg(theme.engine.path); let mut lines: Vec = Vec::new(); if app.audio.config.sample_paths.is_empty() { lines.push(Line::from(Span::styled( " No sample paths configured", dim, ))); lines.push(Line::from(Span::styled( " Add folders containing audio files", dim, ))); } else { 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, 40); lines.push(Line::from(vec![ Span::styled(format!(" {} ", i + 1), dim), Span::styled(display, path_style), ])); } if path_count > 4 { lines.push(Line::from(Span::styled( format!(" ... and {} more", path_count - 4), dim, ))); } } frame.render_widget(Paragraph::new(lines), content_area); let hint_style = if section_focused { Style::new().fg(theme.engine.hint_active) } else { Style::new().fg(theme.engine.hint_inactive) }; let hint = Line::from(vec![ Span::styled("A", hint_style), Span::styled(":add ", Style::new().fg(theme.engine.dim)), Span::styled("D", hint_style), Span::styled(":remove", Style::new().fg(theme.engine.dim)), ]); frame.render_widget(Paragraph::new(hint), hint_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) } }