This commit is contained in:
2026-01-26 00:24:17 +01:00
parent 016d050678
commit 87fd59549d
12 changed files with 862 additions and 614 deletions

View File

@@ -1,430 +0,0 @@
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)
}
}

343
src/views/engine_view.rs Normal file
View File

@@ -0,0 +1,343 @@
use cagire_ratatui::ListSelect;
use ratatui::layout::{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::state::{DeviceKind, EngineSection, SettingKind};
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 block = Block::default()
.borders(Borders::ALL)
.title(" Engine ")
.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(6),
Constraint::Length(1),
Constraint::Min(5),
])
.areas(padded);
render_devices(frame, app, devices_area);
render_settings(frame, app, settings_area);
render_samples(frame, app, samples_area);
}
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 block = Block::default()
.borders(Borders::ALL)
.title(" Scope ")
.border_style(Style::new().fg(Color::Green));
let inner = block.inner(area);
frame.render_widget(block, area);
let scope = Scope::new(&app.metrics.scope)
.orientation(Orientation::Horizontal)
.color(Color::Green);
frame.render_widget(scope, inner);
}
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Spectrum ")
.border_style(Style::new().fg(Color::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());
2 + output_h.max(input_h)
}
fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
let section_focused = app.audio.section == EngineSection::Devices;
let header_style = if section_focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
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 [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, app, 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(Color::Rgb(60, 65, 75));
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, app, 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,
);
}
fn render_device_column(
frame: &mut Frame,
_app: &App,
area: Rect,
label: &str,
devices: &[doux::audio::AudioDeviceInfo],
selected_idx: usize,
cursor: usize,
scroll_offset: usize,
focused: bool,
section_focused: bool,
) {
let [label_area, list_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
]).areas(area);
let label_style = if focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else if section_focused {
Style::new().fg(Color::Rgb(150, 155, 165))
} else {
Style::new().fg(Color::Rgb(100, 105, 115))
};
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 section_focused = app.audio.section == EngineSection::Settings;
let header_style = if section_focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
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 = 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 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(
&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(" Sample rate", label_style),
Span::styled(
format!("{:.0} Hz", app.audio.config.sample_rate),
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 section_focused = app.audio.section == EngineSection::Samples;
let header_style = if section_focused {
Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD)
} else {
Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD)
};
let [header_area, content_area, _, hint_area] = Layout::vertical([
Constraint::Length(1),
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");
frame.render_widget(Paragraph::new(header_text).style(header_style), 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();
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(Color::Rgb(180, 180, 100))
} else {
Style::new().fg(Color::Rgb(60, 60, 70))
};
let hint = Line::from(vec![
Span::styled("A", hint_style),
Span::styled(":add ", Style::new().fg(Color::Rgb(80, 85, 95))),
Span::styled("D", hint_style),
Span::styled(":remove", Style::new().fg(Color::Rgb(80, 85, 95))),
]);
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)
}
}

View File

@@ -1,8 +1,9 @@
pub mod audio_view;
pub mod dict_view;
pub mod engine_view;
pub mod help_view;
pub mod highlight;
pub mod main_view;
pub mod options_view;
pub mod patterns_view;
mod render;
pub mod title_view;

246
src/views/options_view.rs Normal file
View File

@@ -0,0 +1,246 @@
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use crate::app::App;
use crate::engine::LinkState;
use crate::state::OptionsFocus;
const LABEL_COLOR: Color = Color::Rgb(120, 125, 135);
const HEADER_COLOR: Color = Color::Rgb(100, 160, 180);
const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70);
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let block = Block::default()
.borders(Borders::ALL)
.title(" Options ")
.border_style(Style::new().fg(Color::Cyan));
let inner = block.inner(area);
frame.render_widget(block, area);
let padded = Rect {
x: inner.x + 2,
y: inner.y + 1,
width: inner.width.saturating_sub(4),
height: inner.height.saturating_sub(2),
};
let [display_area, _, link_area, _, session_area] = Layout::vertical([
Constraint::Length(7),
Constraint::Length(1),
Constraint::Length(5),
Constraint::Length(1),
Constraint::Min(5),
])
.areas(padded);
render_display_section(frame, app, display_area);
render_link_section(frame, app, link, link_area);
render_session_section(frame, link, session_area);
}
fn render_display_section(frame: &mut Frame, app: &App, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let header = Line::from(Span::styled(
"DISPLAY",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let focus = app.options.focus;
let lines = vec![
render_option_line(
"Refresh rate",
app.audio.config.refresh_rate.label(),
focus == OptionsFocus::RefreshRate,
),
render_option_line(
"Runtime highlight",
if app.ui.runtime_highlight { "On" } else { "Off" },
focus == OptionsFocus::RuntimeHighlight,
),
render_option_line(
"Show scope",
if app.audio.config.show_scope { "On" } else { "Off" },
focus == OptionsFocus::ShowScope,
),
render_option_line(
"Show spectrum",
if app.audio.config.show_spectrum {
"On"
} else {
"Off"
},
focus == OptionsFocus::ShowSpectrum,
),
render_option_line(
"Completion",
if app.ui.show_completion { "On" } else { "Off" },
focus == OptionsFocus::ShowCompletion,
),
];
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
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 peer_text = if enabled && peers > 0 {
if peers == 1 {
" · 1 peer".to_string()
} else {
format!(" · {peers} peers")
}
} else {
String::new()
};
let header = Line::from(vec![
Span::styled(
"ABLETON LINK",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::styled(
status_text,
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
),
Span::styled(peer_text, Style::new().fg(LABEL_COLOR)),
]);
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let focus = app.options.focus;
let quantum_str = format!("{:.0}", link.quantum());
let lines = vec![
render_option_line(
"Enabled",
if link.is_enabled() { "On" } else { "Off" },
focus == OptionsFocus::LinkEnabled,
),
render_option_line(
"Start/Stop sync",
if link.is_start_stop_sync_enabled() {
"On"
} else {
"Off"
},
focus == OptionsFocus::StartStopSync,
),
render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum),
];
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_session_section(frame: &mut Frame, link: &LinkState, area: Rect) {
let [header_area, divider_area, content_area] = Layout::vertical([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(1),
])
.areas(area);
let header = Line::from(Span::styled(
"SESSION",
Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD),
));
frame.render_widget(Paragraph::new(header), header_area);
let divider = "".repeat(area.width as usize);
frame.render_widget(
Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)),
divider_area,
);
let tempo_style = Style::new()
.fg(Color::Rgb(220, 180, 100))
.add_modifier(Modifier::BOLD);
let value_style = Style::new().fg(Color::Rgb(140, 145, 155));
let tempo_str = format!("{:.1} BPM", link.tempo());
let beat_str = format!("{:.2}", link.beat());
let phase_str = format!("{:.2}", link.phase());
let lines = vec![
render_readonly_line("Tempo", &tempo_str, tempo_style),
render_readonly_line("Beat", &beat_str, value_style),
render_readonly_line("Phase", &phase_str, value_style),
];
frame.render_widget(Paragraph::new(lines), content_area);
}
fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> {
let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD);
let normal = Style::new().fg(Color::White);
let label_style = Style::new().fg(LABEL_COLOR);
let prefix = if focused { "> " } else { " " };
let prefix_style = if focused { highlight } else { normal };
let value_display = if focused {
format!("< {value} >")
} else {
format!(" {value} ")
};
let value_style = if focused { highlight } else { normal };
let label_width = 20;
let padded_label = format!("{label:<label_width$}");
Line::from(vec![
Span::styled(prefix, prefix_style),
Span::styled(padded_label, label_style),
Span::styled(value_display, value_style),
])
}
fn render_readonly_line<'a>(label: &'a str, value: &'a str, value_style: Style) -> Line<'a> {
let label_style = Style::new().fg(LABEL_COLOR);
let label_width = 20;
let padded_label = format!("{label:<label_width$}");
Line::from(vec![
Span::raw(" "),
Span::styled(padded_label, label_style),
Span::styled(format!(" {value}"), value_style),
])
}

View File

@@ -14,7 +14,7 @@ use crate::state::{FlashKind, Modal, PanelFocus, PatternField, SidePanel};
use crate::views::highlight::{self, highlight_line, highlight_line_with_runtime};
use crate::widgets::{ConfirmModal, ModalFrame, NavMinimap, NavTile, SampleBrowser, TextInputModal};
use super::{audio_view, dict_view, help_view, main_view, patterns_view, title_view};
use super::{dict_view, engine_view, help_view, main_view, options_view, patterns_view, title_view};
fn adjust_spans_for_line(spans: &[SourceSpan], line_start: usize, line_len: usize) -> Vec<SourceSpan> {
spans.iter().filter_map(|s| {
@@ -82,7 +82,8 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq
match app.page {
Page::Main => main_view::render(frame, app, snapshot, page_area),
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
Page::Audio => audio_view::render(frame, app, link, page_area),
Page::Engine => engine_view::render(frame, app, page_area),
Page::Options => options_view::render(frame, app, link, page_area),
Page::Help => help_view::render(frame, app, page_area),
Page::Dict => dict_view::render(frame, app, page_area),
}
@@ -261,7 +262,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
let page_indicator = match app.page {
Page::Main => "[MAIN]",
Page::Patterns => "[PATTERNS]",
Page::Audio => "[AUDIO]",
Page::Engine => "[ENGINE]",
Page::Options => "[OPTIONS]",
Page::Help => "[HELP]",
Page::Dict => "[DICT]",
};
@@ -294,13 +296,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
("r", "Rename"),
("Del", "Reset"),
],
Page::Audio => vec![
Page::Engine => vec![
("Tab", "Section"),
("←→", "Switch/Adjust"),
("↑↓", "Navigate"),
("←→", "Adjust"),
("h", "Hush"),
("p", "Panic"),
("r", "Reset"),
("t", "Test"),
("Enter", "Select"),
("A", "Add path"),
],
Page::Options => vec![
("Tab", "Next"),
("←→", "Toggle"),
("Space", "Play"),
],
Page::Help => vec![