use ratatui::layout::Rect; use ratatui::style::{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::midi; use crate::state::OptionsFocus; use crate::theme::{self, ThemeColors}; use crate::widgets::{render_scroll_indicators, IndicatorAlign}; pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let theme = theme::get(); let block = Block::default() .borders(Borders::ALL) .title(" Options ") .border_style(Style::new().fg(theme.modal.input)); 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 focus = app.options.focus; let content_width = padded.width as usize; let onboarding_str = format!("{}/6 dismissed", app.ui.onboarding_dismissed.len()); let hue_str = format!("{}°", app.ui.hue_rotation as i32); let mut lines: Vec = vec![ render_section_header("DISPLAY", &theme), render_divider(content_width, &theme), render_option_line( "Theme", app.ui.color_scheme.label(), focus == OptionsFocus::ColorScheme, &theme, ), render_option_line( "Hue rotation", &hue_str, focus == OptionsFocus::HueRotation, &theme, ), render_option_line( "Refresh rate", app.audio.config.refresh_rate.label(), focus == OptionsFocus::RefreshRate, &theme, ), render_option_line( "Runtime highlight", if app.ui.runtime_highlight { "On" } else { "Off" }, focus == OptionsFocus::RuntimeHighlight, &theme, ), render_option_line( "Show scope", if app.audio.config.show_scope { "On" } else { "Off" }, focus == OptionsFocus::ShowScope, &theme, ), render_option_line( "Show spectrum", if app.audio.config.show_spectrum { "On" } else { "Off" }, focus == OptionsFocus::ShowSpectrum, &theme, ), render_option_line( "Show lissajous", if app.audio.config.show_lissajous { "On" } else { "Off" }, focus == OptionsFocus::ShowLissajous, &theme, ), render_option_line( "Gain boost", &gain_boost_label(app.audio.config.gain_boost), focus == OptionsFocus::GainBoost, &theme, ), render_option_line( "Normalize", if app.audio.config.normalize_viz { "On" } else { "Off" }, focus == OptionsFocus::NormalizeViz, &theme, ), render_option_line( "Completion", if app.ui.show_completion { "On" } else { "Off" }, focus == OptionsFocus::ShowCompletion, &theme, ), render_option_line( "Show preview", if app.audio.config.show_preview { "On" } else { "Off" }, focus == OptionsFocus::ShowPreview, &theme, ), render_option_line( "Performance mode", if app.ui.performance_mode { "On" } else { "Off" }, focus == OptionsFocus::PerformanceMode, &theme, ), ]; if app.plugin_mode { let zoom_str = format!("{:.0}%", app.ui.zoom_factor * 100.0); let window_str = format!("{}x{}", app.ui.window_width, app.ui.window_height); lines.push(render_option_line( "Font", &app.ui.font, focus == OptionsFocus::Font, &theme, )); lines.push(render_option_line( "Zoom", &zoom_str, focus == OptionsFocus::ZoomFactor, &theme, )); lines.push(render_option_line( "Window", &window_str, focus == OptionsFocus::WindowSize, &theme, )); } if !app.plugin_mode { let enabled = link.is_enabled(); let peers = link.peers(); let (status_text, status_color) = if !enabled { ("DISABLED", theme.link_status.disabled) } else if peers > 0 { ("CONNECTED", theme.link_status.connected) } else { ("LISTENING", theme.link_status.listening) }; let peer_text = if enabled && peers > 0 { if peers == 1 { " · 1 peer".to_string() } else { format!(" · {peers} peers") } } else { String::new() }; let link_header = Line::from(vec![ Span::styled( "ABLETON LINK", Style::new().fg(theme.ui.header).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(theme.ui.text_muted)), ]); let quantum_str = format!("{:.0}", link.quantum()); let tempo_str = format!("{:.1} BPM", link.tempo()); let beat_str = format!("{:.2}", link.beat()); let phase_str = format!("{:.2}", link.phase()); let tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD); let value_style = Style::new().fg(theme.values.value); lines.push(Line::from("")); lines.extend([ link_header, render_divider(content_width, &theme), render_option_line( "Enabled", if link.is_enabled() { "On" } else { "Off" }, focus == OptionsFocus::LinkEnabled, &theme, ), render_option_line( "Start/Stop sync", if link.is_start_stop_sync_enabled() { "On" } else { "Off" }, focus == OptionsFocus::StartStopSync, &theme, ), render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum, &theme), Line::from(""), render_section_header("SESSION", &theme), render_divider(content_width, &theme), render_readonly_line("Tempo", &tempo_str, tempo_style, &theme), render_readonly_line("Beat", &beat_str, value_style, &theme), render_readonly_line("Phase", &phase_str, value_style, &theme), ]); let midi_outputs = midi::list_midi_outputs(); let midi_inputs = midi::list_midi_inputs(); let midi_out_display = |slot: usize| -> String { if let Some(idx) = app.midi.selected_outputs[slot] { midi_outputs .get(idx) .map(|d| d.name.clone()) .unwrap_or_else(|| "(disconnected)".to_string()) } else if midi_outputs.is_empty() { "(none found)".to_string() } else { "(not connected)".to_string() } }; let midi_in_display = |slot: usize| -> String { if let Some(idx) = app.midi.selected_inputs[slot] { midi_inputs .get(idx) .map(|d| d.name.clone()) .unwrap_or_else(|| "(disconnected)".to_string()) } else if midi_inputs.is_empty() { "(none found)".to_string() } else { "(not connected)".to_string() } }; let midi_out_0 = midi_out_display(0); let midi_out_1 = midi_out_display(1); let midi_out_2 = midi_out_display(2); let midi_out_3 = midi_out_display(3); let midi_in_0 = midi_in_display(0); let midi_in_1 = midi_in_display(1); let midi_in_2 = midi_in_display(2); let midi_in_3 = midi_in_display(3); lines.push(Line::from("")); lines.extend([ render_section_header("MIDI OUTPUTS", &theme), render_divider(content_width, &theme), render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme), render_option_line("Output 1", &midi_out_1, focus == OptionsFocus::MidiOutput1, &theme), render_option_line("Output 2", &midi_out_2, focus == OptionsFocus::MidiOutput2, &theme), render_option_line("Output 3", &midi_out_3, focus == OptionsFocus::MidiOutput3, &theme), Line::from(""), render_section_header("MIDI INPUTS", &theme), render_divider(content_width, &theme), render_option_line("Input 0", &midi_in_0, focus == OptionsFocus::MidiInput0, &theme), render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme), render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme), render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme), ]); } if !app.plugin_mode { lines.push(Line::from("")); lines.extend([ render_section_header("ONBOARDING", &theme), render_divider(content_width, &theme), render_option_line( "Reset guides", &onboarding_str, focus == OptionsFocus::ResetOnboarding, &theme, ), render_option_line( "Demo on startup", if app.ui.load_demo_on_startup { "On" } else { "Off" }, focus == OptionsFocus::LoadDemoOnStartup, &theme, ), ]); } // Insert description below focused option let focus_vec_idx = focus.line_index(app.plugin_mode); if let Some(desc) = option_description(focus) { if focus_vec_idx < lines.len() { lines.insert(focus_vec_idx + 1, render_description_line(desc, &theme)); } } let total_lines = lines.len(); let max_visible = padded.height as usize; let focus_line = focus.line_index(app.plugin_mode); let scroll_offset = if total_lines <= max_visible { 0 } else { focus_line .saturating_sub(max_visible / 2) .min(total_lines.saturating_sub(max_visible)) }; let visible_end = (scroll_offset + max_visible).min(total_lines); let visible_lines: Vec = lines .into_iter() .skip(scroll_offset) .take(visible_end - scroll_offset) .collect(); frame.render_widget(Paragraph::new(visible_lines), padded); render_scroll_indicators( frame, padded, scroll_offset, visible_end - scroll_offset, total_lines, theme.ui.text_dim, IndicatorAlign::Right, ); } fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'static> { Line::from(Span::styled( title.to_string(), Style::new().fg(theme.ui.header).add_modifier(Modifier::BOLD), )) } fn render_divider(width: usize, theme: &theme::ThemeColors) -> Line<'static> { Line::from(Span::styled( "─".repeat(width), Style::new().fg(theme.ui.border), )) } fn render_option_line(label: &str, value: &str, focused: bool, theme: &theme::ThemeColors) -> Line<'static> { let highlight = Style::new().fg(theme.hint.key).add_modifier(Modifier::BOLD); let normal = Style::new().fg(theme.ui.text_primary); let label_style = Style::new().fg(theme.ui.text_muted); 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: Option<&'static str> { match focus { OptionsFocus::ColorScheme => Some("Color scheme for the entire interface"), OptionsFocus::HueRotation => Some("Shift all theme colors by a hue angle"), OptionsFocus::RefreshRate => Some("Lower values reduce CPU usage"), OptionsFocus::RuntimeHighlight => Some("Highlight executed code spans during playback"), OptionsFocus::ShowScope => Some("Oscilloscope on the main view"), OptionsFocus::ShowSpectrum => Some("Spectrum analyzer on the main view"), OptionsFocus::ShowLissajous => Some("XY stereo phase scope (left vs right)"), OptionsFocus::GainBoost => Some("Amplify scope and lissajous waveforms"), OptionsFocus::NormalizeViz => Some("Auto-scale visualizations to fill the display"), OptionsFocus::ShowCompletion => Some("Word completion popup in the editor"), OptionsFocus::ShowPreview => Some("Step script preview on the sequencer grid"), OptionsFocus::PerformanceMode => Some("Hide header and footer bars"), OptionsFocus::Font => Some("Bitmap font for the plugin window"), OptionsFocus::ZoomFactor => Some("Scale factor for the plugin window"), OptionsFocus::WindowSize => Some("Default size for the plugin window"), OptionsFocus::LinkEnabled => Some("Join an Ableton Link session on the local network"), OptionsFocus::StartStopSync => Some("Sync transport start/stop with other Link peers"), OptionsFocus::Quantum => Some("Number of beats per phase cycle"), OptionsFocus::MidiOutput0 => Some("MIDI output device for channel group 1"), OptionsFocus::MidiOutput1 => Some("MIDI output device for channel group 2"), OptionsFocus::MidiOutput2 => Some("MIDI output device for channel group 3"), OptionsFocus::MidiOutput3 => Some("MIDI output device for channel group 4"), OptionsFocus::MidiInput0 => Some("MIDI input device for channel group 1"), OptionsFocus::MidiInput1 => Some("MIDI input device for channel group 2"), OptionsFocus::MidiInput2 => Some("MIDI input device for channel group 3"), OptionsFocus::MidiInput3 => Some("MIDI input device for channel group 4"), OptionsFocus::ResetOnboarding => Some("Re-enable all dismissed guide popups"), OptionsFocus::LoadDemoOnStartup => Some("Load a rotating demo song on fresh startup"), } } fn render_description_line(desc: &str, theme: &ThemeColors) -> Line<'static> { Line::from(Span::styled( format!(" {desc}"), Style::new().fg(theme.ui.text_dim), )) } fn gain_boost_label(gain: f32) -> String { format!("{:.0}x", gain) } fn render_readonly_line(label: &str, value: &str, value_style: Style, theme: &theme::ThemeColors) -> Line<'static> { let label_style = Style::new().fg(theme.ui.text_muted); let label_width = 20; let padded_label = format!("{label: