508 lines
16 KiB
Rust
508 lines
16 KiB
Rust
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::{Orientation, Scope, Spectrum};
|
|
|
|
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
|
let [left_col, _, right_col] = Layout::horizontal([
|
|
Constraint::Percentage(55),
|
|
Constraint::Length(2),
|
|
Constraint::Percentage(45),
|
|
])
|
|
.areas(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);
|
|
}
|
|
|
|
// Scroll indicators
|
|
let indicator_style = Style::new().fg(theme.engine.scroll_indicator);
|
|
let indicator_x = padded.x + padded.width.saturating_sub(1);
|
|
|
|
if scroll_offset > 0 {
|
|
let up_indicator = Paragraph::new("▲").style(indicator_style);
|
|
frame.render_widget(up_indicator, Rect::new(indicator_x, padded.y, 1, 1));
|
|
}
|
|
|
|
if scroll_offset + max_visible < total_lines {
|
|
let down_indicator = Paragraph::new("▼").style(indicator_style);
|
|
frame.render_widget(
|
|
down_indicator,
|
|
Rect::new(
|
|
indicator_x,
|
|
padded.y + padded.height.saturating_sub(1),
|
|
1,
|
|
1,
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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()
|
|
}
|
|
}
|
|
|
|
fn list_height(item_count: usize) -> u16 {
|
|
let visible = item_count.min(5) as u16;
|
|
if item_count > 5 {
|
|
visible + 1
|
|
} else {
|
|
visible
|
|
}
|
|
}
|
|
|
|
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_section_header(frame: &mut Frame, title: &str, focused: bool, area: Rect) {
|
|
let theme = theme::get();
|
|
let [header_area, divider_area] =
|
|
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(area);
|
|
|
|
let header_style = if focused {
|
|
Style::new()
|
|
.fg(theme.engine.header_focused)
|
|
.add_modifier(Modifier::BOLD)
|
|
} else {
|
|
Style::new()
|
|
.fg(theme.engine.header)
|
|
.add_modifier(Modifier::BOLD)
|
|
};
|
|
|
|
frame.render_widget(Paragraph::new(title).style(header_style), header_area);
|
|
|
|
let divider = "─".repeat(area.width as usize);
|
|
frame.render_widget(
|
|
Paragraph::new(divider).style(Style::new().fg(theme.engine.divider)),
|
|
divider_area,
|
|
);
|
|
}
|
|
|
|
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<Line> = (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<String> = 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<Line> = 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 .wav 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)
|
|
}
|
|
}
|