418 lines
15 KiB
Rust
418 lines
15 KiB
Rust
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<Line> = 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<Line> = 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:<label_width$}");
|
|
|
|
Line::from(vec![
|
|
Span::styled(prefix.to_string(), prefix_style),
|
|
Span::styled(padded_label, label_style),
|
|
Span::styled(value_display, value_style),
|
|
])
|
|
}
|
|
|
|
fn option_description(focus: OptionsFocus) -> 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:<label_width$}");
|
|
|
|
Line::from(vec![
|
|
Span::styled(" ".to_string(), Style::new()),
|
|
Span::styled(padded_label, label_style),
|
|
Span::styled(format!(" {value}"), value_style),
|
|
])
|
|
}
|