Files
Cagire/src/views/options_view.rs

289 lines
9.4 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;
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 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);
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);
let hue_str = format!("{}°", app.ui.hue_rotation as i32);
let 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(
"Completion",
if app.ui.show_completion { "On" } else { "Off" },
focus == OptionsFocus::ShowCompletion,
&theme,
),
Line::from(""),
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),
Line::from(""),
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),
];
let total_lines = lines.len();
let max_visible = padded.height as usize;
let focus_line = focus.line_index();
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<'a>(label: &'a str, value: &'a str, focused: bool, theme: &theme::ThemeColors) -> Line<'a> {
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, 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, theme: &theme::ThemeColors) -> Line<'a> {
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::raw(" "),
Span::styled(padded_label, label_style),
Span::styled(format!(" {value}"), value_style),
])
}