From 77d5235d92bb50daf3e992491dd67e32c529f026 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Rapha=C3=ABl=20Forment?= Date: Sat, 21 Feb 2026 01:27:32 +0100 Subject: [PATCH] Clean plugins --- plugins/cagire-plugins/src/editor.rs | 5 + src/app/mod.rs | 1 + src/engine/sequencer.rs | 23 ++- src/input/engine_page.rs | 2 +- src/input/help_page.rs | 4 +- src/input/main_page.rs | 10 +- src/input/mouse.rs | 2 +- src/input/options_page.rs | 2 +- src/input/patterns_page.rs | 2 +- src/state/options.rs | 125 +++++++++++--- src/views/keybindings.rs | 26 ++- src/views/options_view.rs | 249 ++++++++++++++------------- 12 files changed, 284 insertions(+), 167 deletions(-) diff --git a/plugins/cagire-plugins/src/editor.rs b/plugins/cagire-plugins/src/editor.rs index 7b19441..831c4d1 100644 --- a/plugins/cagire-plugins/src/editor.rs +++ b/plugins/cagire-plugins/src/editor.rs @@ -214,6 +214,11 @@ pub fn create_editor( let shared = editor.bridge.shared_state.load(); editor.snapshot = SequencerSnapshot::from(shared.as_ref()); + // Sync host tempo into LinkState so title bar shows real tempo + if shared.tempo > 0.0 { + editor.link.set_tempo(shared.tempo); + } + // Feed scope and spectrum data into app metrics editor.app.metrics.scope = editor.bridge.scope_buffer.read(); (editor.app.metrics.peak_left, editor.app.metrics.peak_right) = diff --git a/src/app/mod.rs b/src/app/mod.rs index ddb6eec..4b7d9c8 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -91,6 +91,7 @@ impl App { Self::build(variables, dict, rng, false) } + #[allow(dead_code)] pub fn new_plugin(variables: Variables, dict: Dictionary, rng: Rng) -> Self { Self::build(variables, dict, rng, true) } diff --git a/src/engine/sequencer.rs b/src/engine/sequencer.rs index 5ff62f3..3fb0dac 100644 --- a/src/engine/sequencer.rs +++ b/src/engine/sequencer.rs @@ -158,12 +158,17 @@ pub struct SharedSequencerState { pub active_patterns: Vec, pub step_traces: Arc, pub event_count: usize, + pub tempo: f64, + pub beat: f64, } +#[allow(dead_code)] pub struct SequencerSnapshot { pub active_patterns: Vec, step_traces: Arc, pub event_count: usize, + pub tempo: f64, + pub beat: f64, } impl From<&SharedSequencerState> for SequencerSnapshot { @@ -172,6 +177,8 @@ impl From<&SharedSequencerState> for SequencerSnapshot { active_patterns: s.active_patterns.clone(), step_traces: Arc::clone(&s.step_traces), event_count: s.event_count, + tempo: s.tempo, + beat: s.beat, } } } @@ -183,6 +190,8 @@ impl SequencerSnapshot { active_patterns: Vec::new(), step_traces: Arc::new(HashMap::new()), event_count: 0, + tempo: 0.0, + beat: 0.0, } } @@ -222,11 +231,7 @@ pub struct SequencerHandle { impl SequencerHandle { pub fn snapshot(&self) -> SequencerSnapshot { let state = self.shared_state.load(); - SequencerSnapshot { - active_patterns: state.active_patterns.clone(), - step_traces: Arc::clone(&state.step_traces), - event_count: state.event_count, - } + SequencerSnapshot::from(state.as_ref()) } pub fn swap_audio_channel(&self) -> Receiver { @@ -563,6 +568,8 @@ pub struct SequencerState { cc_access: Option>, muted: std::collections::HashSet<(usize, usize)>, soloed: std::collections::HashSet<(usize, usize)>, + last_tempo: f64, + last_beat: f64, } impl SequencerState { @@ -592,6 +599,8 @@ impl SequencerState { cc_access, muted: std::collections::HashSet::new(), soloed: std::collections::HashSet::new(), + last_tempo: 0.0, + last_beat: 0.0, } } @@ -701,6 +710,8 @@ impl SequencerState { pub fn tick(&mut self, input: TickInput) -> TickOutput { self.process_commands(input.commands); + self.last_tempo = input.tempo; + self.last_beat = input.beat; if !input.playing { return self.tick_paused(); @@ -1092,6 +1103,8 @@ impl SequencerState { .collect(), step_traces: Arc::clone(&self.step_traces), event_count: self.event_count, + tempo: self.last_tempo, + beat: self.last_beat, } } } diff --git a/src/input/engine_page.rs b/src/input/engine_page.rs index 575d0fa..2ec02bb 100644 --- a/src/input/engine_page.rs +++ b/src/input/engine_page.rs @@ -169,7 +169,7 @@ pub(super) fn handle_engine_page(ctx: &mut InputContext, key: KeyEvent) -> Input KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } - KeyCode::Char(' ') => { + KeyCode::Char(' ') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); diff --git a/src/input/help_page.rs b/src/input/help_page.rs index 27561af..de9eb2b 100644 --- a/src/input/help_page.rs +++ b/src/input/help_page.rs @@ -77,7 +77,7 @@ pub(super) fn handle_help_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } - KeyCode::Char(' ') => { + KeyCode::Char(' ') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); @@ -247,7 +247,7 @@ pub(super) fn handle_dict_page(ctx: &mut InputContext, key: KeyEvent) -> InputRe KeyCode::Char('?') => { ctx.dispatch(AppCommand::OpenModal(Modal::KeybindingsHelp { scroll: 0 })); } - KeyCode::Char(' ') => { + KeyCode::Char(' ') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); diff --git a/src/input/main_page.rs b/src/input/main_page.rs index 47312c9..45bfe22 100644 --- a/src/input/main_page.rs +++ b/src/input/main_page.rs @@ -29,7 +29,7 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool selected: false, })); } - KeyCode::Char(' ') => { + KeyCode::Char(' ') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); @@ -103,9 +103,11 @@ pub(super) fn handle_main_page(ctx: &mut InputContext, key: KeyEvent, ctrl: bool } KeyCode::Char('h') if ctrl => ctx.dispatch(AppCommand::HardenSteps), KeyCode::Char('l') => super::open_load(ctx), - KeyCode::Char('+') | KeyCode::Char('=') => ctx.dispatch(AppCommand::TempoUp), - KeyCode::Char('-') => ctx.dispatch(AppCommand::TempoDown), - KeyCode::Char('T') => { + KeyCode::Char('+') | KeyCode::Char('=') if !ctx.app.plugin_mode => { + ctx.dispatch(AppCommand::TempoUp); + } + KeyCode::Char('-') if !ctx.app.plugin_mode => ctx.dispatch(AppCommand::TempoDown), + KeyCode::Char('T') if !ctx.app.plugin_mode => { let current = format!("{:.1}", ctx.link.tempo()); ctx.dispatch(AppCommand::OpenModal(Modal::SetTempo(current))); } diff --git a/src/input/mouse.rs b/src/input/mouse.rs index dcae527..5e53c36 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -677,7 +677,7 @@ fn handle_options_click(ctx: &mut InputContext, col: u16, row: u16, area: Rect) let focus = ctx.app.options.focus; let plugin_mode = ctx.app.plugin_mode; let focus_line = focus.line_index(plugin_mode); - let total_lines = if plugin_mode { 43 } else { 40 }; + let total_lines = crate::state::options::total_lines(plugin_mode); let max_visible = padded.height as usize; let scroll_offset = if total_lines <= max_visible { diff --git a/src/input/options_page.rs b/src/input/options_page.rs index cdaf411..b53e381 100644 --- a/src/input/options_page.rs +++ b/src/input/options_page.rs @@ -210,7 +210,7 @@ pub(super) fn handle_options_page(ctx: &mut InputContext, key: KeyEvent) -> Inpu KeyCode::Left | KeyCode::Right => { cycle_option_value(ctx, key.code == KeyCode::Right); } - KeyCode::Char(' ') => { + KeyCode::Char(' ') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); diff --git a/src/input/patterns_page.rs b/src/input/patterns_page.rs index b10f98a..28164f0 100644 --- a/src/input/patterns_page.rs +++ b/src/input/patterns_page.rs @@ -84,7 +84,7 @@ pub(super) fn handle_patterns_page(ctx: &mut InputContext, key: KeyEvent) -> Inp } } } - KeyCode::Char(' ') => { + KeyCode::Char(' ') if !ctx.app.plugin_mode => { ctx.dispatch(AppCommand::TogglePlaying); ctx.playing .store(ctx.app.playback.playing, Ordering::Relaxed); diff --git a/src/state/options.rs b/src/state/options.rs index a671566..43e3142 100644 --- a/src/state/options.rs +++ b/src/state/options.rs @@ -56,9 +56,31 @@ impl CyclicEnum for OptionsFocus { ]; } -// Line indices when Font/ZoomFactor are shown (plugin mode). -// In terminal mode, Font/ZoomFactor are absent; all lines after ShowPreview shift up by 2. -const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ +const PLUGIN_ONLY: &[OptionsFocus] = &[ + OptionsFocus::Font, + OptionsFocus::ZoomFactor, + 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, +]; + +/// 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), (OptionsFocus::HueRotation, 3), (OptionsFocus::RefreshRate, 4), @@ -70,51 +92,112 @@ const FOCUS_LINES: &[(OptionsFocus, usize)] = &[ (OptionsFocus::Font, 10), (OptionsFocus::ZoomFactor, 11), (OptionsFocus::WindowSize, 12), + // blank=13, ABLETON LINK header=14, divider=15 (OptionsFocus::LinkEnabled, 16), (OptionsFocus::StartStopSync, 17), (OptionsFocus::Quantum, 18), + // blank=19, SESSION header=20, divider=21, Tempo=22, Beat=23, Phase=24 + // blank=25, MIDI OUTPUTS header=26, divider=27 (OptionsFocus::MidiOutput0, 28), (OptionsFocus::MidiOutput1, 29), (OptionsFocus::MidiOutput2, 30), (OptionsFocus::MidiOutput3, 31), + // blank=32, MIDI INPUTS header=33, divider=34 (OptionsFocus::MidiInput0, 35), (OptionsFocus::MidiInput1, 36), (OptionsFocus::MidiInput2, 37), (OptionsFocus::MidiInput3, 38), + // blank=39, ONBOARDING header=40, divider=41 (OptionsFocus::ResetOnboarding, 42), ]; -const PLUGIN_ONLY: &[OptionsFocus] = &[OptionsFocus::Font, OptionsFocus::ZoomFactor, OptionsFocus::WindowSize]; - impl OptionsFocus { fn is_plugin_only(self) -> bool { 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 + } + pub fn line_index(self, plugin_mode: bool) -> usize { - let base = FOCUS_LINES + visible_layout(plugin_mode) .iter() .find(|(f, _)| *f == self) .map(|(_, l)| *l) - .unwrap_or(0); - if plugin_mode || base <= 9 { - base - } else { - base - 3 - } + .unwrap_or(0) } pub fn at_line(line: usize, plugin_mode: bool) -> Option { - FOCUS_LINES.iter().find_map(|(f, l)| { - if f.is_plugin_only() && !plugin_mode { - return None; - } - let effective = if plugin_mode || *l <= 9 { *l } else { *l - 3 }; - if effective == line { Some(*f) } else { None } - }) + visible_layout(plugin_mode) + .iter() + .find(|(_, l)| *l == line) + .map(|(f, _)| *f) } } +/// Total number of rendered lines for the options view. +pub fn total_lines(plugin_mode: bool) -> usize { + visible_layout(plugin_mode) + .last() + .map(|(_, l)| *l + 1) + .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 (10,11,12) 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(13) through MidiInput3(38) = 26 lines + if plugin_mode { + // blank + LINK header + divider + 3 options + blank + SESSION header + divider + 3 readonlys + // + blank + MIDI OUT header + divider + 4 options + blank + MIDI IN header + divider + 4 options + // = 26 lines (indices 13..=38) + let link_section_lines = 26; + offset += link_section_lines; + } + + let mut result = Vec::new(); + for &(focus, raw_line) in FULL_LAYOUT { + if !focus.is_visible(plugin_mode) { + continue; + } + // Lines at or below index 9 (ShowPreview) are never shifted + let adjusted = if raw_line <= 9 { + raw_line + } else if !plugin_mode && raw_line <= 12 { + // Font/Zoom/Window — these are hidden, skip + continue; + } else { + raw_line - offset + }; + result.push((focus, adjusted)); + } + result +} + #[derive(Default)] pub struct OptionsState { pub focus: OptionsFocus, @@ -125,7 +208,7 @@ impl OptionsState { let mut f = self.focus; loop { f = f.next(); - if !f.is_plugin_only() || plugin_mode { + if f.is_visible(plugin_mode) { break; } } @@ -136,7 +219,7 @@ impl OptionsState { let mut f = self.focus; loop { f = f.prev(); - if !f.is_plugin_only() || plugin_mode { + if f.is_visible(plugin_mode) { break; } } diff --git a/src/views/keybindings.rs b/src/views/keybindings.rs index 51e4f85..5eecc49 100644 --- a/src/views/keybindings.rs +++ b/src/views/keybindings.rs @@ -17,7 +17,9 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati // Page-specific bindings match page { Page::Main => { - bindings.push(("Space", "Play/Stop", "Toggle playback")); + if !plugin_mode { + bindings.push(("Space", "Play/Stop", "Toggle playback")); + } bindings.push(("←→↑↓", "Navigate", "Move cursor between steps")); bindings.push(("Shift+←→↑↓", "Select", "Extend selection")); bindings.push(("Esc", "Clear", "Clear selection")); @@ -33,8 +35,10 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("Del", "Delete", "Delete step(s)")); bindings.push(("< >", "Length", "Decrease/increase pattern length")); bindings.push(("[ ]", "Speed", "Decrease/increase pattern speed")); - bindings.push(("+ -", "Tempo", "Increase/decrease tempo")); - bindings.push(("T", "Set tempo", "Open tempo input")); + if !plugin_mode { + bindings.push(("+ -", "Tempo", "Increase/decrease tempo")); + bindings.push(("T", "Set tempo", "Open tempo input")); + } bindings.push(("L", "Set length", "Open length input")); bindings.push(("S", "Set speed", "Open speed input")); bindings.push(("f", "Fill", "Toggle fill mode (hold)")); @@ -52,7 +56,9 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati Page::Patterns => { bindings.push(("←→↑↓", "Navigate", "Move between banks/patterns")); bindings.push(("Enter", "Select", "Select pattern for editing")); - bindings.push(("Space", "Play", "Toggle pattern playback")); + if !plugin_mode { + bindings.push(("Space", "Play", "Toggle pattern playback")); + } bindings.push(("Esc", "Back", "Clear staged or go back")); bindings.push(("c", "Commit", "Commit staged changes")); bindings.push(("r", "Rename", "Rename bank/pattern")); @@ -72,20 +78,26 @@ pub fn bindings_for(page: Page, plugin_mode: bool) -> Vec<(&'static str, &'stati bindings.push(("↑↓", "Navigate", "Navigate list items")); bindings.push(("PgUp/Dn", "Page", "Page through device list")); bindings.push(("Enter", "Select", "Select device")); - bindings.push(("R", "Restart", "Restart audio engine")); + if !plugin_mode { + bindings.push(("R", "Restart", "Restart audio engine")); + } bindings.push(("A", "Add path", "Add sample path")); bindings.push(("D", "Refresh/Del", "Refresh devices or delete path")); bindings.push(("h", "Hush", "Stop all sounds gracefully")); bindings.push(("p", "Panic", "Stop all sounds immediately")); bindings.push(("r", "Reset", "Reset peak voice counter")); - bindings.push(("t", "Test", "Play test tone")); + if !plugin_mode { + bindings.push(("t", "Test", "Play test tone")); + } } Page::Options => { bindings.push(("Tab", "Next", "Move to next option")); bindings.push(("Shift+Tab", "Previous", "Move to previous option")); bindings.push(("↑↓", "Navigate", "Navigate options")); bindings.push(("←→", "Toggle", "Toggle or adjust option")); - bindings.push(("Space", "Play/Stop", "Toggle playback")); + if !plugin_mode { + bindings.push(("Space", "Play/Stop", "Toggle playback")); + } } Page::Help => { bindings.push(("↑↓ j/k", "Scroll", "Scroll content")); diff --git a/src/views/options_view.rs b/src/views/options_view.rs index 1ce9da5..0db8af4 100644 --- a/src/views/options_view.rs +++ b/src/views/options_view.rs @@ -32,84 +32,6 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { let focus = app.options.focus; let content_width = padded.width as usize; - let enabled = link.is_enabled(); - let peers = link.peers(); - let (status_text, status_color) = if !enabled { - ("DISABLED", theme.link_status.disabled) - } else if peers > 0 { - ("CONNECTED", theme.link_status.connected) - } else { - ("LISTENING", theme.link_status.listening) - }; - let peer_text = if enabled && peers > 0 { - if peers == 1 { - " · 1 peer".to_string() - } else { - format!(" · {peers} peers") - } - } else { - String::new() - }; - - let link_header = Line::from(vec![ - Span::styled( - "ABLETON LINK", - Style::new().fg(theme.ui.header).add_modifier(Modifier::BOLD), - ), - Span::raw(" "), - Span::styled( - status_text, - Style::new().fg(status_color).add_modifier(Modifier::BOLD), - ), - Span::styled(peer_text, Style::new().fg(theme.ui.text_muted)), - ]); - - let quantum_str = format!("{:.0}", link.quantum()); - let tempo_str = format!("{:.1} BPM", link.tempo()); - let beat_str = format!("{:.2}", link.beat()); - let phase_str = format!("{:.2}", link.phase()); - - let tempo_style = Style::new().fg(theme.values.tempo).add_modifier(Modifier::BOLD); - let value_style = Style::new().fg(theme.values.value); - - let midi_outputs = midi::list_midi_outputs(); - let midi_inputs = midi::list_midi_inputs(); - - let midi_out_display = |slot: usize| -> String { - if let Some(idx) = app.midi.selected_outputs[slot] { - midi_outputs - .get(idx) - .map(|d| d.name.clone()) - .unwrap_or_else(|| "(disconnected)".to_string()) - } else if midi_outputs.is_empty() { - "(none found)".to_string() - } else { - "(not connected)".to_string() - } - }; - - let midi_in_display = |slot: usize| -> String { - if let Some(idx) = app.midi.selected_inputs[slot] { - midi_inputs - .get(idx) - .map(|d| d.name.clone()) - .unwrap_or_else(|| "(disconnected)".to_string()) - } else if midi_inputs.is_empty() { - "(none found)".to_string() - } else { - "(not connected)".to_string() - } - }; - - let midi_out_0 = midi_out_display(0); - let midi_out_1 = midi_out_display(1); - let midi_out_2 = midi_out_display(2); - let midi_out_3 = midi_out_display(3); - let midi_in_0 = midi_in_display(0); - let midi_in_1 = midi_in_display(1); - let midi_in_2 = midi_in_display(2); - let midi_in_3 = midi_in_display(3); - let onboarding_str = format!("{}/6 dismissed", app.ui.onboarding_dismissed.len()); let hue_str = format!("{}°", app.ui.hue_rotation as i32); @@ -169,9 +91,9 @@ pub fn render(frame: &mut Frame, app: &App, link: &LinkState, area: Rect) { &theme, ), ]; - let zoom_str = format!("{:.0}%", app.ui.zoom_factor * 100.0); - let window_str = format!("{}x{}", app.ui.window_width, app.ui.window_height); if app.plugin_mode { + let zoom_str = format!("{:.0}%", app.ui.zoom_factor * 100.0); + let window_str = format!("{}x{}", app.ui.window_width, app.ui.window_height); lines.push(render_option_line( "Font", &app.ui.font, @@ -191,48 +113,127 @@ 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), + ]); + } 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), - Line::from(""), - render_section_header("MIDI OUTPUTS", &theme), - render_divider(content_width, &theme), - render_option_line("Output 0", &midi_out_0, focus == OptionsFocus::MidiOutput0, &theme), - render_option_line("Output 1", &midi_out_1, focus == OptionsFocus::MidiOutput1, &theme), - render_option_line("Output 2", &midi_out_2, focus == OptionsFocus::MidiOutput2, &theme), - render_option_line("Output 3", &midi_out_3, focus == OptionsFocus::MidiOutput3, &theme), - Line::from(""), - render_section_header("MIDI INPUTS", &theme), - render_divider(content_width, &theme), - render_option_line("Input 0", &midi_in_0, focus == OptionsFocus::MidiInput0, &theme), - render_option_line("Input 1", &midi_in_1, focus == OptionsFocus::MidiInput1, &theme), - render_option_line("Input 2", &midi_in_2, focus == OptionsFocus::MidiInput2, &theme), - render_option_line("Input 3", &midi_in_3, focus == OptionsFocus::MidiInput3, &theme), - Line::from(""), render_section_header("ONBOARDING", &theme), render_divider(content_width, &theme), render_option_line( @@ -290,7 +291,7 @@ fn render_divider(width: usize, theme: &theme::ThemeColors) -> Line<'static> { )) } -fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool, theme: &theme::ThemeColors) -> Line<'a> { +fn render_option_line(label: &str, value: &str, focused: bool, theme: &theme::ThemeColors) -> Line<'static> { let highlight = Style::new().fg(theme.hint.key).add_modifier(Modifier::BOLD); let normal = Style::new().fg(theme.ui.text_primary); let label_style = Style::new().fg(theme.ui.text_muted); @@ -309,19 +310,19 @@ fn render_option_line<'a>(label: &'a str, value: &'a str, focused: bool, theme: let padded_label = format!("{label:(label: &'a str, value: &'a str, value_style: Style, theme: &theme::ThemeColors) -> Line<'a> { +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: