diff --git a/src/app/dispatch.rs b/src/app/dispatch.rs index 762af3a..5a25c7f 100644 --- a/src/app/dispatch.rs +++ b/src/app/dispatch.rs @@ -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(), diff --git a/src/commands.rs b/src/commands.rs index 3647568..90fa417 100644 --- a/src/commands.rs +++ b/src/commands.rs @@ -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, diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index be16e42..7295a4c 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -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 { diff --git a/src/input/mouse.rs b/src/input/mouse.rs index a95b7c3..cbd2f85 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -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); - } 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 => {} - } + if up { + ctx.dispatch(AppCommand::AudioPrevSection); + } else { + 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); - } 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; - } +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 { + 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 --- diff --git a/src/input/options_page.rs b/src/input/options_page.rs index 01cc145..ac75782 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -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); } diff --git a/src/state/audio.rs b/src/state/audio.rs index aba0317..067a33e 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -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, pub input_devices: Vec, 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 diff --git a/src/state/mod.rs b/src/state/mod.rs index a22590a..2c9122d 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -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, diff --git a/src/state/options.rs b/src/state/options.rs index 29dd3f0..5077900 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -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 diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs index 6eb599a..ffc43f8 100644 --- a/src/views/engine_view.rs +++ b/src/views/engine_view.rs @@ -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
= 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::() + 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 += 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); + y += sec.height as i32; + if i + 1 < sections.len() { + y += gap as i32; + } } 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), +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; + + // 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::Fill(1), + Constraint::Length(9), Constraint::Length(1), Constraint::Fill(1), ]) - .areas(area); + .areas(content_area); - render_scope(frame, app, scope_area); - render_lissajous(frame, app, lissajous_area); - render_spectrum(frame, app, spectrum_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> { + 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 = 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::>()), + 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,80 +557,27 @@ 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); + let scope = Scope::new(&app.metrics.scope) + .color(theme.meter.low) + .gain(gain); + frame.render_widget(scope, inner); +} + +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_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) - } 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); -} +// --- Config sections --- fn truncate_name(name: &str, max_len: usize) -> String { if name.len() > max_len { @@ -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 = (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 = 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::>()), + 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 = 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::>()), + 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 = 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::>()), + content_area, + ); +} + +fn midi_display_name(devices: &[midi::MidiDeviceInfo], selected: Option) -> 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); diff --git a/src/views/options_view.rs b/src/views/options_view.rs index f03bc72..f0853a9 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -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,144 +139,24 @@ 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), - 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, - ), - ]); - } + 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); @@ -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: 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![