diff --git a/crates/ratatui/src/list_select.rs b/crates/ratatui/src/list_select.rs index 297498f..2d4a527 100644 --- a/crates/ratatui/src/list_select.rs +++ b/crates/ratatui/src/list_select.rs @@ -74,7 +74,7 @@ impl<'a> ListSelect<'a> { normal_style }; - let prefix = if is_selected { "● " } else { " " }; + let prefix = if is_selected { "x " } else { " " }; let mut spans = vec![ Span::styled(prefix.to_string(), style), Span::styled(name.clone(), style), diff --git a/src/app.rs b/src/app.rs index 513a4ed..c057e65 100644 --- a/src/app.rs +++ b/src/app.rs @@ -16,7 +16,7 @@ use crate::services::pattern_editor; use crate::settings::Settings; use crate::state::{ AudioSettings, DictFocus, EditorContext, FlashKind, Focus, LiveKeyState, Metrics, Modal, - PanelState, PatternField, PatternsNav, PlaybackState, ProjectState, UiState, + OptionsState, PanelState, PatternField, PatternsNav, PlaybackState, ProjectState, UiState, }; use crate::views::{dict_view, help_view}; @@ -43,6 +43,7 @@ pub struct App { pub copied_bank: Option, pub audio: AudioSettings, + pub options: OptionsState, pub panel: PanelState, } @@ -75,6 +76,7 @@ impl App { copied_bank: None, audio: AudioSettings::default(), + options: OptionsState::default(), panel: PanelState::default(), } } diff --git a/src/input.rs b/src/input.rs index 17c7bbc..0029a3d 100644 --- a/src/input.rs +++ b/src/input.rs @@ -9,7 +9,7 @@ use crate::commands::AppCommand; use crate::engine::{AudioCommand, LinkState, SequencerSnapshot}; use crate::model::PatternSpeed; use crate::page::Page; -use crate::state::{AudioFocus, Modal, PanelFocus, PatternField, SampleBrowserState, SidePanel}; +use crate::state::{DeviceKind, EngineSection, Modal, OptionsFocus, PanelFocus, PatternField, SampleBrowserState, SettingKind, SidePanel}; pub enum InputResult { Continue, @@ -453,7 +453,8 @@ fn handle_normal_input(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match ctx.app.page { Page::Main => handle_main_page(ctx, key, ctrl), Page::Patterns => handle_patterns_page(ctx, key), - Page::Audio => handle_audio_page(ctx, key), + Page::Engine => handle_engine_page(ctx, key), + Page::Options => handle_options_page(ctx, key), Page::Help => handle_help_page(ctx, key), Page::Dict => handle_dict_page(ctx, key), } @@ -719,132 +720,113 @@ fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { InputResult::Continue } -fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { +fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { match key.code { KeyCode::Char('q') => { ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { selected: false, })); } - KeyCode::Tab => ctx.app.audio.next_focus(), - KeyCode::BackTab => ctx.app.audio.prev_focus(), - KeyCode::Up => match ctx.app.audio.focus { - AudioFocus::OutputDevice => ctx.app.audio.output_list.move_up(), - AudioFocus::InputDevice => ctx.app.audio.input_list.move_up(), - _ => {} + KeyCode::Tab => ctx.app.audio.next_section(), + KeyCode::BackTab => ctx.app.audio.prev_section(), + KeyCode::Up => match ctx.app.audio.section { + EngineSection::Devices => match ctx.app.audio.device_kind { + DeviceKind::Output => ctx.app.audio.output_list.move_up(), + DeviceKind::Input => ctx.app.audio.input_list.move_up(), + }, + EngineSection::Settings => { + ctx.app.audio.setting_kind = ctx.app.audio.setting_kind.prev(); + } + EngineSection::Samples => {} }, - KeyCode::Down => match ctx.app.audio.focus { - AudioFocus::OutputDevice => { - let count = ctx.app.audio.output_devices.len(); - ctx.app.audio.output_list.move_down(count); + KeyCode::Down => match ctx.app.audio.section { + EngineSection::Devices => match ctx.app.audio.device_kind { + DeviceKind::Output => { + let count = ctx.app.audio.output_devices.len(); + ctx.app.audio.output_list.move_down(count); + } + DeviceKind::Input => { + let count = ctx.app.audio.input_devices.len(); + ctx.app.audio.input_list.move_down(count); + } + }, + EngineSection::Settings => { + ctx.app.audio.setting_kind = ctx.app.audio.setting_kind.next(); } - AudioFocus::InputDevice => { - let count = ctx.app.audio.input_devices.len(); - ctx.app.audio.input_list.move_down(count); - } - _ => {} + EngineSection::Samples => {} }, - KeyCode::PageUp => match ctx.app.audio.focus { - AudioFocus::OutputDevice => ctx.app.audio.output_list.page_up(), - AudioFocus::InputDevice => ctx.app.audio.input_list.page_up(), - _ => {} - }, - KeyCode::PageDown => match ctx.app.audio.focus { - AudioFocus::OutputDevice => { - let count = ctx.app.audio.output_devices.len(); - ctx.app.audio.output_list.page_down(count); - } - AudioFocus::InputDevice => { - let count = ctx.app.audio.input_devices.len(); - ctx.app.audio.input_list.page_down(count); - } - _ => {} - }, - KeyCode::Enter => match ctx.app.audio.focus { - AudioFocus::OutputDevice => { - let cursor = ctx.app.audio.output_list.cursor; - if cursor < ctx.app.audio.output_devices.len() { - ctx.app.audio.config.output_device = - Some(ctx.app.audio.output_devices[cursor].name.clone()); - ctx.app.save_settings(ctx.link); + KeyCode::PageUp => { + if ctx.app.audio.section == EngineSection::Devices { + match ctx.app.audio.device_kind { + DeviceKind::Output => ctx.app.audio.output_list.page_up(), + DeviceKind::Input => ctx.app.audio.input_list.page_up(), } } - AudioFocus::InputDevice => { - let cursor = ctx.app.audio.input_list.cursor; - if cursor < ctx.app.audio.input_devices.len() { - ctx.app.audio.config.input_device = - Some(ctx.app.audio.input_devices[cursor].name.clone()); - ctx.app.save_settings(ctx.link); - } - } - _ => {} - }, - KeyCode::Left => { - match ctx.app.audio.focus { - AudioFocus::OutputDevice | AudioFocus::InputDevice => {} - AudioFocus::Channels => ctx.app.audio.adjust_channels(-1), - AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(-64), - AudioFocus::Polyphony => ctx.app.audio.adjust_max_voices(-1), - AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), - AudioFocus::RuntimeHighlight => { - ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight - } - AudioFocus::ShowScope => { - ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope; - } - AudioFocus::ShowSpectrum => { - ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum; - } - AudioFocus::ShowCompletion => { - ctx.app.ui.show_completion = !ctx.app.ui.show_completion; - } - AudioFocus::SamplePaths => ctx.app.audio.remove_last_sample_path(), - AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), - AudioFocus::StartStopSync => ctx - .link - .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), - AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() - 1.0), - } - if !matches!( - ctx.app.audio.focus, - AudioFocus::SamplePaths | AudioFocus::OutputDevice | AudioFocus::InputDevice - ) { - ctx.app.save_settings(ctx.link); - } } - KeyCode::Right => { - match ctx.app.audio.focus { - AudioFocus::OutputDevice | AudioFocus::InputDevice => {} - AudioFocus::Channels => ctx.app.audio.adjust_channels(1), - AudioFocus::BufferSize => ctx.app.audio.adjust_buffer_size(64), - AudioFocus::Polyphony => ctx.app.audio.adjust_max_voices(1), - AudioFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), - AudioFocus::RuntimeHighlight => { - ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight + KeyCode::PageDown => { + if ctx.app.audio.section == EngineSection::Devices { + match ctx.app.audio.device_kind { + DeviceKind::Output => { + let count = ctx.app.audio.output_devices.len(); + ctx.app.audio.output_list.page_down(count); + } + DeviceKind::Input => { + let count = ctx.app.audio.input_devices.len(); + ctx.app.audio.input_list.page_down(count); + } } - AudioFocus::ShowScope => { - ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope; - } - AudioFocus::ShowSpectrum => { - ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum; - } - AudioFocus::ShowCompletion => { - ctx.app.ui.show_completion = !ctx.app.ui.show_completion; - } - AudioFocus::SamplePaths => {} - AudioFocus::LinkEnabled => ctx.link.set_enabled(!ctx.link.is_enabled()), - AudioFocus::StartStopSync => ctx - .link - .set_start_stop_sync_enabled(!ctx.link.is_start_stop_sync_enabled()), - AudioFocus::Quantum => ctx.link.set_quantum(ctx.link.quantum() + 1.0), - } - if !matches!( - ctx.app.audio.focus, - AudioFocus::SamplePaths | AudioFocus::OutputDevice | AudioFocus::InputDevice - ) { - ctx.app.save_settings(ctx.link); } } + KeyCode::Enter => { + if ctx.app.audio.section == EngineSection::Devices { + match ctx.app.audio.device_kind { + DeviceKind::Output => { + let cursor = ctx.app.audio.output_list.cursor; + if cursor < ctx.app.audio.output_devices.len() { + ctx.app.audio.config.output_device = + Some(ctx.app.audio.output_devices[cursor].name.clone()); + ctx.app.save_settings(ctx.link); + } + } + DeviceKind::Input => { + let cursor = ctx.app.audio.input_list.cursor; + if cursor < ctx.app.audio.input_devices.len() { + ctx.app.audio.config.input_device = + Some(ctx.app.audio.input_devices[cursor].name.clone()); + ctx.app.save_settings(ctx.link); + } + } + } + } + } + KeyCode::Left => match ctx.app.audio.section { + EngineSection::Devices => { + ctx.app.audio.device_kind = DeviceKind::Output; + } + EngineSection::Settings => { + match ctx.app.audio.setting_kind { + SettingKind::Channels => ctx.app.audio.adjust_channels(-1), + SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(-64), + SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(-1), + } + ctx.app.save_settings(ctx.link); + } + EngineSection::Samples => {} + }, + KeyCode::Right => match ctx.app.audio.section { + EngineSection::Devices => { + ctx.app.audio.device_kind = DeviceKind::Input; + } + EngineSection::Settings => { + match ctx.app.audio.setting_kind { + SettingKind::Channels => ctx.app.audio.adjust_channels(1), + SettingKind::BufferSize => ctx.app.audio.adjust_buffer_size(64), + SettingKind::Polyphony => ctx.app.audio.adjust_max_voices(1), + } + ctx.app.save_settings(ctx.link); + } + EngineSection::Samples => {} + }, KeyCode::Char('R') => ctx.app.audio.trigger_restart(), KeyCode::Char('A') => { use crate::state::file_browser::FileBrowserState; @@ -852,12 +834,16 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { ctx.dispatch(AppCommand::OpenModal(Modal::AddSamplePath(state))); } KeyCode::Char('D') => { - ctx.app.audio.refresh_devices(); - let out_count = ctx.app.audio.output_devices.len(); - let in_count = ctx.app.audio.input_devices.len(); - ctx.dispatch(AppCommand::SetStatus(format!( - "Found {out_count} output, {in_count} input devices" - ))); + if ctx.app.audio.section == EngineSection::Samples { + ctx.app.audio.remove_last_sample_path(); + } else { + ctx.app.audio.refresh_devices(); + let out_count = ctx.app.audio.output_devices.len(); + let in_count = ctx.app.audio.input_devices.len(); + ctx.dispatch(AppCommand::SetStatus(format!( + "Found {out_count} output, {in_count} input devices" + ))); + } } KeyCode::Char('h') => { let _ = ctx.audio_tx.send(AudioCommand::Hush); @@ -871,6 +857,46 @@ fn handle_audio_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { .audio_tx .send(AudioCommand::Evaluate("/sound/sine/dur/0.5/decay/0.2".into())); } + _ => {} + } + InputResult::Continue +} + +fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> InputResult { + match key.code { + KeyCode::Char('q') => { + ctx.dispatch(AppCommand::OpenModal(Modal::ConfirmQuit { + selected: false, + })); + } + KeyCode::Down | KeyCode::Tab => ctx.app.options.next_focus(), + KeyCode::Up | KeyCode::BackTab => ctx.app.options.prev_focus(), + KeyCode::Left | KeyCode::Right => { + match ctx.app.options.focus { + OptionsFocus::RefreshRate => ctx.app.audio.toggle_refresh_rate(), + OptionsFocus::RuntimeHighlight => { + ctx.app.ui.runtime_highlight = !ctx.app.ui.runtime_highlight + } + OptionsFocus::ShowScope => { + ctx.app.audio.config.show_scope = !ctx.app.audio.config.show_scope + } + OptionsFocus::ShowSpectrum => { + ctx.app.audio.config.show_spectrum = !ctx.app.audio.config.show_spectrum + } + OptionsFocus::ShowCompletion => { + ctx.app.ui.show_completion = !ctx.app.ui.show_completion + } + 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 key.code == KeyCode::Left { -1.0 } else { 1.0 }; + ctx.link.set_quantum(ctx.link.quantum() + delta); + } + } + ctx.app.save_settings(ctx.link); + } KeyCode::Char(' ') => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing diff --git a/src/page.rs b/src/page.rs index eb58fcd..7e28560 100644 --- a/src/page.rs +++ b/src/page.rs @@ -3,9 +3,10 @@ pub enum Page { #[default] Main, Patterns, - Audio, + Engine, Help, Dict, + Options, } impl Page { @@ -13,9 +14,10 @@ impl Page { pub const ALL: &'static [Page] = &[ Page::Main, Page::Patterns, - Page::Audio, + Page::Engine, Page::Help, Page::Dict, + Page::Options, ]; /// Grid dimensions (cols, rows) @@ -24,14 +26,15 @@ impl Page { /// Grid position (col, row) for each page /// Layout: /// col 0 col 1 col 2 - /// row 0 Patterns Help - /// row 1 Dict Sequencer Audio + /// row 0 Options Patterns Help + /// row 1 Dict Sequencer Engine pub const fn grid_pos(self) -> (i8, i8) { match self { + Page::Options => (0, 0), Page::Dict => (0, 1), Page::Main => (1, 1), Page::Patterns => (1, 0), - Page::Audio => (2, 1), + Page::Engine => (2, 1), Page::Help => (2, 0), } } @@ -46,9 +49,10 @@ impl Page { match self { Page::Main => "Sequencer", Page::Patterns => "Patterns", - Page::Audio => "Audio", + Page::Engine => "Engine", Page::Help => "Help", Page::Dict => "Dict", + Page::Options => "Options", } } diff --git a/src/state/audio.rs b/src/state/audio.rs index fff5b61..a9fd9f1 100644 --- a/src/state/audio.rs +++ b/src/state/audio.rs @@ -119,22 +119,44 @@ impl ListSelectState { } #[derive(Clone, Copy, PartialEq, Eq, Default)] -pub enum AudioFocus { +pub enum EngineSection { + #[default] + Devices, + Settings, + Samples, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum DeviceKind { + #[default] + Output, + Input, +} + +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum SettingKind { #[default] - OutputDevice, - InputDevice, Channels, BufferSize, Polyphony, - RefreshRate, - RuntimeHighlight, - ShowScope, - ShowSpectrum, - ShowCompletion, - SamplePaths, - LinkEnabled, - StartStopSync, - Quantum, +} + +impl SettingKind { + pub fn next(self) -> Self { + match self { + Self::Channels => Self::BufferSize, + Self::BufferSize => Self::Polyphony, + Self::Polyphony => Self::Channels, + } + } + + pub fn prev(self) -> Self { + match self { + Self::Channels => Self::Polyphony, + Self::BufferSize => Self::Channels, + Self::Polyphony => Self::BufferSize, + } + } } pub struct Metrics { @@ -169,7 +191,9 @@ impl Default for Metrics { pub struct AudioSettings { pub config: AudioConfig, - pub focus: AudioFocus, + pub section: EngineSection, + pub device_kind: DeviceKind, + pub setting_kind: SettingKind, pub output_devices: Vec, pub input_devices: Vec, pub output_list: ListSelectState, @@ -182,7 +206,9 @@ impl Default for AudioSettings { fn default() -> Self { Self { config: AudioConfig::default(), - focus: AudioFocus::default(), + section: EngineSection::default(), + device_kind: DeviceKind::default(), + setting_kind: SettingKind::default(), output_devices: doux::audio::list_output_devices(), input_devices: doux::audio::list_input_devices(), output_list: ListSelectState { cursor: 0, scroll_offset: 0 }, @@ -199,41 +225,19 @@ impl AudioSettings { self.input_devices = doux::audio::list_input_devices(); } - pub fn next_focus(&mut self) { - self.focus = match self.focus { - AudioFocus::OutputDevice => AudioFocus::InputDevice, - AudioFocus::InputDevice => AudioFocus::Channels, - AudioFocus::Channels => AudioFocus::BufferSize, - AudioFocus::BufferSize => AudioFocus::Polyphony, - AudioFocus::Polyphony => AudioFocus::RefreshRate, - AudioFocus::RefreshRate => AudioFocus::RuntimeHighlight, - AudioFocus::RuntimeHighlight => AudioFocus::ShowScope, - AudioFocus::ShowScope => AudioFocus::ShowSpectrum, - AudioFocus::ShowSpectrum => AudioFocus::ShowCompletion, - AudioFocus::ShowCompletion => AudioFocus::SamplePaths, - AudioFocus::SamplePaths => AudioFocus::LinkEnabled, - AudioFocus::LinkEnabled => AudioFocus::StartStopSync, - AudioFocus::StartStopSync => AudioFocus::Quantum, - AudioFocus::Quantum => AudioFocus::OutputDevice, + pub fn next_section(&mut self) { + self.section = match self.section { + EngineSection::Devices => EngineSection::Settings, + EngineSection::Settings => EngineSection::Samples, + EngineSection::Samples => EngineSection::Devices, }; } - pub fn prev_focus(&mut self) { - self.focus = match self.focus { - AudioFocus::OutputDevice => AudioFocus::Quantum, - AudioFocus::InputDevice => AudioFocus::OutputDevice, - AudioFocus::Channels => AudioFocus::InputDevice, - AudioFocus::BufferSize => AudioFocus::Channels, - AudioFocus::Polyphony => AudioFocus::BufferSize, - AudioFocus::RefreshRate => AudioFocus::Polyphony, - AudioFocus::RuntimeHighlight => AudioFocus::RefreshRate, - AudioFocus::ShowScope => AudioFocus::RuntimeHighlight, - AudioFocus::ShowSpectrum => AudioFocus::ShowScope, - AudioFocus::ShowCompletion => AudioFocus::ShowSpectrum, - AudioFocus::SamplePaths => AudioFocus::ShowCompletion, - AudioFocus::LinkEnabled => AudioFocus::SamplePaths, - AudioFocus::StartStopSync => AudioFocus::LinkEnabled, - AudioFocus::Quantum => AudioFocus::StartStopSync, + pub fn prev_section(&mut self) { + self.section = match self.section { + EngineSection::Devices => EngineSection::Samples, + EngineSection::Settings => EngineSection::Devices, + EngineSection::Samples => EngineSection::Settings, }; } diff --git a/src/state/mod.rs b/src/state/mod.rs index e690e98..6a9db9c 100644 --- a/src/state/mod.rs +++ b/src/state/mod.rs @@ -3,6 +3,7 @@ pub mod editor; pub mod file_browser; pub mod live_keys; pub mod modal; +pub mod options; pub mod panel; pub mod patterns_nav; pub mod playback; @@ -10,7 +11,8 @@ pub mod project; pub mod sample_browser; pub mod ui; -pub use audio::{AudioFocus, AudioSettings, Metrics}; +pub use audio::{AudioSettings, DeviceKind, EngineSection, Metrics, SettingKind}; +pub use options::{OptionsFocus, OptionsState}; pub use editor::{CopiedStep, EditorContext, Focus, PatternField}; pub use live_keys::LiveKeyState; pub use modal::Modal; diff --git a/src/state/options.rs b/src/state/options.rs new file mode 100644 index 0000000..d21309d --- /dev/null +++ b/src/state/options.rs @@ -0,0 +1,45 @@ +#[derive(Clone, Copy, PartialEq, Eq, Default)] +pub enum OptionsFocus { + #[default] + RefreshRate, + RuntimeHighlight, + ShowScope, + ShowSpectrum, + ShowCompletion, + LinkEnabled, + StartStopSync, + Quantum, +} + +#[derive(Default)] +pub struct OptionsState { + pub focus: OptionsFocus, +} + +impl OptionsState { + pub fn next_focus(&mut self) { + self.focus = match self.focus { + OptionsFocus::RefreshRate => OptionsFocus::RuntimeHighlight, + OptionsFocus::RuntimeHighlight => OptionsFocus::ShowScope, + OptionsFocus::ShowScope => OptionsFocus::ShowSpectrum, + OptionsFocus::ShowSpectrum => OptionsFocus::ShowCompletion, + OptionsFocus::ShowCompletion => OptionsFocus::LinkEnabled, + OptionsFocus::LinkEnabled => OptionsFocus::StartStopSync, + OptionsFocus::StartStopSync => OptionsFocus::Quantum, + OptionsFocus::Quantum => OptionsFocus::RefreshRate, + }; + } + + pub fn prev_focus(&mut self) { + self.focus = match self.focus { + OptionsFocus::RefreshRate => OptionsFocus::Quantum, + OptionsFocus::RuntimeHighlight => OptionsFocus::RefreshRate, + OptionsFocus::ShowScope => OptionsFocus::RuntimeHighlight, + OptionsFocus::ShowSpectrum => OptionsFocus::ShowScope, + OptionsFocus::ShowCompletion => OptionsFocus::ShowSpectrum, + OptionsFocus::LinkEnabled => OptionsFocus::ShowCompletion, + OptionsFocus::StartStopSync => OptionsFocus::LinkEnabled, + OptionsFocus::Quantum => OptionsFocus::StartStopSync, + }; + } +} diff --git a/src/views/audio_view.rs b/src/views/audio_view.rs deleted file mode 100644 index 910be52..0000000 --- a/src/views/audio_view.rs +++ /dev/null @@ -1,430 +0,0 @@ -use cagire_ratatui::ListSelect; -use ratatui::layout::{Alignment, Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; -use ratatui::Frame; - -use crate::app::App; -use crate::engine::LinkState; -use crate::state::AudioFocus; - -pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { - let [left_col, _, right_col] = Layout::horizontal([ - Constraint::Percentage(52), - Constraint::Length(2), - Constraint::Percentage(48), - ]) - .areas(area); - - render_audio_section(frame, app, left_col); - render_link_section(frame, app, link, right_col); -} - -fn truncate_name(name: &str, max_len: usize) -> String { - if name.len() > max_len { - format!("{}...", &name[..max_len.saturating_sub(3)]) - } else { - name.to_string() - } -} - -fn render_audio_section(frame: &mut Frame, app: &App, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Audio ") - .border_style(Style::new().fg(Color::Magenta)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let padded = Rect { - x: inner.x + 1, - y: inner.y + 1, - width: inner.width.saturating_sub(2), - height: inner.height.saturating_sub(1), - }; - - let devices_height = devices_section_height(app); - - let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ - Constraint::Length(devices_height), - Constraint::Length(1), - Constraint::Length(10), - Constraint::Length(1), - Constraint::Min(3), - ]) - .areas(padded); - - render_devices(frame, app, devices_area); - render_settings(frame, app, settings_area); - render_samples(frame, app, samples_area); -} - -fn list_height(item_count: usize) -> u16 { - let visible = item_count.min(5) as u16; - if item_count > 5 { visible + 1 } else { visible } -} - -fn devices_section_height(app: &App) -> u16 { - // header(1) + "Output" label(1) + output list + "Input" label(1) + input list - 1 + 1 + list_height(app.audio.output_devices.len()) - + 1 + list_height(app.audio.input_devices.len()) -} - -fn render_devices(frame: &mut Frame, app: &App, area: Rect) { - let header_style = Style::new() - .fg(Color::Rgb(100, 160, 180)) - .add_modifier(Modifier::BOLD); - let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - - let output_h = list_height(app.audio.output_devices.len()); - let input_h = list_height(app.audio.input_devices.len()); - - let [header_area, output_label_area, output_list_area, input_label_area, input_list_area] = - Layout::vertical([ - Constraint::Length(1), - Constraint::Length(1), - Constraint::Length(output_h), - Constraint::Length(1), - Constraint::Length(input_h), - ]) - .areas(area); - - frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); - frame.render_widget(Paragraph::new(Span::styled("Output", label_style)), output_label_area); - frame.render_widget(Paragraph::new(Span::styled("Input", label_style)), input_label_area); - - let output_items: Vec = app.audio.output_devices.iter() - .map(|d| truncate_name(&d.name, 35)) - .collect(); - let output_selected = app.audio.current_output_device_index(); - ListSelect::new(&output_items, output_selected, app.audio.output_list.cursor) - .focused(app.audio.focus == AudioFocus::OutputDevice) - .scroll_offset(app.audio.output_list.scroll_offset) - .render(frame, output_list_area); - - let input_items: Vec = app.audio.input_devices.iter() - .map(|d| truncate_name(&d.name, 35)) - .collect(); - let input_selected = app.audio.current_input_device_index(); - ListSelect::new(&input_items, input_selected, app.audio.input_list.cursor) - .focused(app.audio.focus == AudioFocus::InputDevice) - .scroll_offset(app.audio.input_list.scroll_offset) - .render(frame, input_list_area); -} - -fn render_settings(frame: &mut Frame, app: &App, area: Rect) { - let header_style = Style::new() - .fg(Color::Rgb(100, 160, 180)) - .add_modifier(Modifier::BOLD); - - let [header_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); - - frame.render_widget(Paragraph::new("Settings").style(header_style), header_area); - - let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let normal = Style::new().fg(Color::White); - let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); - - let channels_focused = app.audio.focus == AudioFocus::Channels; - let buffer_focused = app.audio.focus == AudioFocus::BufferSize; - let polyphony_focused = app.audio.focus == AudioFocus::Polyphony; - let fps_focused = app.audio.focus == AudioFocus::RefreshRate; - let highlight_focused = app.audio.focus == AudioFocus::RuntimeHighlight; - let scope_focused = app.audio.focus == AudioFocus::ShowScope; - let spectrum_focused = app.audio.focus == AudioFocus::ShowSpectrum; - let completion_focused = app.audio.focus == AudioFocus::ShowCompletion; - - let highlight_text = if app.ui.runtime_highlight { "On" } else { "Off" }; - let scope_text = if app.audio.config.show_scope { "On" } else { "Off" }; - let spectrum_text = if app.audio.config.show_spectrum { "On" } else { "Off" }; - let completion_text = if app.ui.show_completion { "On" } else { "Off" }; - - let rows = vec![ - Row::new(vec![ - Span::styled("Output channels", label_style), - render_selector( - &format!("{}", app.audio.config.channels), - channels_focused, - highlight, - normal, - ), - ]), - Row::new(vec![ - Span::styled("Buffer size", label_style), - render_selector( - &format!("{}", app.audio.config.buffer_size), - buffer_focused, - highlight, - normal, - ), - ]), - Row::new(vec![ - Span::styled("Max voices", label_style), - render_selector( - &format!("{}", app.audio.config.max_voices), - polyphony_focused, - highlight, - normal, - ), - ]), - Row::new(vec![ - Span::styled("Refresh rate", label_style), - render_selector( - app.audio.config.refresh_rate.label(), - fps_focused, - highlight, - normal, - ), - ]), - Row::new(vec![ - Span::styled("Show highlight", label_style), - render_selector(highlight_text, highlight_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Show scope", label_style), - render_selector(scope_text, scope_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Show spectrum", label_style), - render_selector(spectrum_text, spectrum_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Completion", label_style), - render_selector(completion_text, completion_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Sample rate", label_style), - Span::styled( - format!("{:.0} Hz", app.audio.config.sample_rate), - value_style, - ), - ]), - ]; - - let table = Table::new(rows, [Constraint::Length(16), Constraint::Fill(1)]); - frame.render_widget(table, content_area); -} - -fn render_samples(frame: &mut Frame, app: &App, area: Rect) { - let header_style = Style::new() - .fg(Color::Rgb(100, 160, 180)) - .add_modifier(Modifier::BOLD); - - let [header_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); - - let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let samples_focused = app.audio.focus == AudioFocus::SamplePaths; - - let header_text = format!( - "Samples {} paths · {} indexed", - app.audio.config.sample_paths.len(), - app.audio.config.sample_count - ); - - let header_line = if samples_focused { - Line::from(vec![ - Span::styled("Samples ", header_style), - Span::styled( - format!( - "{} paths · {} indexed", - app.audio.config.sample_paths.len(), - app.audio.config.sample_count - ), - highlight, - ), - ]) - } else { - Line::from(Span::styled(header_text, header_style)) - }; - frame.render_widget(Paragraph::new(header_line), header_area); - - let dim = Style::new().fg(Color::Rgb(80, 85, 95)); - let path_style = Style::new().fg(Color::Rgb(120, 125, 135)); - - let mut lines: Vec = Vec::new(); - for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { - let path_str = path.to_string_lossy(); - let display = truncate_name(&path_str, 45); - lines.push(Line::from(vec![ - Span::styled(format!(" {} ", i + 1), dim), - Span::styled(display, path_style), - ])); - } - - if lines.is_empty() { - lines.push(Line::from(Span::styled( - " No sample paths configured", - dim, - ))); - } - - frame.render_widget(Paragraph::new(lines), content_area); -} - -fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { - let block = Block::default() - .borders(Borders::ALL) - .title(" Ableton Link ") - .border_style(Style::new().fg(Color::Cyan)); - - let inner = block.inner(area); - frame.render_widget(block, area); - - let padded = Rect { - x: inner.x + 1, - y: inner.y + 1, - width: inner.width.saturating_sub(2), - height: inner.height.saturating_sub(1), - }; - - let [status_area, _, config_area, _, info_area] = Layout::vertical([ - Constraint::Length(3), - Constraint::Length(1), - Constraint::Length(5), - Constraint::Length(1), - Constraint::Min(1), - ]) - .areas(padded); - - render_link_status(frame, link, status_area); - render_link_config(frame, app, link, config_area); - render_link_info(frame, link, info_area); -} - -fn render_link_status(frame: &mut Frame, link: &LinkState, area: Rect) { - let enabled = link.is_enabled(); - let peers = link.peers(); - - let (status_text, status_color) = if !enabled { - ("DISABLED", Color::Rgb(120, 60, 60)) - } else if peers > 0 { - ("CONNECTED", Color::Rgb(60, 120, 60)) - } else { - ("LISTENING", Color::Rgb(120, 120, 60)) - }; - - let status_style = Style::new().fg(status_color).add_modifier(Modifier::BOLD); - - let peer_text = if enabled { - if peers == 0 { - "No peers".to_string() - } else if peers == 1 { - "1 peer".to_string() - } else { - format!("{peers} peers") - } - } else { - String::new() - }; - - let lines = vec![ - Line::from(Span::styled(status_text, status_style)), - Line::from(Span::styled( - peer_text, - Style::new().fg(Color::Rgb(120, 125, 135)), - )), - ]; - - frame.render_widget(Paragraph::new(lines).alignment(Alignment::Center), area); -} - -fn render_link_config(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { - let header_style = Style::new() - .fg(Color::Rgb(100, 160, 180)) - .add_modifier(Modifier::BOLD); - - let [header_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); - - frame.render_widget( - Paragraph::new("Configuration").style(header_style), - header_area, - ); - - let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); - let normal = Style::new().fg(Color::White); - let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - - let enabled_focused = app.audio.focus == AudioFocus::LinkEnabled; - let startstop_focused = app.audio.focus == AudioFocus::StartStopSync; - let quantum_focused = app.audio.focus == AudioFocus::Quantum; - - let enabled_text = if link.is_enabled() { "On" } else { "Off" }; - let startstop_text = if link.is_start_stop_sync_enabled() { - "On" - } else { - "Off" - }; - let quantum_text = format!("{:.0}", link.quantum()); - - let rows = vec![ - Row::new(vec![ - Span::styled("Enabled", label_style), - render_selector(enabled_text, enabled_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Start/Stop", label_style), - render_selector(startstop_text, startstop_focused, highlight, normal), - ]), - Row::new(vec![ - Span::styled("Quantum", label_style), - render_selector(&quantum_text, quantum_focused, highlight, normal), - ]), - ]; - - let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); - frame.render_widget(table, content_area); -} - -fn render_link_info(frame: &mut Frame, link: &LinkState, area: Rect) { - let header_style = Style::new() - .fg(Color::Rgb(100, 160, 180)) - .add_modifier(Modifier::BOLD); - - let [header_area, content_area] = - Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); - - frame.render_widget(Paragraph::new("Session").style(header_style), header_area); - - let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); - let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); - let tempo_style = Style::new() - .fg(Color::Rgb(220, 180, 100)) - .add_modifier(Modifier::BOLD); - - let tempo = link.tempo(); - let beat = link.beat(); - let phase = link.phase(); - - let rows = vec![ - Row::new(vec![ - Span::styled("Tempo", label_style), - Span::styled(format!("{tempo:.1} BPM"), tempo_style), - ]), - Row::new(vec![ - Span::styled("Beat", label_style), - Span::styled(format!("{beat:.2}"), value_style), - ]), - Row::new(vec![ - Span::styled("Phase", label_style), - Span::styled(format!("{phase:.2}"), value_style), - ]), - ]; - - let table = Table::new(rows, [Constraint::Length(10), Constraint::Fill(1)]); - frame.render_widget(table, content_area); -} - -fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { - let style = if focused { highlight } else { normal }; - if focused { - Span::styled(format!("< {value} >"), style) - } else { - Span::styled(format!(" {value} "), style) - } -} diff --git a/src/views/engine_view.rs b/src/views/engine_view.rs new file mode 100644 index 0000000..22ccbac --- /dev/null +++ b/src/views/engine_view.rs @@ -0,0 +1,343 @@ +use cagire_ratatui::ListSelect; +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph, Row, Table}; +use ratatui::Frame; + +use crate::app::App; +use crate::state::{DeviceKind, EngineSection, SettingKind}; +use crate::widgets::{Orientation, Scope, Spectrum}; + +pub fn render(frame: &mut Frame, app: &App, area: Rect) { + let [left_col, _, right_col] = Layout::horizontal([ + Constraint::Percentage(55), + Constraint::Length(2), + Constraint::Percentage(45), + ]) + .areas(area); + + render_settings_section(frame, app, left_col); + render_visualizers(frame, app, right_col); +} + +fn render_settings_section(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Engine ") + .border_style(Style::new().fg(Color::Magenta)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let padded = Rect { + x: inner.x + 1, + y: inner.y + 1, + width: inner.width.saturating_sub(2), + height: inner.height.saturating_sub(1), + }; + + let devices_height = devices_section_height(app); + + let [devices_area, _, settings_area, _, samples_area] = Layout::vertical([ + Constraint::Length(devices_height), + Constraint::Length(1), + Constraint::Length(6), + Constraint::Length(1), + Constraint::Min(5), + ]) + .areas(padded); + + render_devices(frame, app, devices_area); + render_settings(frame, app, settings_area); + render_samples(frame, app, samples_area); +} + +fn render_visualizers(frame: &mut Frame, app: &App, area: Rect) { + let [scope_area, _, spectrum_area] = Layout::vertical([ + Constraint::Percentage(50), + Constraint::Length(1), + Constraint::Percentage(50), + ]) + .areas(area); + + render_scope(frame, app, scope_area); + render_spectrum(frame, app, spectrum_area); +} + +fn render_scope(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Scope ") + .border_style(Style::new().fg(Color::Green)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let scope = Scope::new(&app.metrics.scope) + .orientation(Orientation::Horizontal) + .color(Color::Green); + frame.render_widget(scope, inner); +} + +fn render_spectrum(frame: &mut Frame, app: &App, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Spectrum ") + .border_style(Style::new().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let spectrum = Spectrum::new(&app.metrics.spectrum); + frame.render_widget(spectrum, inner); +} + +fn truncate_name(name: &str, max_len: usize) -> String { + if name.len() > max_len { + format!("{}...", &name[..max_len.saturating_sub(3)]) + } else { + name.to_string() + } +} + +fn list_height(item_count: usize) -> u16 { + let visible = item_count.min(5) as u16; + if item_count > 5 { visible + 1 } else { visible } +} + +fn devices_section_height(app: &App) -> u16 { + let output_h = list_height(app.audio.output_devices.len()); + let input_h = list_height(app.audio.input_devices.len()); + 2 + output_h.max(input_h) +} + +fn render_devices(frame: &mut Frame, app: &App, area: Rect) { + let section_focused = app.audio.section == EngineSection::Devices; + let header_style = if section_focused { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD) + }; + + let [header_area, content_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + ]).areas(area); + + frame.render_widget(Paragraph::new("Devices").style(header_style), header_area); + + let [output_col, separator, input_col] = Layout::horizontal([ + Constraint::Percentage(48), + Constraint::Length(3), + Constraint::Percentage(48), + ]).areas(content_area); + + let output_focused = section_focused && app.audio.device_kind == DeviceKind::Output; + let input_focused = section_focused && app.audio.device_kind == DeviceKind::Input; + + render_device_column( + frame, app, output_col, + "Output", &app.audio.output_devices, + app.audio.current_output_device_index(), + app.audio.output_list.cursor, + app.audio.output_list.scroll_offset, + output_focused, + section_focused, + ); + + let sep_style = Style::new().fg(Color::Rgb(60, 65, 75)); + let sep_lines: Vec = (0..separator.height) + .map(|_| Line::from(Span::styled("│", sep_style))) + .collect(); + frame.render_widget(Paragraph::new(sep_lines), separator); + + render_device_column( + frame, app, input_col, + "Input", &app.audio.input_devices, + app.audio.current_input_device_index(), + app.audio.input_list.cursor, + app.audio.input_list.scroll_offset, + input_focused, + section_focused, + ); +} + +fn render_device_column( + frame: &mut Frame, + _app: &App, + area: Rect, + label: &str, + devices: &[doux::audio::AudioDeviceInfo], + selected_idx: usize, + cursor: usize, + scroll_offset: usize, + focused: bool, + section_focused: bool, +) { + let [label_area, list_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + ]).areas(area); + + let label_style = if focused { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else if section_focused { + Style::new().fg(Color::Rgb(150, 155, 165)) + } else { + Style::new().fg(Color::Rgb(100, 105, 115)) + }; + + let arrow = if focused { "> " } else { " " }; + frame.render_widget( + Paragraph::new(format!("{arrow}{label}")).style(label_style), + label_area, + ); + + let items: Vec = devices.iter() + .map(|d| truncate_name(&d.name, 25)) + .collect(); + + ListSelect::new(&items, selected_idx, cursor) + .focused(focused) + .scroll_offset(scroll_offset) + .render(frame, list_area); +} + +fn render_settings(frame: &mut Frame, app: &App, area: Rect) { + let section_focused = app.audio.section == EngineSection::Settings; + let header_style = if section_focused { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD) + }; + + let [header_area, content_area] = + Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(area); + + frame.render_widget(Paragraph::new("Settings").style(header_style), header_area); + + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(Color::White); + let label_style = Style::new().fg(Color::Rgb(120, 125, 135)); + let value_style = Style::new().fg(Color::Rgb(180, 180, 190)); + + let channels_focused = section_focused && app.audio.setting_kind == SettingKind::Channels; + let buffer_focused = section_focused && app.audio.setting_kind == SettingKind::BufferSize; + let polyphony_focused = section_focused && app.audio.setting_kind == SettingKind::Polyphony; + + let rows = vec![ + Row::new(vec![ + Span::styled(if channels_focused { "> Channels" } else { " Channels" }, label_style), + render_selector( + &format!("{}", app.audio.config.channels), + channels_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled(if buffer_focused { "> Buffer" } else { " Buffer" }, label_style), + render_selector( + &format!("{}", app.audio.config.buffer_size), + buffer_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled(if polyphony_focused { "> Voices" } else { " Voices" }, label_style), + render_selector( + &format!("{}", app.audio.config.max_voices), + polyphony_focused, + highlight, + normal, + ), + ]), + Row::new(vec![ + Span::styled(" Sample rate", label_style), + Span::styled( + format!("{:.0} Hz", app.audio.config.sample_rate), + value_style, + ), + ]), + ]; + + let table = Table::new(rows, [Constraint::Length(14), Constraint::Fill(1)]); + frame.render_widget(table, content_area); +} + +fn render_samples(frame: &mut Frame, app: &App, area: Rect) { + let section_focused = app.audio.section == EngineSection::Samples; + let header_style = if section_focused { + Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD) + } else { + Style::new().fg(Color::Rgb(100, 160, 180)).add_modifier(Modifier::BOLD) + }; + + let [header_area, content_area, _, hint_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Min(1), + Constraint::Length(1), + Constraint::Length(1), + ]) + .areas(area); + + let path_count = app.audio.config.sample_paths.len(); + let sample_count = app.audio.config.sample_count; + let header_text = format!("Samples {path_count} paths · {sample_count} indexed"); + frame.render_widget(Paragraph::new(header_text).style(header_style), header_area); + + let dim = Style::new().fg(Color::Rgb(80, 85, 95)); + let path_style = Style::new().fg(Color::Rgb(120, 125, 135)); + + let mut lines: Vec = Vec::new(); + if app.audio.config.sample_paths.is_empty() { + lines.push(Line::from(Span::styled( + " No sample paths configured", + dim, + ))); + lines.push(Line::from(Span::styled( + " Add folders containing .wav files", + dim, + ))); + } else { + for (i, path) in app.audio.config.sample_paths.iter().take(4).enumerate() { + let path_str = path.to_string_lossy(); + let display = truncate_name(&path_str, 40); + lines.push(Line::from(vec![ + Span::styled(format!(" {} ", i + 1), dim), + Span::styled(display, path_style), + ])); + } + if path_count > 4 { + lines.push(Line::from(Span::styled( + format!(" ... and {} more", path_count - 4), + dim, + ))); + } + } + frame.render_widget(Paragraph::new(lines), content_area); + + let hint_style = if section_focused { + Style::new().fg(Color::Rgb(180, 180, 100)) + } else { + Style::new().fg(Color::Rgb(60, 60, 70)) + }; + let hint = Line::from(vec![ + Span::styled("A", hint_style), + Span::styled(":add ", Style::new().fg(Color::Rgb(80, 85, 95))), + Span::styled("D", hint_style), + Span::styled(":remove", Style::new().fg(Color::Rgb(80, 85, 95))), + ]); + frame.render_widget(Paragraph::new(hint), hint_area); +} + +fn render_selector(value: &str, focused: bool, highlight: Style, normal: Style) -> Span<'static> { + let style = if focused { highlight } else { normal }; + if focused { + Span::styled(format!("< {value} >"), style) + } else { + Span::styled(format!(" {value} "), style) + } +} diff --git a/src/views/mod.rs b/src/views/mod.rs index 77e2977..3756da6 100644 --- a/src/views/mod.rs +++ b/src/views/mod.rs @@ -1,8 +1,9 @@ -pub mod audio_view; pub mod dict_view; +pub mod engine_view; pub mod help_view; pub mod highlight; pub mod main_view; +pub mod options_view; pub mod patterns_view; mod render; pub mod title_view; diff --git a/src/views/options_view.rs b/src/views/options_view.rs new file mode 100644 index 0000000..83f5ed7 --- /dev/null +++ b/src/views/options_view.rs @@ -0,0 +1,246 @@ +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Paragraph}; +use ratatui::Frame; + +use crate::app::App; +use crate::engine::LinkState; +use crate::state::OptionsFocus; + +const LABEL_COLOR: Color = Color::Rgb(120, 125, 135); +const HEADER_COLOR: Color = Color::Rgb(100, 160, 180); +const DIVIDER_COLOR: Color = Color::Rgb(60, 65, 70); + +pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { + let block = Block::default() + .borders(Borders::ALL) + .title(" Options ") + .border_style(Style::new().fg(Color::Cyan)); + + let inner = block.inner(area); + frame.render_widget(block, area); + + let padded = Rect { + x: inner.x + 2, + y: inner.y + 1, + width: inner.width.saturating_sub(4), + height: inner.height.saturating_sub(2), + }; + + let [display_area, _, link_area, _, session_area] = Layout::vertical([ + Constraint::Length(7), + Constraint::Length(1), + Constraint::Length(5), + Constraint::Length(1), + Constraint::Min(5), + ]) + .areas(padded); + + render_display_section(frame, app, display_area); + render_link_section(frame, app, link, link_area); + render_session_section(frame, link, session_area); +} + +fn render_display_section(frame: &mut Frame, app: &App, area: Rect) { + let [header_area, divider_area, content_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ]) + .areas(area); + + let header = Line::from(Span::styled( + "DISPLAY", + Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD), + )); + frame.render_widget(Paragraph::new(header), header_area); + + let divider = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)), + divider_area, + ); + + let focus = app.options.focus; + let lines = vec![ + render_option_line( + "Refresh rate", + app.audio.config.refresh_rate.label(), + focus == OptionsFocus::RefreshRate, + ), + render_option_line( + "Runtime highlight", + if app.ui.runtime_highlight { "On" } else { "Off" }, + focus == OptionsFocus::RuntimeHighlight, + ), + render_option_line( + "Show scope", + if app.audio.config.show_scope { "On" } else { "Off" }, + focus == OptionsFocus::ShowScope, + ), + render_option_line( + "Show spectrum", + if app.audio.config.show_spectrum { + "On" + } else { + "Off" + }, + focus == OptionsFocus::ShowSpectrum, + ), + render_option_line( + "Completion", + if app.ui.show_completion { "On" } else { "Off" }, + focus == OptionsFocus::ShowCompletion, + ), + ]; + + frame.render_widget(Paragraph::new(lines), content_area); +} + +fn render_link_section(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { + let [header_area, divider_area, content_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ]) + .areas(area); + + let enabled = link.is_enabled(); + let peers = link.peers(); + + let (status_text, status_color) = if !enabled { + ("DISABLED", Color::Rgb(120, 60, 60)) + } else if peers > 0 { + ("CONNECTED", Color::Rgb(60, 120, 60)) + } else { + ("LISTENING", Color::Rgb(120, 120, 60)) + }; + + let peer_text = if enabled && peers > 0 { + if peers == 1 { + " · 1 peer".to_string() + } else { + format!(" · {peers} peers") + } + } else { + String::new() + }; + + let header = Line::from(vec![ + Span::styled( + "ABLETON LINK", + Style::new().fg(HEADER_COLOR).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(LABEL_COLOR)), + ]); + frame.render_widget(Paragraph::new(header), header_area); + + let divider = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)), + divider_area, + ); + + let focus = app.options.focus; + let quantum_str = format!("{:.0}", link.quantum()); + let lines = vec![ + render_option_line( + "Enabled", + if link.is_enabled() { "On" } else { "Off" }, + focus == OptionsFocus::LinkEnabled, + ), + render_option_line( + "Start/Stop sync", + if link.is_start_stop_sync_enabled() { + "On" + } else { + "Off" + }, + focus == OptionsFocus::StartStopSync, + ), + render_option_line("Quantum", &quantum_str, focus == OptionsFocus::Quantum), + ]; + + frame.render_widget(Paragraph::new(lines), content_area); +} + +fn render_session_section(frame: &mut Frame, link: &LinkState, area: Rect) { + let [header_area, divider_area, content_area] = Layout::vertical([ + Constraint::Length(1), + Constraint::Length(1), + Constraint::Min(1), + ]) + .areas(area); + + let header = Line::from(Span::styled( + "SESSION", + Style::new().fg(HEADER_COLOR).add_modifier(Modifier::BOLD), + )); + frame.render_widget(Paragraph::new(header), header_area); + + let divider = "─".repeat(area.width as usize); + frame.render_widget( + Paragraph::new(divider).style(Style::new().fg(DIVIDER_COLOR)), + divider_area, + ); + + let tempo_style = Style::new() + .fg(Color::Rgb(220, 180, 100)) + .add_modifier(Modifier::BOLD); + let value_style = Style::new().fg(Color::Rgb(140, 145, 155)); + + let tempo_str = format!("{:.1} BPM", link.tempo()); + let beat_str = format!("{:.2}", link.beat()); + let phase_str = format!("{:.2}", link.phase()); + + let lines = vec![ + render_readonly_line("Tempo", &tempo_str, tempo_style), + render_readonly_line("Beat", &beat_str, value_style), + render_readonly_line("Phase", &phase_str, value_style), + ]; + + frame.render_widget(Paragraph::new(lines), content_area); +} + +fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool) -> Line<'a> { + let highlight = Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD); + let normal = Style::new().fg(Color::White); + let label_style = Style::new().fg(LABEL_COLOR); + + let prefix = if focused { "> " } else { " " }; + let prefix_style = if focused { highlight } else { normal }; + + let value_display = if focused { + format!("< {value} >") + } else { + format!(" {value} ") + }; + let value_style = if focused { highlight } else { normal }; + + let label_width = 20; + let padded_label = format!("{label:(label: &'a str, value: &'a str, value_style: Style) -> Line<'a> { + let label_style = Style::new().fg(LABEL_COLOR); + let label_width = 20; + let padded_label = format!("{label: Vec { spans.iter().filter_map(|s| { @@ -82,7 +82,8 @@ pub fn render(frame: &mut Frame, app: &mut App, link: &LinkState, snapshot: &Seq match app.page { Page::Main => main_view::render(frame, app, snapshot, page_area), Page::Patterns => patterns_view::render(frame, app, snapshot, page_area), - Page::Audio => audio_view::render(frame, app, link, page_area), + Page::Engine => engine_view::render(frame, app, page_area), + Page::Options => options_view::render(frame, app, link, page_area), Page::Help => help_view::render(frame, app, page_area), Page::Dict => dict_view::render(frame, app, page_area), } @@ -261,7 +262,8 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { let page_indicator = match app.page { Page::Main => "[MAIN]", Page::Patterns => "[PATTERNS]", - Page::Audio => "[AUDIO]", + Page::Engine => "[ENGINE]", + Page::Options => "[OPTIONS]", Page::Help => "[HELP]", Page::Dict => "[DICT]", }; @@ -294,13 +296,16 @@ fn render_footer(frame: &mut Frame, app: &App, area: Rect) { ("r", "Rename"), ("Del", "Reset"), ], - Page::Audio => vec![ + Page::Engine => vec![ + ("Tab", "Section"), + ("←→", "Switch/Adjust"), ("↑↓", "Navigate"), - ("←→", "Adjust"), - ("h", "Hush"), - ("p", "Panic"), - ("r", "Reset"), - ("t", "Test"), + ("Enter", "Select"), + ("A", "Add path"), + ], + Page::Options => vec![ + ("Tab", "Next"), + ("←→", "Toggle"), ("Space", "Play"), ], Page::Help => vec![