431 lines
15 KiB
Rust
431 lines
15 KiB
Rust
use cagire_ratatui::ListSelect;
|
|
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_height = devices_section_height(app);
|
|
|
|
let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([
|
|
Constraint::Length(devices_height),
|
|
Constraint::Length(1),
|
|
Constraint::Length(10),
|
|
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 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 {
|
|
// header(1) + "Output" label(1) + output list + "Input" label(1) + input list
|
|
1 + 1 + list_height(app.audio.output_devices.len())
|
|
+ 1 + list_height(app.audio.input_devices.len())
|
|
}
|
|
|
|
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 label_style = Style::new().fg(Color::Rgb(120, 125, 135));
|
|
|
|
let output_h = list_height(app.audio.output_devices.len());
|
|
let input_h = list_height(app.audio.input_devices.len());
|
|
|
|
let [header_area, output_label_area, output_list_area, input_label_area, input_list_area] =
|
|
Layout::vertical([
|
|
Constraint::Length(1),
|
|
Constraint::Length(1),
|
|
Constraint::Length(output_h),
|
|
Constraint::Length(1),
|
|
Constraint::Length(input_h),
|
|
])
|
|
.areas(area);
|
|
|
|
frame.render_widget(Paragraph::new("Devices").style(header_style), header_area);
|
|
frame.render_widget(Paragraph::new(Span::styled("Output", label_style)), output_label_area);
|
|
frame.render_widget(Paragraph::new(Span::styled("Input", label_style)), input_label_area);
|
|
|
|
let output_items: Vec<String> = app.audio.output_devices.iter()
|
|
.map(|d| truncate_name(&d.name, 35))
|
|
.collect();
|
|
let output_selected = app.audio.current_output_device_index();
|
|
ListSelect::new(&output_items, output_selected, app.audio.output_list.cursor)
|
|
.focused(app.audio.focus == AudioFocus::OutputDevice)
|
|
.scroll_offset(app.audio.output_list.scroll_offset)
|
|
.render(frame, output_list_area);
|
|
|
|
let input_items: Vec<String> = app.audio.input_devices.iter()
|
|
.map(|d| truncate_name(&d.name, 35))
|
|
.collect();
|
|
let input_selected = app.audio.current_input_device_index();
|
|
ListSelect::new(&input_items, input_selected, app.audio.input_list.cursor)
|
|
.focused(app.audio.focus == AudioFocus::InputDevice)
|
|
.scroll_offset(app.audio.input_list.scroll_offset)
|
|
.render(frame, input_list_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 polyphony_focused = app.audio.focus == AudioFocus::Polyphony;
|
|
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 completion_focused = app.audio.focus == AudioFocus::ShowCompletion;
|
|
|
|
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 completion_text = if app.ui.show_completion { "On" } else { "Off" };
|
|
|
|
let rows = vec![
|
|
Row::new(vec![
|
|
Span::styled("Output channels", label_style),
|
|
render_selector(
|
|
&format!("{}", app.audio.config.channels),
|
|
channels_focused,
|
|
highlight,
|
|
normal,
|
|
),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Buffer size", label_style),
|
|
render_selector(
|
|
&format!("{}", app.audio.config.buffer_size),
|
|
buffer_focused,
|
|
highlight,
|
|
normal,
|
|
),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Max voices", label_style),
|
|
render_selector(
|
|
&format!("{}", app.audio.config.max_voices),
|
|
polyphony_focused,
|
|
highlight,
|
|
normal,
|
|
),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Refresh rate", label_style),
|
|
render_selector(
|
|
app.audio.config.refresh_rate.label(),
|
|
fps_focused,
|
|
highlight,
|
|
normal,
|
|
),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Show highlight", label_style),
|
|
render_selector(highlight_text, highlight_focused, highlight, normal),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Show scope", label_style),
|
|
render_selector(scope_text, scope_focused, highlight, normal),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Show spectrum", label_style),
|
|
render_selector(spectrum_text, spectrum_focused, highlight, normal),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Completion", label_style),
|
|
render_selector(completion_text, completion_focused, highlight, normal),
|
|
]),
|
|
Row::new(vec![
|
|
Span::styled("Sample rate", label_style),
|
|
Span::styled(
|
|
format!("{:.0} Hz", app.audio.config.sample_rate),
|
|
value_style,
|
|
),
|
|
]),
|
|
];
|
|
|
|
let table = Table::new(rows, [Constraint::Length(16), 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<Line> = 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)
|
|
}
|
|
}
|