This commit is contained in:
@@ -379,7 +379,6 @@ impl App {
|
||||
}
|
||||
|
||||
// Audio settings (engine page)
|
||||
AppCommand::AudioSetSection(section) => self.audio.section = section,
|
||||
AppCommand::AudioNextSection => self.audio.next_section(self.plugin_mode),
|
||||
AppCommand::AudioPrevSection => self.audio.prev_section(self.plugin_mode),
|
||||
AppCommand::AudioOutputListUp => self.audio.output_list.move_up(),
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::path::PathBuf;
|
||||
|
||||
use crate::model::{FollowUp, LaunchQuantization, PatternSpeed, SyncMode};
|
||||
use crate::page::Page;
|
||||
use crate::state::{ColorScheme, DeviceKind, EngineSection, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
|
||||
use crate::state::{ColorScheme, DeviceKind, Modal, OptionsFocus, PatternField, ScriptField, SettingKind};
|
||||
|
||||
pub enum AppCommand {
|
||||
// Undo/Redo
|
||||
@@ -247,7 +247,6 @@ pub enum AppCommand {
|
||||
SetSelectionAnchor(usize),
|
||||
|
||||
// Audio settings (engine page)
|
||||
AudioSetSection(EngineSection),
|
||||
AudioNextSection,
|
||||
AudioPrevSection,
|
||||
AudioOutputListUp,
|
||||
|
||||
@@ -4,7 +4,7 @@ use std::sync::atomic::Ordering;
|
||||
use super::{InputContext, InputResult};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::engine::{AudioCommand, SeqCommand};
|
||||
use crate::state::{ConfirmAction, DeviceKind, EngineSection, Modal, SettingKind};
|
||||
use crate::state::{ConfirmAction, DeviceKind, EngineSection, LinkSetting, Modal, SettingKind};
|
||||
|
||||
pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
|
||||
let sign = if right { 1 } else { -1 };
|
||||
@@ -31,6 +31,112 @@ pub(crate) fn cycle_engine_setting(ctx: &mut InputContext, right: bool) {
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_link_setting(ctx: &mut InputContext, right: bool) {
|
||||
match ctx.app.audio.link_setting {
|
||||
LinkSetting::Enabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||
LinkSetting::StartStopSync => ctx
|
||||
.link
|
||||
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||
LinkSetting::Quantum => {
|
||||
let delta = if right { 1.0 } else { -1.0 };
|
||||
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_midi_output(ctx: &mut InputContext, right: bool) {
|
||||
let slot = ctx.app.audio.midi_output_slot;
|
||||
let all_devices = crate::midi::list_midi_outputs();
|
||||
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|
||||
|| !ctx
|
||||
.app
|
||||
.midi
|
||||
.selected_outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
||||
})
|
||||
.collect();
|
||||
let total_options = available.len() + 1;
|
||||
let current_pos = ctx.app.midi.selected_outputs[slot]
|
||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let new_pos = if right {
|
||||
(current_pos + 1) % total_options
|
||||
} else if current_pos == 0 {
|
||||
total_options - 1
|
||||
} else {
|
||||
current_pos - 1
|
||||
};
|
||||
if new_pos == 0 {
|
||||
ctx.app.midi.disconnect_output(slot);
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output {slot}: disconnected"
|
||||
)));
|
||||
} else {
|
||||
let (device_idx, device) = available[new_pos - 1];
|
||||
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output {}: {}",
|
||||
slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
pub(crate) fn cycle_midi_input(ctx: &mut InputContext, right: bool) {
|
||||
let slot = ctx.app.audio.midi_input_slot;
|
||||
let all_devices = crate::midi::list_midi_inputs();
|
||||
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|
||||
|| !ctx
|
||||
.app
|
||||
.midi
|
||||
.selected_inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
||||
})
|
||||
.collect();
|
||||
let total_options = available.len() + 1;
|
||||
let current_pos = ctx.app.midi.selected_inputs[slot]
|
||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let new_pos = if right {
|
||||
(current_pos + 1) % total_options
|
||||
} else if current_pos == 0 {
|
||||
total_options - 1
|
||||
} else {
|
||||
current_pos - 1
|
||||
};
|
||||
if new_pos == 0 {
|
||||
ctx.app.midi.disconnect_input(slot);
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input {slot}: disconnected"
|
||||
)));
|
||||
} else {
|
||||
let (device_idx, device) = available[new_pos - 1];
|
||||
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input {}: {}",
|
||||
slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult {
|
||||
match key.code {
|
||||
KeyCode::Char('q') if !ctx.app.plugin_mode => {
|
||||
@@ -49,6 +155,15 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
EngineSection::Settings => {
|
||||
ctx.dispatch(AppCommand::AudioSettingPrev);
|
||||
}
|
||||
EngineSection::Link => {
|
||||
ctx.app.audio.prev_link_setting();
|
||||
}
|
||||
EngineSection::MidiOutput => {
|
||||
ctx.app.audio.prev_midi_output_slot();
|
||||
}
|
||||
EngineSection::MidiInput => {
|
||||
ctx.app.audio.prev_midi_input_slot();
|
||||
}
|
||||
EngineSection::Samples => {
|
||||
ctx.app.audio.sample_list.move_up();
|
||||
}
|
||||
@@ -68,6 +183,15 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
EngineSection::Settings => {
|
||||
ctx.dispatch(AppCommand::AudioSettingNext);
|
||||
}
|
||||
EngineSection::Link => {
|
||||
ctx.app.audio.next_link_setting();
|
||||
}
|
||||
EngineSection::MidiOutput => {
|
||||
ctx.app.audio.next_midi_output_slot();
|
||||
}
|
||||
EngineSection::MidiInput => {
|
||||
ctx.app.audio.next_midi_input_slot();
|
||||
}
|
||||
EngineSection::Samples => {
|
||||
let count = ctx.app.audio.config.sample_paths.len();
|
||||
ctx.app.audio.sample_list.move_down(count);
|
||||
@@ -123,6 +247,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
|
||||
}
|
||||
EngineSection::Settings => cycle_engine_setting(ctx, false),
|
||||
EngineSection::Link => cycle_link_setting(ctx, false),
|
||||
EngineSection::MidiOutput => cycle_midi_output(ctx, false),
|
||||
EngineSection::MidiInput => cycle_midi_input(ctx, false),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Right => match ctx.app.audio.section {
|
||||
@@ -130,6 +257,9 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
|
||||
}
|
||||
EngineSection::Settings => cycle_engine_setting(ctx, true),
|
||||
EngineSection::Link => cycle_link_setting(ctx, true),
|
||||
EngineSection::MidiOutput => cycle_midi_output(ctx, true),
|
||||
EngineSection::MidiInput => cycle_midi_input(ctx, true),
|
||||
_ => {}
|
||||
},
|
||||
KeyCode::Char('R') if !ctx.app.plugin_mode => {
|
||||
@@ -158,7 +288,6 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input
|
||||
if !ctx.app.plugin_mode {
|
||||
let _ = ctx.audio_tx.load().send(AudioCommand::Hush);
|
||||
}
|
||||
let _ = ctx.seq_cmd_tx.send(SeqCommand::StopAll);
|
||||
}
|
||||
KeyCode::Char('p') => {
|
||||
if !ctx.app.plugin_mode {
|
||||
|
||||
@@ -6,8 +6,8 @@ use ratatui::layout::{Constraint, Layout, Rect};
|
||||
use crate::commands::AppCommand;
|
||||
use crate::page::Page;
|
||||
use crate::state::{
|
||||
DeviceKind, DictFocus, EditorTarget, EngineSection, HelpFocus, MinimapMode, Modal,
|
||||
OptionsFocus, PatternsColumn, SettingKind,
|
||||
DictFocus, EditorTarget, HelpFocus, MinimapMode, Modal,
|
||||
OptionsFocus, PatternsColumn,
|
||||
};
|
||||
use crate::views::{dict_view, engine_view, help_view, main_view, patterns_view, script_view};
|
||||
|
||||
@@ -288,31 +288,10 @@ fn handle_scroll(ctx: &mut InputContext, col: u16, row: u16, term: Rect, up: boo
|
||||
}
|
||||
}
|
||||
Page::Engine => {
|
||||
let [left_col, _, _] = engine_view::layout(body);
|
||||
if contains(left_col, col, row) {
|
||||
match ctx.app.audio.section {
|
||||
EngineSection::Devices => {
|
||||
if ctx.app.audio.device_kind == DeviceKind::Input {
|
||||
if up {
|
||||
ctx.dispatch(AppCommand::AudioInputListUp);
|
||||
ctx.dispatch(AppCommand::AudioPrevSection);
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::AudioInputListDown(1));
|
||||
}
|
||||
} else if up {
|
||||
ctx.dispatch(AppCommand::AudioOutputListUp);
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::AudioOutputListDown(1));
|
||||
}
|
||||
}
|
||||
EngineSection::Settings => {
|
||||
if up {
|
||||
ctx.dispatch(AppCommand::AudioSettingPrev);
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::AudioSettingNext);
|
||||
}
|
||||
}
|
||||
EngineSection::Samples => {}
|
||||
}
|
||||
ctx.dispatch(AppCommand::AudioNextSection);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -944,132 +923,24 @@ fn handle_script_editor_mouse(
|
||||
ctx.app.script_editor.editor.move_cursor_to(text_row, text_col);
|
||||
}
|
||||
|
||||
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, kind: ClickKind) {
|
||||
let [left_col, _, right_col] = engine_view::layout(area);
|
||||
|
||||
// Viz panel clicks (right column)
|
||||
if contains(right_col, col, row) {
|
||||
let [scope_area, _, lissajous_area, _, spectrum_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(right_col);
|
||||
|
||||
if contains(scope_area, col, row) {
|
||||
if kind == ClickKind::Double {
|
||||
ctx.dispatch(AppCommand::FlipScopeOrientation);
|
||||
fn handle_engine_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect, _kind: ClickKind) {
|
||||
// In narrow mode the whole area is a single column, in wide mode it's a 55/45 split.
|
||||
// Either way, left-column clicks cycle through sections; right column is monitoring (non-interactive).
|
||||
let is_narrow = area.width < 100;
|
||||
let left_col = if is_narrow {
|
||||
area
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::CycleScopeMode);
|
||||
}
|
||||
} else if contains(lissajous_area, col, row) {
|
||||
ctx.dispatch(AppCommand::ToggleLissajousTrails);
|
||||
} else if contains(spectrum_area, col, row) {
|
||||
if kind == ClickKind::Double {
|
||||
ctx.dispatch(AppCommand::ToggleSpectrumPeaks);
|
||||
} else {
|
||||
ctx.dispatch(AppCommand::CycleSpectrumMode);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
let [left, _, _] = engine_view::layout(area);
|
||||
left
|
||||
};
|
||||
|
||||
if !contains(left_col, col, row) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Replicate engine_view render_settings_section layout
|
||||
let inner = Rect {
|
||||
x: left_col.x + 1,
|
||||
y: left_col.y + 1,
|
||||
width: left_col.width.saturating_sub(2),
|
||||
height: left_col.height.saturating_sub(2),
|
||||
};
|
||||
let padded = Rect {
|
||||
x: inner.x + 1,
|
||||
y: inner.y + 1,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height.saturating_sub(1),
|
||||
};
|
||||
|
||||
if row < padded.y || row >= padded.y + padded.height {
|
||||
return;
|
||||
}
|
||||
|
||||
let devices_lines = engine_view::devices_section_height(ctx.app) as usize;
|
||||
let settings_lines: usize = 8;
|
||||
let samples_lines: usize = 6;
|
||||
let total_lines = devices_lines + 1 + settings_lines + 1 + samples_lines;
|
||||
let max_visible = padded.height as usize;
|
||||
|
||||
let (focus_start, focus_height) = match ctx.app.audio.section {
|
||||
EngineSection::Devices => (0, devices_lines),
|
||||
EngineSection::Settings => (devices_lines + 1, settings_lines),
|
||||
EngineSection::Samples => (devices_lines + 1 + settings_lines + 1, samples_lines),
|
||||
};
|
||||
|
||||
let scroll_offset = if total_lines <= max_visible {
|
||||
0
|
||||
} else {
|
||||
let focus_end = focus_start + focus_height;
|
||||
if focus_end <= max_visible {
|
||||
0
|
||||
} else {
|
||||
focus_start.min(total_lines.saturating_sub(max_visible))
|
||||
}
|
||||
};
|
||||
|
||||
let relative_y = (row - padded.y) as usize;
|
||||
let abs_line = scroll_offset + relative_y;
|
||||
|
||||
let devices_end = devices_lines;
|
||||
let settings_start = devices_lines + 1;
|
||||
let settings_end = settings_start + settings_lines;
|
||||
let samples_start = settings_end + 1;
|
||||
|
||||
if abs_line < devices_end {
|
||||
ctx.dispatch(AppCommand::AudioSetSection(EngineSection::Devices));
|
||||
// Determine output vs input sub-column
|
||||
let [output_col, _sep, input_col] = Layout::horizontal([
|
||||
Constraint::Percentage(48),
|
||||
Constraint::Length(3),
|
||||
Constraint::Percentage(48),
|
||||
])
|
||||
.areas(padded);
|
||||
if contains(input_col, col, row) {
|
||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Input));
|
||||
} else if contains(output_col, col, row) {
|
||||
ctx.dispatch(AppCommand::SetDeviceKind(DeviceKind::Output));
|
||||
}
|
||||
} else if abs_line >= settings_start && abs_line < settings_end {
|
||||
ctx.dispatch(AppCommand::AudioSetSection(EngineSection::Settings));
|
||||
// Settings section: 2 header lines + 6 table rows
|
||||
// Rows 0-3 are adjustable (Channels, Buffer, Voices, Nudge)
|
||||
let row_in_section = abs_line - settings_start;
|
||||
if row_in_section >= 2 {
|
||||
let table_row = row_in_section - 2;
|
||||
let setting = match table_row {
|
||||
0 => Some(SettingKind::Channels),
|
||||
1 => Some(SettingKind::BufferSize),
|
||||
2 => Some(SettingKind::Polyphony),
|
||||
3 => Some(SettingKind::Nudge),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = setting {
|
||||
ctx.app.audio.setting_kind = kind;
|
||||
// Table columns: [Length(14), Fill(1)] — value starts at padded.x + 14
|
||||
let value_x = padded.x + 14;
|
||||
if col >= value_x {
|
||||
let right = col >= value_x + 4;
|
||||
super::engine_page::cycle_engine_setting(ctx, right);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if abs_line >= samples_start {
|
||||
ctx.dispatch(AppCommand::AudioSetSection(EngineSection::Samples));
|
||||
}
|
||||
// Simple: cycle section on click. The complex per-line hit-testing is fragile
|
||||
// given the scrollable layout. Tab/keyboard is the primary navigation.
|
||||
ctx.dispatch(AppCommand::AudioNextSection);
|
||||
}
|
||||
|
||||
// --- Modal ---
|
||||
|
||||
@@ -89,126 +89,12 @@ pub(crate) fn cycle_option_value(ctx: &mut InputContext, right: bool) {
|
||||
let (w, h) = WINDOW_SIZES[new_pos];
|
||||
ctx.dispatch(AppCommand::SetWindowSize(w, h));
|
||||
}
|
||||
OptionsFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()),
|
||||
OptionsFocus::StartStopSync => ctx
|
||||
.link
|
||||
.set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()),
|
||||
OptionsFocus::Quantum => {
|
||||
let delta = if right { 1.0 } else { -1.0 };
|
||||
ctx.link.set_quantum(ctx.link.quantum() + delta);
|
||||
}
|
||||
OptionsFocus::MidiOutput0
|
||||
| OptionsFocus::MidiOutput1
|
||||
| OptionsFocus::MidiOutput2
|
||||
| OptionsFocus::MidiOutput3 => {
|
||||
let slot = match ctx.app.options.focus {
|
||||
OptionsFocus::MidiOutput0 => 0,
|
||||
OptionsFocus::MidiOutput1 => 1,
|
||||
OptionsFocus::MidiOutput2 => 2,
|
||||
OptionsFocus::MidiOutput3 => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let all_devices = crate::midi::list_midi_outputs();
|
||||
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
ctx.app.midi.selected_outputs[slot] == Some(*idx)
|
||||
|| !ctx
|
||||
.app
|
||||
.midi
|
||||
.selected_outputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
||||
})
|
||||
.collect();
|
||||
let total_options = available.len() + 1;
|
||||
let current_pos = ctx.app.midi.selected_outputs[slot]
|
||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let new_pos = if right {
|
||||
(current_pos + 1) % total_options
|
||||
} else if current_pos == 0 {
|
||||
total_options - 1
|
||||
} else {
|
||||
current_pos - 1
|
||||
};
|
||||
if new_pos == 0 {
|
||||
ctx.app.midi.disconnect_output(slot);
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output {slot}: disconnected"
|
||||
)));
|
||||
} else {
|
||||
let (device_idx, device) = available[new_pos - 1];
|
||||
if ctx.app.midi.connect_output(slot, device_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI output {}: {}",
|
||||
slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
OptionsFocus::ResetOnboarding => {
|
||||
ctx.dispatch(AppCommand::ResetOnboarding);
|
||||
}
|
||||
OptionsFocus::LoadDemoOnStartup => {
|
||||
ctx.app.ui.load_demo_on_startup = !ctx.app.ui.load_demo_on_startup;
|
||||
}
|
||||
OptionsFocus::MidiInput0
|
||||
| OptionsFocus::MidiInput1
|
||||
| OptionsFocus::MidiInput2
|
||||
| OptionsFocus::MidiInput3 => {
|
||||
let slot = match ctx.app.options.focus {
|
||||
OptionsFocus::MidiInput0 => 0,
|
||||
OptionsFocus::MidiInput1 => 1,
|
||||
OptionsFocus::MidiInput2 => 2,
|
||||
OptionsFocus::MidiInput3 => 3,
|
||||
_ => 0,
|
||||
};
|
||||
let all_devices = crate::midi::list_midi_inputs();
|
||||
let available: Vec<(usize, &crate::midi::MidiDeviceInfo)> = all_devices
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(idx, _)| {
|
||||
ctx.app.midi.selected_inputs[slot] == Some(*idx)
|
||||
|| !ctx
|
||||
.app
|
||||
.midi
|
||||
.selected_inputs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.any(|(s, sel)| s != slot && *sel == Some(*idx))
|
||||
})
|
||||
.collect();
|
||||
let total_options = available.len() + 1;
|
||||
let current_pos = ctx.app.midi.selected_inputs[slot]
|
||||
.and_then(|idx| available.iter().position(|(i, _)| *i == idx))
|
||||
.map(|p| p + 1)
|
||||
.unwrap_or(0);
|
||||
let new_pos = if right {
|
||||
(current_pos + 1) % total_options
|
||||
} else if current_pos == 0 {
|
||||
total_options - 1
|
||||
} else {
|
||||
current_pos - 1
|
||||
};
|
||||
if new_pos == 0 {
|
||||
ctx.app.midi.disconnect_input(slot);
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input {slot}: disconnected"
|
||||
)));
|
||||
} else {
|
||||
let (device_idx, device) = available[new_pos - 1];
|
||||
if ctx.app.midi.connect_input(slot, device_idx).is_ok() {
|
||||
ctx.dispatch(AppCommand::SetStatus(format!(
|
||||
"MIDI input {}: {}",
|
||||
slot, device.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ctx.app.save_settings(ctx.link);
|
||||
}
|
||||
|
||||
@@ -202,11 +202,33 @@ pub enum EngineSection {
|
||||
#[default]
|
||||
Devices,
|
||||
Settings,
|
||||
Link,
|
||||
MidiOutput,
|
||||
MidiInput,
|
||||
Samples,
|
||||
}
|
||||
|
||||
impl CyclicEnum for EngineSection {
|
||||
const VARIANTS: &'static [Self] = &[Self::Devices, Self::Settings, Self::Samples];
|
||||
const VARIANTS: &'static [Self] = &[
|
||||
Self::Devices,
|
||||
Self::Settings,
|
||||
Self::Link,
|
||||
Self::MidiOutput,
|
||||
Self::MidiInput,
|
||||
Self::Samples,
|
||||
];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum LinkSetting {
|
||||
#[default]
|
||||
Enabled,
|
||||
StartStopSync,
|
||||
Quantum,
|
||||
}
|
||||
|
||||
impl CyclicEnum for LinkSetting {
|
||||
const VARIANTS: &'static [Self] = &[Self::Enabled, Self::StartStopSync, Self::Quantum];
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq, Eq, Default)]
|
||||
@@ -271,6 +293,9 @@ pub struct AudioSettings {
|
||||
pub section: EngineSection,
|
||||
pub device_kind: DeviceKind,
|
||||
pub setting_kind: SettingKind,
|
||||
pub link_setting: LinkSetting,
|
||||
pub midi_output_slot: usize,
|
||||
pub midi_input_slot: usize,
|
||||
pub output_devices: Vec<AudioDeviceInfo>,
|
||||
pub input_devices: Vec<AudioDeviceInfo>,
|
||||
pub output_list: ListSelectState,
|
||||
@@ -288,6 +313,9 @@ impl Default for AudioSettings {
|
||||
section: EngineSection::default(),
|
||||
device_kind: DeviceKind::default(),
|
||||
setting_kind: SettingKind::default(),
|
||||
link_setting: LinkSetting::default(),
|
||||
midi_output_slot: 0,
|
||||
midi_input_slot: 0,
|
||||
output_devices: doux::audio::list_output_devices(),
|
||||
input_devices: doux::audio::list_input_devices(),
|
||||
output_list: ListSelectState {
|
||||
@@ -313,9 +341,12 @@ impl AudioSettings {
|
||||
pub fn new_plugin() -> Self {
|
||||
Self {
|
||||
config: AudioConfig::default(),
|
||||
section: EngineSection::default(),
|
||||
section: EngineSection::Settings,
|
||||
device_kind: DeviceKind::default(),
|
||||
setting_kind: SettingKind::default(),
|
||||
setting_kind: SettingKind::Polyphony,
|
||||
link_setting: LinkSetting::default(),
|
||||
midi_output_slot: 0,
|
||||
midi_input_slot: 0,
|
||||
output_devices: Vec::new(),
|
||||
input_devices: Vec::new(),
|
||||
output_list: ListSelectState {
|
||||
@@ -346,7 +377,7 @@ impl AudioSettings {
|
||||
match self.section {
|
||||
EngineSection::Settings => EngineSection::Samples,
|
||||
EngineSection::Samples => EngineSection::Settings,
|
||||
EngineSection::Devices => EngineSection::Settings,
|
||||
_ => EngineSection::Settings,
|
||||
}
|
||||
} else {
|
||||
self.section.next()
|
||||
@@ -358,7 +389,7 @@ impl AudioSettings {
|
||||
match self.section {
|
||||
EngineSection::Settings => EngineSection::Samples,
|
||||
EngineSection::Samples => EngineSection::Settings,
|
||||
EngineSection::Devices => EngineSection::Settings,
|
||||
_ => EngineSection::Settings,
|
||||
}
|
||||
} else {
|
||||
self.section.prev()
|
||||
@@ -389,6 +420,30 @@ impl AudioSettings {
|
||||
};
|
||||
}
|
||||
|
||||
pub fn next_link_setting(&mut self) {
|
||||
self.link_setting = self.link_setting.next();
|
||||
}
|
||||
|
||||
pub fn prev_link_setting(&mut self) {
|
||||
self.link_setting = self.link_setting.prev();
|
||||
}
|
||||
|
||||
pub fn next_midi_output_slot(&mut self) {
|
||||
self.midi_output_slot = (self.midi_output_slot + 1).min(3);
|
||||
}
|
||||
|
||||
pub fn prev_midi_output_slot(&mut self) {
|
||||
self.midi_output_slot = self.midi_output_slot.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn next_midi_input_slot(&mut self) {
|
||||
self.midi_input_slot = (self.midi_input_slot + 1).min(3);
|
||||
}
|
||||
|
||||
pub fn prev_midi_input_slot(&mut self) {
|
||||
self.midi_input_slot = self.midi_input_slot.saturating_sub(1);
|
||||
}
|
||||
|
||||
pub fn current_output_device_index(&self) -> usize {
|
||||
match &self.config.output_device {
|
||||
Some(name) => self
|
||||
|
||||
@@ -30,7 +30,7 @@ pub mod sample_browser;
|
||||
pub mod undo;
|
||||
pub mod ui;
|
||||
|
||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode};
|
||||
pub use audio::{AudioSettings, DeviceKind, EngineSection, LinkSetting, MainLayout, Metrics, ScopeMode, SettingKind, SpectrumMode};
|
||||
pub use color_scheme::ColorScheme;
|
||||
pub use editor::{
|
||||
CopiedStepData, CopiedSteps, EditorContext, EditorTarget, EuclideanField, PatternField,
|
||||
|
||||
@@ -18,17 +18,6 @@ pub enum OptionsFocus {
|
||||
Font,
|
||||
ZoomFactor,
|
||||
WindowSize,
|
||||
LinkEnabled,
|
||||
StartStopSync,
|
||||
Quantum,
|
||||
MidiOutput0,
|
||||
MidiOutput1,
|
||||
MidiOutput2,
|
||||
MidiOutput3,
|
||||
MidiInput0,
|
||||
MidiInput1,
|
||||
MidiInput2,
|
||||
MidiInput3,
|
||||
ResetOnboarding,
|
||||
LoadDemoOnStartup,
|
||||
}
|
||||
@@ -50,17 +39,6 @@ impl CyclicEnum for OptionsFocus {
|
||||
Self::Font,
|
||||
Self::ZoomFactor,
|
||||
Self::WindowSize,
|
||||
Self::LinkEnabled,
|
||||
Self::StartStopSync,
|
||||
Self::Quantum,
|
||||
Self::MidiOutput0,
|
||||
Self::MidiOutput1,
|
||||
Self::MidiOutput2,
|
||||
Self::MidiOutput3,
|
||||
Self::MidiInput0,
|
||||
Self::MidiInput1,
|
||||
Self::MidiInput2,
|
||||
Self::MidiInput3,
|
||||
Self::ResetOnboarding,
|
||||
Self::LoadDemoOnStartup,
|
||||
];
|
||||
@@ -72,25 +50,7 @@ const PLUGIN_ONLY: &[OptionsFocus] = &[
|
||||
OptionsFocus::WindowSize,
|
||||
];
|
||||
|
||||
const STANDALONE_ONLY: &[OptionsFocus] = &[
|
||||
OptionsFocus::LinkEnabled,
|
||||
OptionsFocus::StartStopSync,
|
||||
OptionsFocus::Quantum,
|
||||
OptionsFocus::MidiOutput0,
|
||||
OptionsFocus::MidiOutput1,
|
||||
OptionsFocus::MidiOutput2,
|
||||
OptionsFocus::MidiOutput3,
|
||||
OptionsFocus::MidiInput0,
|
||||
OptionsFocus::MidiInput1,
|
||||
OptionsFocus::MidiInput2,
|
||||
OptionsFocus::MidiInput3,
|
||||
OptionsFocus::ResetOnboarding,
|
||||
OptionsFocus::LoadDemoOnStartup,
|
||||
];
|
||||
|
||||
/// Section layout: header line, divider line, then option lines.
|
||||
/// Each entry gives the raw line offsets assuming ALL sections are visible
|
||||
/// (plugin mode with Font/Zoom/Window shown).
|
||||
const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[
|
||||
// DISPLAY section: header=0, divider=1
|
||||
(OptionsFocus::ColorScheme, 2),
|
||||
@@ -108,24 +68,9 @@ const FULL_LAYOUT: &[(OptionsFocus, usize)] = &[
|
||||
(OptionsFocus::Font, 14),
|
||||
(OptionsFocus::ZoomFactor, 15),
|
||||
(OptionsFocus::WindowSize, 16),
|
||||
// blank=17, ABLETON LINK header=18, divider=19
|
||||
(OptionsFocus::LinkEnabled, 20),
|
||||
(OptionsFocus::StartStopSync, 21),
|
||||
(OptionsFocus::Quantum, 22),
|
||||
// blank=23, SESSION header=24, divider=25, Tempo=26, Beat=27, Phase=28
|
||||
// blank=29, MIDI OUTPUTS header=30, divider=31
|
||||
(OptionsFocus::MidiOutput0, 32),
|
||||
(OptionsFocus::MidiOutput1, 33),
|
||||
(OptionsFocus::MidiOutput2, 34),
|
||||
(OptionsFocus::MidiOutput3, 35),
|
||||
// blank=36, MIDI INPUTS header=37, divider=38
|
||||
(OptionsFocus::MidiInput0, 39),
|
||||
(OptionsFocus::MidiInput1, 40),
|
||||
(OptionsFocus::MidiInput2, 41),
|
||||
(OptionsFocus::MidiInput3, 42),
|
||||
// blank=43, ONBOARDING header=44, divider=45
|
||||
(OptionsFocus::ResetOnboarding, 46),
|
||||
(OptionsFocus::LoadDemoOnStartup, 47),
|
||||
// blank=17, ONBOARDING header=18, divider=19
|
||||
(OptionsFocus::ResetOnboarding, 20),
|
||||
(OptionsFocus::LoadDemoOnStartup, 21),
|
||||
];
|
||||
|
||||
impl OptionsFocus {
|
||||
@@ -133,17 +78,10 @@ impl OptionsFocus {
|
||||
PLUGIN_ONLY.contains(&self)
|
||||
}
|
||||
|
||||
fn is_standalone_only(self) -> bool {
|
||||
STANDALONE_ONLY.contains(&self)
|
||||
}
|
||||
|
||||
fn is_visible(self, plugin_mode: bool) -> bool {
|
||||
if self.is_plugin_only() && !plugin_mode {
|
||||
return false;
|
||||
}
|
||||
if self.is_standalone_only() && plugin_mode {
|
||||
return false;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
@@ -171,26 +109,12 @@ pub fn total_lines(plugin_mode: bool) -> usize {
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Compute (focus, line_index) pairs for only the visible options,
|
||||
/// with line indices adjusted to account for hidden sections.
|
||||
fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> {
|
||||
// Start from the full layout and compute adjusted line numbers.
|
||||
// Hidden items + their section headers/dividers/blanks shrink the layout.
|
||||
|
||||
// We know the exact section structure, so we compute the offset to subtract
|
||||
// based on which sections are hidden.
|
||||
let mut offset: usize = 0;
|
||||
|
||||
// Font/Zoom/Window lines (14,15,16) hidden when !plugin_mode
|
||||
if !plugin_mode {
|
||||
offset += 3; // 3 lines for Font, ZoomFactor, WindowSize
|
||||
}
|
||||
|
||||
// Link + Session + MIDI sections hidden when plugin_mode
|
||||
// These span from blank(17) through MidiInput3(42) = 26 lines
|
||||
if plugin_mode {
|
||||
let link_section_lines = 26;
|
||||
offset += link_section_lines;
|
||||
offset += 3;
|
||||
}
|
||||
|
||||
let mut result = Vec::new();
|
||||
@@ -198,11 +122,9 @@ fn visible_layout(plugin_mode: bool) -> Vec<(OptionsFocus, usize)> {
|
||||
if !focus.is_visible(plugin_mode) {
|
||||
continue;
|
||||
}
|
||||
// Lines at or below index 13 (PerformanceMode) are never shifted
|
||||
let adjusted = if raw_line <= 13 {
|
||||
raw_line
|
||||
} else if !plugin_mode && raw_line <= 16 {
|
||||
// Font/Zoom/Window — these are hidden, skip
|
||||
continue;
|
||||
} else {
|
||||
raw_line - offset
|
||||
|
||||
@@ -6,12 +6,12 @@ use ratatui::widgets::{Block, Borders, Paragraph, Row, Table};
|
||||
use ratatui::Frame;
|
||||
|
||||
use crate::app::App;
|
||||
use crate::state::{DeviceKind, EngineSection, SettingKind};
|
||||
use crate::engine::LinkState;
|
||||
use crate::midi;
|
||||
use crate::state::{DeviceKind, EngineSection, LinkSetting, SettingKind};
|
||||
use crate::theme;
|
||||
use crate::state::{ScopeMode, SpectrumMode};
|
||||
use crate::widgets::{
|
||||
render_scroll_indicators, render_section_header, IndicatorAlign, Lissajous, Orientation, Scope,
|
||||
Spectrum, SpectrumStyle, Waveform,
|
||||
render_scroll_indicators, render_section_header, IndicatorAlign, Scope,
|
||||
};
|
||||
|
||||
pub fn layout(area: Rect) -> [Rect; 3] {
|
||||
@@ -23,14 +23,112 @@ pub fn layout(area: Rect) -> [Rect; 3] {
|
||||
.areas(area)
|
||||
}
|
||||
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [left_col, _, right_col] = layout(area);
|
||||
|
||||
render_settings_section(frame, app, left_col);
|
||||
render_visualizers(frame, app, right_col);
|
||||
fn is_narrow(area: Rect) -> bool {
|
||||
area.width < 100
|
||||
}
|
||||
|
||||
fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
if is_narrow(area) {
|
||||
render_narrow(frame, app, link, area);
|
||||
} else {
|
||||
render_wide(frame, app, link, area);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_wide(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let [left_col, _, right_col] = layout(area);
|
||||
render_config_column(frame, app, link, left_col);
|
||||
render_monitoring_column(frame, app, link, right_col);
|
||||
}
|
||||
|
||||
fn render_narrow(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Engine ")
|
||||
.border_style(Style::new().fg(theme.engine.border_magenta));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let padded = Rect {
|
||||
x: inner.x + 1,
|
||||
y: inner.y,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height,
|
||||
};
|
||||
|
||||
if padded.height < 2 {
|
||||
return;
|
||||
}
|
||||
|
||||
// Compact metrics banner at top
|
||||
let [banner_area, _, content_area] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(padded);
|
||||
|
||||
render_compact_banner(frame, app, banner_area);
|
||||
render_config_sections(frame, app, link, content_area);
|
||||
}
|
||||
|
||||
fn render_compact_banner(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
|
||||
let voices = app.metrics.active_voices;
|
||||
let max_voices = app.audio.config.max_voices;
|
||||
|
||||
let left_db = amplitude_to_db(app.metrics.peak_left);
|
||||
let right_db = amplitude_to_db(app.metrics.peak_right);
|
||||
|
||||
let left_bar = mini_meter(app.metrics.peak_left, 8);
|
||||
let right_bar = mini_meter(app.metrics.peak_right, 8);
|
||||
|
||||
let meter_color = Style::new().fg(theme.meter.low);
|
||||
let dim = Style::new().fg(theme.engine.dim);
|
||||
let cpu_color = cpu_style(cpu_pct, &theme);
|
||||
|
||||
let line = Line::from(vec![
|
||||
Span::styled("L:", dim),
|
||||
Span::styled(left_bar, meter_color),
|
||||
Span::styled(format!("{left_db:+.0}"), dim),
|
||||
Span::styled(" R:", dim),
|
||||
Span::styled(right_bar, meter_color),
|
||||
Span::styled(format!("{right_db:+.0}"), dim),
|
||||
Span::styled(format!(" CPU {cpu_pct:.0}%"), cpu_color),
|
||||
Span::styled(format!(" V:{voices}/{max_voices}"), dim),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
|
||||
fn mini_meter(amplitude: f32, width: usize) -> String {
|
||||
let db = amplitude_to_db(amplitude);
|
||||
let norm = ((db + 48.0) / 51.0).clamp(0.0, 1.0);
|
||||
let filled = (norm * width as f32).round() as usize;
|
||||
let empty = width.saturating_sub(filled);
|
||||
format!("{}{}", "\u{2588}".repeat(filled), "\u{2591}".repeat(empty))
|
||||
}
|
||||
|
||||
fn amplitude_to_db(amp: f32) -> f32 {
|
||||
if amp <= 0.0 {
|
||||
-48.0
|
||||
} else {
|
||||
(20.0 * amp.log10()).clamp(-48.0, 3.0)
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_style(pct: f32, theme: &theme::ThemeColors) -> Style {
|
||||
if pct >= 80.0 {
|
||||
Style::new().fg(theme.flash.error_fg)
|
||||
} else if pct >= 50.0 {
|
||||
Style::new().fg(theme.ui.accent)
|
||||
} else {
|
||||
Style::new().fg(theme.engine.dim)
|
||||
}
|
||||
}
|
||||
|
||||
fn render_config_column(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
@@ -47,31 +145,75 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
height: inner.height.saturating_sub(1),
|
||||
};
|
||||
|
||||
// Calculate section heights
|
||||
let intro_lines: usize = 3;
|
||||
render_config_sections(frame, app, link, padded);
|
||||
}
|
||||
|
||||
fn render_config_sections(frame: &mut Frame, app: &App, link: &LinkState, padded: Rect) {
|
||||
let theme = theme::get();
|
||||
let plugin_mode = app.plugin_mode;
|
||||
let devices_lines = if plugin_mode {
|
||||
0
|
||||
} else {
|
||||
devices_section_height(app) as usize
|
||||
};
|
||||
let settings_lines: usize = if plugin_mode { 5 } else { 8 }; // plugin: header(1) + divider(1) + 3 rows
|
||||
let sample_content = app.audio.config.sample_paths.len().max(2); // at least 2 for empty message
|
||||
let samples_lines: usize = 2 + sample_content; // header(2) + content
|
||||
|
||||
let sections_gap = if plugin_mode { 1 } else { 2 }; // 1 gap without devices, 2 gaps with
|
||||
let total_lines = intro_lines + 1 + devices_lines + settings_lines + samples_lines + sections_gap;
|
||||
// Build section list with heights
|
||||
struct Section {
|
||||
kind: EngineSection,
|
||||
height: usize,
|
||||
}
|
||||
|
||||
let mut sections: Vec<Section> = Vec::new();
|
||||
|
||||
if !plugin_mode {
|
||||
sections.push(Section {
|
||||
kind: EngineSection::Devices,
|
||||
height: devices_section_height(app) as usize,
|
||||
});
|
||||
}
|
||||
|
||||
let settings_rows = if plugin_mode { 4 } else { 6 };
|
||||
sections.push(Section {
|
||||
kind: EngineSection::Settings,
|
||||
height: 2 + settings_rows,
|
||||
});
|
||||
|
||||
if !plugin_mode {
|
||||
sections.push(Section {
|
||||
kind: EngineSection::Link,
|
||||
height: 2 + 3 + 1 + 3, // header + 3 settings + blank + 3 session readouts
|
||||
});
|
||||
sections.push(Section {
|
||||
kind: EngineSection::MidiOutput,
|
||||
height: 2 + 4,
|
||||
});
|
||||
sections.push(Section {
|
||||
kind: EngineSection::MidiInput,
|
||||
height: 2 + 4,
|
||||
});
|
||||
}
|
||||
|
||||
let sample_content = app.audio.config.sample_paths.len().max(2);
|
||||
sections.push(Section {
|
||||
kind: EngineSection::Samples,
|
||||
height: 2 + sample_content,
|
||||
});
|
||||
|
||||
let gap = 1usize;
|
||||
let total_lines: usize =
|
||||
sections.iter().map(|s| s.height).sum::<usize>() + gap * sections.len().saturating_sub(1);
|
||||
let max_visible = padded.height as usize;
|
||||
|
||||
// Calculate scroll offset based on focused section
|
||||
let intro_offset = intro_lines + 1;
|
||||
let settings_start = if plugin_mode { intro_offset } else { intro_offset + devices_lines + 1 };
|
||||
let (focus_start, focus_height) = match app.audio.section {
|
||||
EngineSection::Devices => (intro_offset, devices_lines),
|
||||
EngineSection::Settings => (settings_start, settings_lines),
|
||||
EngineSection::Samples => (settings_start + settings_lines + 1, samples_lines),
|
||||
};
|
||||
// Find focused section offset
|
||||
let mut focus_start = 0usize;
|
||||
let mut focus_height = 0usize;
|
||||
let mut offset = 0usize;
|
||||
for (i, sec) in sections.iter().enumerate() {
|
||||
if sec.kind == app.audio.section {
|
||||
focus_start = offset;
|
||||
focus_height = sec.height;
|
||||
break;
|
||||
}
|
||||
offset += sec.height;
|
||||
if i + 1 < sections.len() {
|
||||
offset += gap;
|
||||
}
|
||||
}
|
||||
|
||||
let scroll_offset = if total_lines <= max_visible {
|
||||
0
|
||||
@@ -86,82 +228,37 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let viewport_top = padded.y as i32;
|
||||
let viewport_bottom = (padded.y + padded.height) as i32;
|
||||
|
||||
let mut y = viewport_top - scroll_offset as i32;
|
||||
|
||||
// Intro text
|
||||
let intro_top = y;
|
||||
let intro_bottom = y + intro_lines as i32;
|
||||
if intro_bottom > viewport_top && intro_top < viewport_bottom {
|
||||
let clipped_y = intro_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(intro_bottom.min(viewport_bottom) - intro_top.max(viewport_top)) as u16;
|
||||
let intro_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
let dim = Style::new().fg(theme.engine.dim);
|
||||
let intro = Paragraph::new(vec![
|
||||
Line::from(Span::styled(" Audio devices, settings, and sample paths.", dim)),
|
||||
Line::from(Span::styled(" Supports .wav, .ogg, .mp3 samples and .sf2 soundfonts.", dim)),
|
||||
Line::from(Span::styled(" Press R to restart the audio engine after changes.", dim)),
|
||||
]);
|
||||
frame.render_widget(intro, intro_area);
|
||||
}
|
||||
y += intro_lines as i32 + 1;
|
||||
for (i, sec) in sections.iter().enumerate() {
|
||||
let sec_top = y;
|
||||
let sec_bottom = y + sec.height as i32;
|
||||
|
||||
// Devices section (skip in plugin mode)
|
||||
if !plugin_mode {
|
||||
let devices_top = y;
|
||||
let devices_bottom = y + devices_lines as i32;
|
||||
if devices_bottom > viewport_top && devices_top < viewport_bottom {
|
||||
let clipped_y = devices_top.max(viewport_top) as u16;
|
||||
if sec_bottom > viewport_top && sec_top < viewport_bottom {
|
||||
let clipped_y = sec_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(devices_bottom.min(viewport_bottom) - devices_top.max(viewport_top)) as u16;
|
||||
let devices_area = Rect {
|
||||
(sec_bottom.min(viewport_bottom) - sec_top.max(viewport_top)) as u16;
|
||||
let sec_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
render_devices(frame, app, devices_area);
|
||||
|
||||
match sec.kind {
|
||||
EngineSection::Devices => render_devices(frame, app, sec_area),
|
||||
EngineSection::Settings => render_settings(frame, app, sec_area),
|
||||
EngineSection::Link => render_link(frame, app, link, sec_area),
|
||||
EngineSection::MidiOutput => render_midi_output(frame, app, sec_area),
|
||||
EngineSection::MidiInput => render_midi_input(frame, app, sec_area),
|
||||
EngineSection::Samples => render_samples(frame, app, sec_area),
|
||||
}
|
||||
y += devices_lines as i32 + 1;
|
||||
}
|
||||
|
||||
// Settings section
|
||||
let settings_top = y;
|
||||
let settings_bottom = y + settings_lines as i32;
|
||||
if settings_bottom > viewport_top && settings_top < viewport_bottom {
|
||||
let clipped_y = settings_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(settings_bottom.min(viewport_bottom) - settings_top.max(viewport_top)) as u16;
|
||||
let settings_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
render_settings(frame, app, settings_area);
|
||||
y += sec.height as i32;
|
||||
if i + 1 < sections.len() {
|
||||
y += gap as i32;
|
||||
}
|
||||
y += settings_lines as i32 + 1;
|
||||
|
||||
// Samples section
|
||||
let samples_top = y;
|
||||
let samples_bottom = y + samples_lines as i32;
|
||||
if samples_bottom > viewport_top && samples_top < viewport_bottom {
|
||||
let clipped_y = samples_top.max(viewport_top) as u16;
|
||||
let clipped_height =
|
||||
(samples_bottom.min(viewport_bottom) - samples_top.max(viewport_top)) as u16;
|
||||
let samples_area = Rect {
|
||||
x: padded.x,
|
||||
y: clipped_y,
|
||||
width: padded.width,
|
||||
height: clipped_height,
|
||||
};
|
||||
render_samples(frame, app, samples_area);
|
||||
}
|
||||
|
||||
render_scroll_indicators(
|
||||
@@ -175,32 +272,283 @@ fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) {
|
||||
);
|
||||
}
|
||||
|
||||
fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let [scope_area, _, lissajous_area, _gap, spectrum_area] = Layout::vertical([
|
||||
Constraint::Fill(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
fn render_monitoring_column(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let theme = theme::get();
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Metrics ")
|
||||
.border_style(Style::new().fg(theme.engine.border_green));
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
let area = Rect {
|
||||
x: inner.x + 1,
|
||||
y: inner.y + 1,
|
||||
width: inner.width.saturating_sub(2),
|
||||
height: inner.height.saturating_sub(1),
|
||||
};
|
||||
|
||||
// Error display takes priority at top
|
||||
let (error_height, content_area) = if let Some(ref err) = app.audio.error {
|
||||
let err_h = 2u16;
|
||||
let [err_area, _, rest] = Layout::vertical([
|
||||
Constraint::Length(err_h),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(area);
|
||||
let err_style = Style::new().fg(theme.flash.error_fg);
|
||||
let truncated = if err.len() > area.width as usize * 2 {
|
||||
format!("{}...", &err[..area.width as usize * 2 - 3])
|
||||
} else {
|
||||
err.clone()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
"ERROR",
|
||||
err_style.add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(Span::styled(truncated, err_style)),
|
||||
]),
|
||||
err_area,
|
||||
);
|
||||
(err_h, rest)
|
||||
} else {
|
||||
(0, area)
|
||||
};
|
||||
let _ = error_height;
|
||||
|
||||
render_scope(frame, app, scope_area);
|
||||
render_lissajous(frame, app, lissajous_area);
|
||||
render_spectrum(frame, app, spectrum_area);
|
||||
// Split: VU meters (3 rows) + gap + STATUS block + gap + Scope (fill)
|
||||
let [vu_area, _, status_area, _, scope_area] = Layout::vertical([
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(9),
|
||||
Constraint::Length(1),
|
||||
Constraint::Fill(1),
|
||||
])
|
||||
.areas(content_area);
|
||||
|
||||
// VU meters
|
||||
render_vu_meters(frame, app, vu_area);
|
||||
|
||||
// Status metrics
|
||||
render_status(frame, app, link, status_area);
|
||||
|
||||
// Scope
|
||||
render_compact_scope(frame, app, scope_area);
|
||||
}
|
||||
|
||||
fn viz_gain(data: &[f32], config: &crate::state::audio::AudioConfig) -> f32 {
|
||||
if config.normalize_viz {
|
||||
let peak = data.iter().fold(0.0_f32, |m, s| m.max(s.abs()));
|
||||
if peak > 0.0001 { 1.0 / peak } else { 1.0 }
|
||||
} else {
|
||||
config.gain_boost
|
||||
fn render_vu_meters(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
|
||||
let left_db = amplitude_to_db(app.metrics.peak_left);
|
||||
let right_db = amplitude_to_db(app.metrics.peak_right);
|
||||
|
||||
// Row 0: label + horizontal bar + dB reading
|
||||
let label_w = 3u16;
|
||||
let db_w = 10u16;
|
||||
let bar_w = area.width.saturating_sub(label_w + db_w);
|
||||
|
||||
let label_style = Style::new().fg(theme.engine.label);
|
||||
let dim = Style::new().fg(theme.engine.dim);
|
||||
|
||||
// Left channel
|
||||
if area.height >= 1 {
|
||||
let y = area.y;
|
||||
let mut spans = vec![Span::styled(" L ", label_style)];
|
||||
spans.extend(horizontal_meter_spans(app.metrics.peak_left, bar_w as usize, &theme));
|
||||
spans.push(Span::styled(format!(" {left_db:+.1} dB"), dim));
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(spans)),
|
||||
Rect::new(area.x, y, area.width, 1),
|
||||
);
|
||||
}
|
||||
|
||||
// Right channel
|
||||
if area.height >= 3 {
|
||||
let y = area.y + 2;
|
||||
let mut spans = vec![Span::styled(" R ", label_style)];
|
||||
spans.extend(horizontal_meter_spans(app.metrics.peak_right, bar_w as usize, &theme));
|
||||
spans.push(Span::styled(format!(" {right_db:+.1} dB"), dim));
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(spans)),
|
||||
Rect::new(area.x, y, area.width, 1),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
|
||||
fn horizontal_meter_spans(amplitude: f32, width: usize, theme: &theme::ThemeColors) -> Vec<Span<'static>> {
|
||||
let db = amplitude_to_db(amplitude);
|
||||
let norm = ((db + 48.0) / 51.0).clamp(0.0, 1.0);
|
||||
let filled = (norm * width as f32).round() as usize;
|
||||
let empty = width.saturating_sub(filled);
|
||||
|
||||
let mut spans = Vec::new();
|
||||
|
||||
let green_end = (width as f32 * 0.75).round() as usize;
|
||||
let yellow_end = (width as f32 * 0.9).round() as usize;
|
||||
|
||||
let green_chars = filled.min(green_end);
|
||||
let yellow_chars = if filled > green_end {
|
||||
(filled - green_end).min(yellow_end - green_end)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let red_chars = filled.saturating_sub(yellow_end);
|
||||
|
||||
if green_chars > 0 {
|
||||
spans.push(Span::styled(
|
||||
"\u{2588}".repeat(green_chars),
|
||||
Style::new().fg(theme.meter.low),
|
||||
));
|
||||
}
|
||||
if yellow_chars > 0 {
|
||||
spans.push(Span::styled(
|
||||
"\u{2588}".repeat(yellow_chars),
|
||||
Style::new().fg(theme.meter.mid),
|
||||
));
|
||||
}
|
||||
if red_chars > 0 {
|
||||
spans.push(Span::styled(
|
||||
"\u{2588}".repeat(red_chars),
|
||||
Style::new().fg(theme.meter.high),
|
||||
));
|
||||
}
|
||||
if empty > 0 {
|
||||
spans.push(Span::styled(
|
||||
"\u{2591}".repeat(empty),
|
||||
Style::new().fg(theme.engine.dim),
|
||||
));
|
||||
}
|
||||
|
||||
spans
|
||||
}
|
||||
|
||||
fn render_status(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let section_header_style = Style::new()
|
||||
.fg(theme.engine.header)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let label_style = Style::new().fg(theme.engine.label);
|
||||
let value_style = Style::new().fg(theme.engine.value);
|
||||
|
||||
let cpu_pct = (app.metrics.cpu_load * 100.0).min(100.0);
|
||||
let cpu_bar_w = 12usize;
|
||||
let cpu_filled = (cpu_pct / 100.0 * cpu_bar_w as f32).round() as usize;
|
||||
let cpu_empty = cpu_bar_w.saturating_sub(cpu_filled);
|
||||
let cpu_bar_color = if cpu_pct >= 80.0 {
|
||||
theme.flash.error_fg
|
||||
} else if cpu_pct >= 50.0 {
|
||||
theme.ui.accent
|
||||
} else {
|
||||
theme.meter.low
|
||||
};
|
||||
|
||||
let voices = app.metrics.active_voices;
|
||||
let max_voices = app.audio.config.max_voices;
|
||||
let peak_voices = app.metrics.peak_voices;
|
||||
let events = app.metrics.event_count;
|
||||
let depth = app.metrics.schedule_depth;
|
||||
let nudge_ms = app.metrics.nudge_ms;
|
||||
let sample_rate = app.audio.config.sample_rate;
|
||||
let host = if app.audio.config.host_name.is_empty() {
|
||||
"-"
|
||||
} else {
|
||||
&app.audio.config.host_name
|
||||
};
|
||||
let peers = link.peers();
|
||||
|
||||
let nudge_label = if nudge_ms == 0.0 {
|
||||
"0 ms".to_string()
|
||||
} else {
|
||||
format!("{nudge_ms:+.1} ms")
|
||||
};
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
lines.push(Line::from(Span::styled("STATUS", section_header_style)));
|
||||
lines.push(Line::from(Span::styled(
|
||||
"\u{2500}".repeat(area.width as usize),
|
||||
Style::new().fg(theme.engine.divider),
|
||||
)));
|
||||
|
||||
// CPU
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!(" CPU {cpu_pct:>3.0}% "), label_style),
|
||||
Span::styled(
|
||||
"\u{2588}".repeat(cpu_filled),
|
||||
Style::new().fg(cpu_bar_color),
|
||||
),
|
||||
Span::styled(
|
||||
"\u{2591}".repeat(cpu_empty),
|
||||
Style::new().fg(theme.engine.dim),
|
||||
),
|
||||
]));
|
||||
|
||||
// Voices
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" Voices {voices}/{max_voices}"),
|
||||
label_style,
|
||||
),
|
||||
Span::styled(format!(" (peak: {peak_voices})"), value_style),
|
||||
]));
|
||||
|
||||
// Events
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Events {events}"),
|
||||
label_style,
|
||||
)));
|
||||
|
||||
// Depth
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Depth {depth}"),
|
||||
label_style,
|
||||
)));
|
||||
|
||||
// Nudge
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Nudge {nudge_label}"),
|
||||
label_style,
|
||||
)));
|
||||
|
||||
// Sample rate
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Rate {sample_rate:.0} Hz"),
|
||||
label_style,
|
||||
)));
|
||||
|
||||
if !app.plugin_mode {
|
||||
// Host
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Host {host}"),
|
||||
label_style,
|
||||
)));
|
||||
|
||||
// Link peers
|
||||
if link.is_enabled() && peers > 0 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
format!(" Peers {peers}"),
|
||||
label_style,
|
||||
)));
|
||||
}
|
||||
}
|
||||
|
||||
let visible = lines.len().min(area.height as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines.into_iter().take(visible).collect::<Vec<_>>()),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_compact_scope(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
|
||||
if area.height < 3 {
|
||||
return;
|
||||
}
|
||||
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Scope ")
|
||||
@@ -209,81 +557,28 @@ fn render_scope(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let orientation = if app.audio.config.scope_vertical {
|
||||
Orientation::Vertical
|
||||
} else {
|
||||
Orientation::Horizontal
|
||||
};
|
||||
let gain = viz_gain(&app.metrics.scope, &app.audio.config);
|
||||
match app.audio.config.scope_mode {
|
||||
ScopeMode::Line => {
|
||||
let scope = Scope::new(&app.metrics.scope)
|
||||
.orientation(orientation)
|
||||
.color(theme.meter.low)
|
||||
.gain(gain);
|
||||
frame.render_widget(scope, inner);
|
||||
}
|
||||
ScopeMode::Filled => {
|
||||
let waveform = Waveform::new(&app.metrics.scope)
|
||||
.orientation(orientation)
|
||||
.color(theme.meter.low)
|
||||
.gain(gain);
|
||||
frame.render_widget(waveform, inner);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_lissajous(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Lissajous ")
|
||||
.border_style(Style::new().fg(theme.engine.border_green));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let peak = app.metrics.scope.iter().chain(app.metrics.scope_right.iter())
|
||||
.fold(0.0_f32, |m, s| m.max(s.abs()));
|
||||
let gain = if app.audio.config.normalize_viz {
|
||||
if peak > 0.0001 { 1.0 / peak } else { 1.0 }
|
||||
} else {
|
||||
app.audio.config.gain_boost
|
||||
};
|
||||
let lissajous = Lissajous::new(&app.metrics.scope, &app.metrics.scope_right)
|
||||
.color(theme.meter.low)
|
||||
.gain(gain)
|
||||
.trails(app.audio.config.lissajous_trails);
|
||||
frame.render_widget(lissajous, inner);
|
||||
}
|
||||
|
||||
fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.title(" Spectrum ")
|
||||
.border_style(Style::new().fg(theme.engine.border_cyan));
|
||||
|
||||
let inner = block.inner(area);
|
||||
frame.render_widget(block, area);
|
||||
|
||||
let gain = if app.audio.config.normalize_viz {
|
||||
viz_gain(&app.metrics.spectrum, &app.audio.config)
|
||||
fn viz_gain(data: &[f32], config: &crate::state::audio::AudioConfig) -> f32 {
|
||||
if config.normalize_viz {
|
||||
let peak = data.iter().fold(0.0_f32, |m, s| m.max(s.abs()));
|
||||
if peak > 0.0001 {
|
||||
1.0 / peak
|
||||
} else {
|
||||
1.0
|
||||
};
|
||||
let style = match app.audio.config.spectrum_mode {
|
||||
SpectrumMode::Bars => SpectrumStyle::Bars,
|
||||
SpectrumMode::Line => SpectrumStyle::Line,
|
||||
SpectrumMode::Filled => SpectrumStyle::Filled,
|
||||
};
|
||||
let spectrum = Spectrum::new(&app.metrics.spectrum)
|
||||
.gain(gain)
|
||||
.style(style)
|
||||
.peaks(app.audio.config.spectrum_peaks);
|
||||
frame.render_widget(spectrum, inner);
|
||||
}
|
||||
} else {
|
||||
config.gain_boost
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config sections ---
|
||||
|
||||
fn truncate_name(name: &str, max_len: usize) -> String {
|
||||
if name.len() > max_len {
|
||||
format!("{}...", &name[..max_len.saturating_sub(3)])
|
||||
@@ -340,7 +635,7 @@ fn render_devices(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let sep_style = Style::new().fg(theme.engine.separator);
|
||||
let sep_lines: Vec<Line> = (0..separator.height)
|
||||
.map(|_| Line::from(Span::styled("│", sep_style)))
|
||||
.map(|_| Line::from(Span::styled("\u{2502}", sep_style)))
|
||||
.collect();
|
||||
frame.render_widget(Paragraph::new(sep_lines), separator);
|
||||
|
||||
@@ -411,7 +706,6 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal = Style::new().fg(theme.engine.normal);
|
||||
let label_style = Style::new().fg(theme.engine.label);
|
||||
let value_style = Style::new().fg(theme.engine.value);
|
||||
|
||||
let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony;
|
||||
let nudge_focused = section_focused && app.audio.setting_kind == SettingKind::Nudge;
|
||||
@@ -491,32 +785,233 @@ fn render_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||
),
|
||||
render_selector(&nudge_label, nudge_focused, highlight, normal),
|
||||
]));
|
||||
rows.push(Row::new(vec![
|
||||
Span::styled(" Sample rate", label_style),
|
||||
Span::styled(
|
||||
format!("{:.0} Hz", app.audio.config.sample_rate),
|
||||
value_style,
|
||||
),
|
||||
]));
|
||||
|
||||
if !app.plugin_mode {
|
||||
rows.push(Row::new(vec![
|
||||
Span::styled(" Audio host", label_style),
|
||||
Span::styled(
|
||||
if app.audio.config.host_name.is_empty() {
|
||||
"-".to_string()
|
||||
} else {
|
||||
app.audio.config.host_name.clone()
|
||||
},
|
||||
value_style,
|
||||
),
|
||||
]));
|
||||
}
|
||||
|
||||
let table = Table::new(rows, [Constraint::Length(14), Constraint::Fill(1)]);
|
||||
frame.render_widget(table, content_area);
|
||||
}
|
||||
|
||||
fn render_link(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let section_focused = app.audio.section == EngineSection::Link;
|
||||
|
||||
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 [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
|
||||
|
||||
// Custom header with status badge
|
||||
let header_style = if section_focused {
|
||||
Style::new()
|
||||
.fg(theme.engine.header_focused)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::new()
|
||||
.fg(theme.engine.header)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
};
|
||||
|
||||
let [title_row, divider_row] =
|
||||
Layout::vertical([Constraint::Length(1), Constraint::Length(1)]).areas(header_area);
|
||||
|
||||
let header_line = Line::from(vec![
|
||||
Span::styled("ABLETON LINK", header_style),
|
||||
Span::raw(" "),
|
||||
Span::styled(
|
||||
status_text,
|
||||
Style::new().fg(status_color).add_modifier(Modifier::BOLD),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(header_line), title_row);
|
||||
frame.render_widget(
|
||||
Paragraph::new("\u{2500}".repeat(area.width as usize))
|
||||
.style(Style::new().fg(theme.engine.divider)),
|
||||
divider_row,
|
||||
);
|
||||
|
||||
let highlight = Style::new()
|
||||
.fg(theme.engine.focused)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
let normal = Style::new().fg(theme.engine.normal);
|
||||
let label_style = Style::new().fg(theme.engine.label);
|
||||
let value_style = Style::new().fg(theme.engine.value);
|
||||
|
||||
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 enabled_focused = section_focused && app.audio.link_setting == LinkSetting::Enabled;
|
||||
let sss_focused = section_focused && app.audio.link_setting == LinkSetting::StartStopSync;
|
||||
let quantum_focused = section_focused && app.audio.link_setting == LinkSetting::Quantum;
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
if enabled_focused {
|
||||
"> Enabled "
|
||||
} else {
|
||||
" Enabled "
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(
|
||||
if enabled { "On" } else { "Off" },
|
||||
enabled_focused,
|
||||
highlight,
|
||||
normal,
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
if sss_focused {
|
||||
"> Start/Stop "
|
||||
} else {
|
||||
" Start/Stop "
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(
|
||||
if link.is_start_stop_sync_enabled() {
|
||||
"On"
|
||||
} else {
|
||||
"Off"
|
||||
},
|
||||
sss_focused,
|
||||
highlight,
|
||||
normal,
|
||||
),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(
|
||||
if quantum_focused {
|
||||
"> Quantum "
|
||||
} else {
|
||||
" Quantum "
|
||||
},
|
||||
label_style,
|
||||
),
|
||||
render_selector(&quantum_str, quantum_focused, highlight, normal),
|
||||
]));
|
||||
|
||||
// Session readouts
|
||||
let tempo_style = Style::new()
|
||||
.fg(theme.values.tempo)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
lines.push(Line::from(""));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Tempo ", label_style),
|
||||
Span::styled(format!(" {tempo_str}"), tempo_style),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Beat ", label_style),
|
||||
Span::styled(format!(" {beat_str}"), value_style),
|
||||
]));
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(" Phase ", label_style),
|
||||
Span::styled(format!(" {phase_str}"), value_style),
|
||||
]));
|
||||
|
||||
let visible = lines.len().min(content_area.height as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines.into_iter().take(visible).collect::<Vec<_>>()),
|
||||
content_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_midi_output(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let section_focused = app.audio.section == EngineSection::MidiOutput;
|
||||
|
||||
let [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
|
||||
|
||||
render_section_header(frame, "MIDI OUTPUTS", section_focused, header_area);
|
||||
|
||||
let midi_outputs = midi::list_midi_outputs();
|
||||
let label_style = Style::new().fg(theme.engine.label);
|
||||
let value_style = Style::new().fg(theme.engine.normal);
|
||||
let highlight = Style::new()
|
||||
.fg(theme.engine.focused)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for slot in 0..4 {
|
||||
let is_focused = section_focused && app.audio.midi_output_slot == slot;
|
||||
let display = midi_display_name(&midi_outputs, app.midi.selected_outputs[slot]);
|
||||
let prefix = if is_focused { "> " } else { " " };
|
||||
let style = if is_focused { highlight } else { label_style };
|
||||
let val_style = if is_focused { highlight } else { value_style };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{prefix}Output {slot} "), style),
|
||||
Span::styled(display, val_style),
|
||||
]));
|
||||
}
|
||||
|
||||
let visible = lines.len().min(content_area.height as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines.into_iter().take(visible).collect::<Vec<_>>()),
|
||||
content_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_midi_input(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let section_focused = app.audio.section == EngineSection::MidiInput;
|
||||
|
||||
let [header_area, content_area] =
|
||||
Layout::vertical([Constraint::Length(2), Constraint::Min(1)]).areas(area);
|
||||
|
||||
render_section_header(frame, "MIDI INPUTS", section_focused, header_area);
|
||||
|
||||
let midi_inputs = midi::list_midi_inputs();
|
||||
let label_style = Style::new().fg(theme.engine.label);
|
||||
let value_style = Style::new().fg(theme.engine.normal);
|
||||
let highlight = Style::new()
|
||||
.fg(theme.engine.focused)
|
||||
.add_modifier(Modifier::BOLD);
|
||||
|
||||
let mut lines: Vec<Line> = Vec::new();
|
||||
for slot in 0..4 {
|
||||
let is_focused = section_focused && app.audio.midi_input_slot == slot;
|
||||
let display = midi_display_name(&midi_inputs, app.midi.selected_inputs[slot]);
|
||||
let prefix = if is_focused { "> " } else { " " };
|
||||
let style = if is_focused { highlight } else { label_style };
|
||||
let val_style = if is_focused { highlight } else { value_style };
|
||||
lines.push(Line::from(vec![
|
||||
Span::styled(format!("{prefix}Input {slot} "), style),
|
||||
Span::styled(display, val_style),
|
||||
]));
|
||||
}
|
||||
|
||||
let visible = lines.len().min(content_area.height as usize);
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines.into_iter().take(visible).collect::<Vec<_>>()),
|
||||
content_area,
|
||||
);
|
||||
}
|
||||
|
||||
fn midi_display_name(devices: &[midi::MidiDeviceInfo], selected: Option<usize>) -> String {
|
||||
if let Some(idx) = selected {
|
||||
devices
|
||||
.get(idx)
|
||||
.map(|d| d.name.clone())
|
||||
.unwrap_or_else(|| "(disconnected)".to_string())
|
||||
} else if devices.is_empty() {
|
||||
"(none found)".to_string()
|
||||
} else {
|
||||
"(not connected)".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
let section_focused = app.audio.section == EngineSection::Samples;
|
||||
@@ -529,7 +1024,7 @@ fn render_samples(frame: &mut Frame, app: &App, area: Rect) {
|
||||
|
||||
let path_count = app.audio.config.sample_paths.len();
|
||||
let sample_count: usize = app.audio.total_sample_count();
|
||||
let header_text = format!("SAMPLES {path_count} paths · {sample_count} indexed");
|
||||
let header_text = format!("SAMPLES {path_count} paths \u{00b7} {sample_count} indexed");
|
||||
render_section_header(frame, &header_text, section_focused, header_area);
|
||||
|
||||
let dim = Style::new().fg(theme.engine.dim);
|
||||
|
||||
@@ -5,13 +5,11 @@ 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) {
|
||||
pub fn render(frame: &mut Frame, app: &App, area: Rect) {
|
||||
let theme = theme::get();
|
||||
|
||||
let block = Block::default()
|
||||
@@ -141,126 +139,7 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
&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),
|
||||
@@ -278,7 +157,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) {
|
||||
&theme,
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
||||
// Insert description below focused option
|
||||
let focus_vec_idx = focus.line_index(app.plugin_mode);
|
||||
@@ -330,7 +208,7 @@ fn render_section_header(title: &str, theme: &theme::ThemeColors) -> Line<'stati
|
||||
|
||||
fn render_divider(width: usize, theme: &theme::ThemeColors) -> Line<'static> {
|
||||
Line::from(Span::styled(
|
||||
"─".repeat(width),
|
||||
"\u{2500}".repeat(width),
|
||||
Style::new().fg(theme.ui.border),
|
||||
))
|
||||
}
|
||||
@@ -377,17 +255,6 @@ fn option_description(focus: OptionsFocus) -> Option<&'static str> {
|
||||
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"),
|
||||
}
|
||||
@@ -403,15 +270,3 @@ fn render_description_line(desc: &str, theme: &ThemeColors) -> Line<'static> {
|
||||
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),
|
||||
])
|
||||
}
|
||||
|
||||
@@ -185,8 +185,8 @@ pub fn render(
|
||||
match app.page {
|
||||
Page::Main => main_view::render(frame, app, snapshot, page_area),
|
||||
Page::Patterns => patterns_view::render(frame, app, snapshot, page_area),
|
||||
Page::Engine => engine_view::render(frame, app, page_area),
|
||||
Page::Options => options_view::render(frame, app, link, page_area),
|
||||
Page::Engine => engine_view::render(frame, app, link, page_area),
|
||||
Page::Options => options_view::render(frame, app, page_area),
|
||||
Page::Help => help_view::render(frame, app, page_area),
|
||||
Page::Dict => dict_view::render(frame, app, page_area),
|
||||
Page::Script => script_view::render(frame, app, snapshot, page_area),
|
||||
@@ -557,10 +557,10 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||
],
|
||||
Page::Engine => vec![
|
||||
("Tab", "Section"),
|
||||
("←→", "Switch/Adjust"),
|
||||
("A", "Add Samples"),
|
||||
("D", "Remove"),
|
||||
("←→", "Adjust"),
|
||||
("R", "Restart"),
|
||||
("t", "Test"),
|
||||
("h/p", "Hush/Panic"),
|
||||
("?", "Keys"),
|
||||
],
|
||||
Page::Options => vec![
|
||||
|
||||
Reference in New Issue
Block a user