From 1e9c717e120a793fa823de52b9d6f16e062325b0 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 20:39:55 +0100 Subject: [PATCH 01/17] feat: add PanelVisibility struct for per-panel collapse Add PanelVisibility struct to control visibility of individual UI panels (synth_a_knobs, synth_a_grid, synth_b_knobs, synth_b_grid, drum_grid, drum_knobs, waveform). Synth B panels default to collapsed. This is the first task in a 17-task plan to add per-panel minimize toggles and dual synth support. Later tasks will use this struct for layout computation, rendering, and input handling. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/app.rs b/src/app.rs index c03755f..f6631cb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -419,6 +419,31 @@ pub struct StatusMessage { pub frames_remaining: u16, } +#[derive(Clone, Debug)] +pub struct PanelVisibility { + pub synth_a_knobs: bool, + pub synth_a_grid: bool, + pub synth_b_knobs: bool, + pub synth_b_grid: bool, + pub drum_grid: bool, + pub drum_knobs: bool, + pub waveform: bool, +} + +impl Default for PanelVisibility { + fn default() -> Self { + Self { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: false, // Synth B collapsed by default + synth_b_grid: false, + drum_grid: true, + drum_knobs: true, + waveform: true, + } + } +} + pub struct UiState { pub splash: SplashState, pub focus: FocusSection, @@ -433,6 +458,7 @@ pub struct UiState { pub show_help: bool, pub show_waveform: bool, pub synth_collapsed: bool, + pub panel_vis: PanelVisibility, /// Per-track trigger flash countdown (> 0 means flashing) pub trigger_flash: [u8; NUM_DRUM_TRACKS], /// Current active pattern index (0-9) @@ -499,6 +525,7 @@ impl Default for UiState { show_help: false, show_waveform: true, synth_collapsed: false, + panel_vis: PanelVisibility::default(), trigger_flash: [0; NUM_DRUM_TRACKS], active_pattern: 0, queued_pattern: None, From e4521a5715875dfced2449e5aa5688b1cd06462b Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 20:43:10 +0100 Subject: [PATCH 02/17] refactor: SynthId enum, parameterized synth messages - Add SynthId enum (A, B) to messages.rs - Parameterize UiToAudio: SetSynthPattern, TriggerSynth, ReleaseSynth now take SynthId - Update AudioToUi::PlaybackPosition: synth_triggered -> synth_a_triggered, add synth_b_triggered/synth_b_step - Route all synth messages to SynthId::A (dual synth wiring in later tasks) - Update message handlers in audio/engine.rs, app.rs, keys.rs Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 12 +++++++----- src/audio/engine.rs | 12 +++++++----- src/keys.rs | 14 +++++++------- src/messages.rs | 18 +++++++++++++----- 4 files changed, 34 insertions(+), 22 deletions(-) diff --git a/src/app.rs b/src/app.rs index f6631cb..ab82e69 100644 --- a/src/app.rs +++ b/src/app.rs @@ -8,7 +8,7 @@ use crossbeam_channel::{Receiver, Sender}; use crate::audio::display_buffer::AudioDisplayBuffer; -use crate::messages::{AudioToUi, UiToAudio}; +use crate::messages::{AudioToUi, SynthId, UiToAudio}; use crate::params::EffectParams; use crate::sequencer::drum_pattern::{DrumPattern, NUM_DRUM_TRACKS}; use crate::sequencer::project::{self, ProjectFile, NUM_KITS, NUM_PATTERNS}; @@ -616,7 +616,7 @@ impl App { // Send initial state to audio thread so it has the pattern from the start let _ = tx.send(UiToAudio::SetTransport(transport)); let _ = tx.send(UiToAudio::SetDrumPattern(drum_pattern.clone())); - let _ = tx.send(UiToAudio::SetSynthPattern(synth_pattern.clone())); + let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::A, synth_pattern.clone())); Self { ui: UiState::default(), @@ -674,9 +674,11 @@ impl App { beat, is_bar_start, triggered, - synth_triggered, + synth_a_triggered: synth_triggered, drum_step, - synth_step, + synth_a_step: synth_step, + synth_b_step: _, + synth_b_triggered: _, } => { self.ui.playback_step = drum_step; self.ui.synth_playback_step = synth_step; @@ -783,7 +785,7 @@ impl App { pub fn send_synth_pattern(&self) { let _ = self .tx_to_audio - .send(UiToAudio::SetSynthPattern(self.synth_pattern.clone())); + .send(UiToAudio::SetSynthPattern(SynthId::A, self.synth_pattern.clone())); } /// Send effect params to the audio thread. diff --git a/src/audio/engine.rs b/src/audio/engine.rs index daadd02..3c8acaf 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -187,7 +187,7 @@ impl AudioEngine { UiToAudio::SetDrumPattern(p) => { self.drum_pattern = p; } - UiToAudio::SetSynthPattern(p) => { + UiToAudio::SetSynthPattern(_synth_id, p) => { self.synth_pattern = p; self.synth_note_end_step = None; } @@ -216,13 +216,13 @@ impl AudioEngine { self.drum_voices[DrumTrackId::OpenHiHat as usize].choke(); } } - UiToAudio::TriggerSynth(note) => { + UiToAudio::TriggerSynth(_synth_id, note) => { self.synth_voice.trigger(&self.synth_pattern.params, note); // Gate for ~half a step (will be released when gate runs out) let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; self.synth_gate_samples = samples_per_step * 3 / 4; } - UiToAudio::ReleaseSynth => { + UiToAudio::ReleaseSynth(_synth_id) => { self.synth_voice.release(); self.synth_gate_samples = 0; } @@ -319,9 +319,11 @@ impl AudioEngine { beat: event.beat, is_bar_start: event.is_bar_start, triggered, - synth_triggered, + synth_a_triggered: synth_triggered, drum_step, - synth_step, + synth_a_step: synth_step, + synth_b_step: 0, + synth_b_triggered: false, }); } } diff --git a/src/keys.rs b/src/keys.rs index f52364b..aed0c62 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -4,7 +4,7 @@ use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crate::app::{App, DrumControlField, FocusSection, ModalAction, ModalState}; use crate::presets::{PatternMergeMode, PresetTarget}; -use crate::messages::UiToAudio; +use crate::messages::{SynthId, UiToAudio}; use crate::sequencer::drum_pattern::{MAX_STEPS, NUM_DRUM_TRACKS, TRACK_IDS}; use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; use crate::sequencer::synth_pattern::{SynthControlField, MAX_STEPS as SYNTH_MAX_STEPS}; @@ -464,7 +464,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let Some(semitone) = synth_key_to_semitone(ch) { let note = (app.ui.synth_octave * 12 + semitone).min(127); // Trigger synth sound - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_flash = 6; // If on synth grid, write note at cursor @@ -781,7 +781,7 @@ fn handle_synth_grid(app: &mut App, key: KeyEvent) { let note = app.synth_pattern.steps[s].note; app.send_synth_pattern(); app.dirty = true; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_flash = 6; } } @@ -793,7 +793,7 @@ fn handle_synth_grid(app: &mut App, key: KeyEvent) { let note = app.synth_pattern.steps[s].note; app.send_synth_pattern(); app.dirty = true; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_flash = 6; } } @@ -893,7 +893,7 @@ fn handle_synth_controls(app: &mut App, key: KeyEvent) { adjust_synth_field(app, PARAM_INCREMENT); if has_alt { let note = app.ui.synth_octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_flash = 6; } } @@ -901,7 +901,7 @@ fn handle_synth_controls(app: &mut App, key: KeyEvent) { adjust_synth_field(app, -PARAM_INCREMENT); if has_alt { let note = app.ui.synth_octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_flash = 6; } } @@ -1150,7 +1150,7 @@ fn preview_preset(app: &mut App) { if let Some(params) = browser.selected_synth_params() { app.apply_synth_preset(params); let note = app.ui.synth_octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(note)); + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_flash = 6; } } diff --git a/src/messages.rs b/src/messages.rs index d141bde..ea031ea 100644 --- a/src/messages.rs +++ b/src/messages.rs @@ -5,16 +5,22 @@ use crate::sequencer::drum_pattern::{DrumPattern, DrumTrackId}; use crate::sequencer::synth_pattern::SynthPattern; use crate::sequencer::transport::Transport; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SynthId { + A, + B, +} + /// Commands sent from the UI thread to the audio thread. /// Sent via a bounded crossbeam channel (capacity 64). pub enum UiToAudio { SetTransport(Transport), SetDrumPattern(DrumPattern), - SetSynthPattern(SynthPattern), + SetSynthPattern(SynthId, SynthPattern), SetEffectParams(EffectParams), TriggerDrum(DrumTrackId), // fire the voice immediately - TriggerSynth(u8), // MIDI note number — fire synth immediately - ReleaseSynth, // release synth envelopes + TriggerSynth(SynthId, u8), // MIDI note number — fire synth immediately + ReleaseSynth(SynthId), // release synth envelopes } /// Notifications sent from the audio thread back to the UI. @@ -25,8 +31,10 @@ pub enum AudioToUi { beat: u8, is_bar_start: bool, triggered: u8, // bitmask: which drum tracks triggered on this step - synth_triggered: bool, // whether synth was triggered on this step + synth_a_triggered: bool, // whether synth A was triggered on this step drum_step: usize, // drum pattern step (global_step % drum_length) - synth_step: usize, // synth pattern step (global_step % synth_length) + synth_a_step: usize, // synth A pattern step (global_step % synth_length) + synth_b_step: usize, // synth B pattern step (global_step % synth_length) + synth_b_triggered: bool, // whether synth B was triggered on this step }, } From 28db62482f44730a9eaf96c8147dc26df61e8a36 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 20:51:28 +0100 Subject: [PATCH 03/17] refactor: SynthUiState, dual FocusSection, dual synth patterns in App - Add SynthUiState struct bundling per-synth UI state (playback_step, cursor_step, ctrl_field, flash, octave, active_pattern, queued_pattern, active_kit) - Replace individual synth_* fields in UiState with synth_a and synth_b - Rename FocusSection::SynthGrid -> SynthAGrid, SynthControls -> SynthAControls - Add SynthBGrid and SynthBControls variants - Make FocusSection::next()/prev() visibility-aware via PanelVisibility - Replace App::synth_pattern with synth_a_pattern + synth_b_pattern - Fix all references across keys.rs, mouse.rs, ui/*.rs Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 193 ++++++++++++++++++++++++---------------- src/keys.rs | 158 ++++++++++++++++---------------- src/mouse.rs | 54 +++++------ src/ui/mod.rs | 2 +- src/ui/synth_grid.rs | 10 +-- src/ui/synth_knobs.rs | 10 +-- src/ui/transport_bar.rs | 10 +-- 7 files changed, 239 insertions(+), 198 deletions(-) diff --git a/src/app.rs b/src/app.rs index ab82e69..b2411f7 100644 --- a/src/app.rs +++ b/src/app.rs @@ -17,33 +17,60 @@ use crate::sequencer::transport::Transport; // ── Focus & field enums ───────────────────────────────────────────────────── -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum FocusSection { DrumGrid, Knobs, - SynthGrid, - SynthControls, + SynthAGrid, // was: SynthGrid + SynthAControls, // was: SynthControls + SynthBGrid, // new + SynthBControls, // new Transport, } impl FocusSection { - pub fn next(self) -> Self { - match self { - FocusSection::DrumGrid => FocusSection::Knobs, - FocusSection::Knobs => FocusSection::SynthGrid, - FocusSection::SynthGrid => FocusSection::SynthControls, - FocusSection::SynthControls => FocusSection::Transport, - FocusSection::Transport => FocusSection::DrumGrid, + pub fn next(&self, vis: &PanelVisibility) -> Self { + use FocusSection::*; + let order = [ + Transport, SynthAControls, SynthAGrid, + SynthBControls, SynthBGrid, DrumGrid, Knobs, + ]; + let cur = order.iter().position(|s| s == self).unwrap_or(0); + for i in 1..=order.len() { + let candidate = order[(cur + i) % order.len()]; + if candidate.is_visible(vis) { + return candidate; + } + } + Transport + } + + pub fn prev(&self, vis: &PanelVisibility) -> Self { + use FocusSection::*; + let order = [ + Transport, SynthAControls, SynthAGrid, + SynthBControls, SynthBGrid, DrumGrid, Knobs, + ]; + let cur = order.iter().position(|s| s == self).unwrap_or(0); + for i in 1..=order.len() { + let candidate = order[(cur + order.len() - i) % order.len()]; + if candidate.is_visible(vis) { + return candidate; + } } + Transport } - pub fn prev(self) -> Self { + pub fn is_visible(&self, vis: &PanelVisibility) -> bool { + use FocusSection::*; match self { - FocusSection::DrumGrid => FocusSection::Transport, - FocusSection::Knobs => FocusSection::DrumGrid, - FocusSection::SynthGrid => FocusSection::Knobs, - FocusSection::SynthControls => FocusSection::SynthGrid, - FocusSection::Transport => FocusSection::SynthControls, + Transport => true, + SynthAControls => vis.synth_a_knobs, + SynthAGrid => vis.synth_a_grid, + SynthBControls => vis.synth_b_knobs, + SynthBGrid => vis.synth_b_grid, + DrumGrid => vis.drum_grid, + Knobs => vis.drum_knobs, } } } @@ -444,6 +471,33 @@ impl Default for PanelVisibility { } } +#[derive(Clone, Debug)] +pub struct SynthUiState { + pub playback_step: usize, + pub cursor_step: usize, + pub ctrl_field: SynthControlField, + pub flash: u8, + pub octave: u8, + pub active_pattern: usize, + pub queued_pattern: Option, + pub active_kit: usize, +} + +impl Default for SynthUiState { + fn default() -> Self { + Self { + playback_step: 0, + cursor_step: 0, + ctrl_field: SynthControlField::Osc1Waveform, + flash: 0, + octave: 4, + active_pattern: 0, + queued_pattern: None, + active_kit: 0, + } + } +} + pub struct UiState { pub splash: SplashState, pub focus: FocusSection, @@ -478,19 +532,8 @@ pub struct UiState { /// Scope bar intensity/brightness (0.0-1.0), decays faster than bars for glow effect pub scope_intensity: Vec, // ── Synth state ── - pub synth_playback_step: usize, - pub synth_cursor_step: usize, - pub synth_ctrl_field: SynthControlField, - /// Synth trigger flash countdown - pub synth_flash: u8, - /// Current octave for synth note entry - pub synth_octave: u8, - /// Current active synth pattern index (0-9) - pub synth_active_pattern: usize, - /// Queued synth pattern to switch to at end of loop (None = no change pending) - pub synth_queued_pattern: Option, - /// Current active synth kit index (0-7) - pub synth_active_kit: usize, + pub synth_a: SynthUiState, + pub synth_b: SynthUiState, } impl UiState { @@ -535,14 +578,8 @@ impl Default for UiState { mouse: MouseState::default(), scope_bars: Vec::new(), scope_intensity: Vec::new(), - synth_playback_step: 0, - synth_cursor_step: 0, - synth_ctrl_field: SynthControlField::Osc1Waveform, - synth_flash: 0, - synth_octave: 4, - synth_active_pattern: 0, - synth_queued_pattern: None, - synth_active_kit: 0, + synth_a: SynthUiState::default(), + synth_b: SynthUiState::default(), } } } @@ -553,7 +590,8 @@ pub struct App { pub ui: UiState, pub transport: Transport, pub drum_pattern: DrumPattern, - pub synth_pattern: SynthPattern, + pub synth_a_pattern: SynthPattern, + pub synth_b_pattern: SynthPattern, pub effect_params: EffectParams, pub project: ProjectFile, pub project_path: Option, @@ -582,7 +620,7 @@ impl App { } } - let mut synth_pattern = SynthPattern::default(); + let mut synth_a_pattern = SynthPattern::default(); // Load startup presets: "Four on the Floor" drum + "Techno 2" synth { @@ -606,9 +644,9 @@ impl App { if let Some(preset) = SYNTH_PATTERN_PRESETS.iter().find(|p| p.name == "Techno 2") { for (i, &(note, vel, len)) in preset.steps.iter().enumerate() { - synth_pattern.steps[i].note = note; - synth_pattern.steps[i].velocity = vel; - synth_pattern.steps[i].length = len; + synth_a_pattern.steps[i].note = note; + synth_a_pattern.steps[i].velocity = vel; + synth_a_pattern.steps[i].length = len; } } } @@ -616,13 +654,14 @@ impl App { // Send initial state to audio thread so it has the pattern from the start let _ = tx.send(UiToAudio::SetTransport(transport)); let _ = tx.send(UiToAudio::SetDrumPattern(drum_pattern.clone())); - let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::A, synth_pattern.clone())); + let _ = tx.send(UiToAudio::SetSynthPattern(SynthId::A, synth_a_pattern.clone())); Self { ui: UiState::default(), transport, drum_pattern, - synth_pattern, + synth_a_pattern, + synth_b_pattern: SynthPattern::default(), effect_params: EffectParams::default(), project, project_path: None, @@ -652,7 +691,7 @@ impl App { pub fn tick(&mut self) { // Decay trigger flashes each frame self.ui.decay_flashes(); - self.ui.synth_flash = self.ui.synth_flash.saturating_sub(1); + self.ui.synth_a.flash = self.ui.synth_a.flash.saturating_sub(1); // Update scope bars (only when visible to avoid unnecessary work) if self.ui.show_waveform { @@ -681,7 +720,7 @@ impl App { synth_b_triggered: _, } => { self.ui.playback_step = drum_step; - self.ui.synth_playback_step = synth_step; + self.ui.synth_a.playback_step = synth_step; self.ui.current_beat = beat; self.ui.is_bar_start = is_bar_start; @@ -694,7 +733,7 @@ impl App { // Check for queued synth pattern switch at loop wrap (step 0) if synth_step == 0 && global_step > 0 { - if let Some(next) = self.ui.synth_queued_pattern.take() { + if let Some(next) = self.ui.synth_a.queued_pattern.take() { self.switch_synth_pattern(next); } } @@ -708,7 +747,7 @@ impl App { // Flash synth if synth_triggered { - self.ui.synth_flash = FLASH_FRAMES; + self.ui.synth_a.flash = FLASH_FRAMES; } } } @@ -785,7 +824,7 @@ impl App { pub fn send_synth_pattern(&self) { let _ = self .tx_to_audio - .send(UiToAudio::SetSynthPattern(SynthId::A, self.synth_pattern.clone())); + .send(UiToAudio::SetSynthPattern(SynthId::A, self.synth_a_pattern.clone())); } /// Send effect params to the audio thread. @@ -838,10 +877,10 @@ impl App { pat.bpm = self.transport.bpm; } // Save synth pattern and kit - self.project.save_synth_pattern(self.ui.synth_active_pattern, &self.synth_pattern); - self.project.save_synth_kit(self.ui.synth_active_kit, &self.synth_pattern.params); - self.project.active_synth_pattern = self.ui.synth_active_pattern; - self.project.active_synth_kit = self.ui.synth_active_kit; + self.project.save_synth_pattern(self.ui.synth_a.active_pattern, &self.synth_a_pattern); + self.project.save_synth_kit(self.ui.synth_a.active_kit, &self.synth_a_pattern.params); + self.project.active_synth_pattern = self.ui.synth_a.active_pattern; + self.project.active_synth_kit = self.ui.synth_a.active_kit; } /// Switch to a different pattern immediately. @@ -882,23 +921,23 @@ impl App { pub fn switch_synth_pattern(&mut self, index: usize) { if index >= NUM_PATTERNS { return; } // Save current synth pattern first - self.project.save_synth_pattern(self.ui.synth_active_pattern, &self.synth_pattern); + self.project.save_synth_pattern(self.ui.synth_a.active_pattern, &self.synth_a_pattern); // Load new synth pattern - self.ui.synth_active_pattern = index; + self.ui.synth_a.active_pattern = index; self.project.active_synth_pattern = index; - self.synth_pattern = SynthPattern::default(); - self.project.load_synth_pattern(index, &mut self.synth_pattern); + self.synth_a_pattern = SynthPattern::default(); + self.project.load_synth_pattern(index, &mut self.synth_a_pattern); self.send_synth_pattern(); } /// Queue a synth pattern to switch at end of current loop. pub fn queue_synth_pattern(&mut self, index: usize) { if index >= NUM_PATTERNS { return; } - if index == self.ui.synth_active_pattern { + if index == self.ui.synth_a.active_pattern { // Pressing the same pattern cancels the queue - self.ui.synth_queued_pattern = None; + self.ui.synth_a.queued_pattern = None; } else { - self.ui.synth_queued_pattern = Some(index); + self.ui.synth_a.queued_pattern = Some(index); } } @@ -906,11 +945,11 @@ impl App { pub fn switch_synth_kit(&mut self, index: usize) { if index >= NUM_KITS { return; } // Save current synth kit params back - self.project.save_synth_kit(self.ui.synth_active_kit, &self.synth_pattern.params); + self.project.save_synth_kit(self.ui.synth_a.active_kit, &self.synth_a_pattern.params); // Load new synth kit - self.ui.synth_active_kit = index; + self.ui.synth_a.active_kit = index; self.project.active_synth_kit = index; - self.project.load_synth_kit(index, &mut self.synth_pattern); + self.project.load_synth_kit(index, &mut self.synth_a_pattern); self.send_synth_pattern(); } @@ -1063,7 +1102,7 @@ impl App { // ── Preset Browser ───────────────────────────────────────────────── pub fn open_preset_browser(&mut self) { - let is_synth = matches!(self.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); let browser = if is_synth { crate::presets::PresetBrowserState::for_synth() } else { @@ -1084,7 +1123,7 @@ impl App { } pub fn open_pattern_browser(&mut self) { - let is_synth = matches!(self.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); let pb = if is_synth { crate::presets::PatternBrowserState::new_synth() } else { @@ -1165,16 +1204,16 @@ impl App { if vel > 0 { match merge { crate::presets::PatternMergeMode::Replace => { - self.synth_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; + self.synth_a_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; } crate::presets::PatternMergeMode::Layer => { - if !self.synth_pattern.steps[s].is_active() { - self.synth_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; + if !self.synth_a_pattern.steps[s].is_active() { + self.synth_a_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; } } } } else if matches!(merge, crate::presets::PatternMergeMode::Replace) { - self.synth_pattern.steps[s] = SynthStep::default(); + self.synth_a_pattern.steps[s] = SynthStep::default(); } } self.send_synth_pattern(); @@ -1182,9 +1221,9 @@ impl App { } pub fn apply_synth_preset(&mut self, params: crate::sequencer::synth_pattern::SynthParams) { - let mute = self.synth_pattern.params.mute; - self.synth_pattern.params = params; - self.synth_pattern.params.mute = mute; + let mute = self.synth_a_pattern.params.mute; + self.synth_a_pattern.params = params; + self.synth_a_pattern.params.mute = mute; self.send_synth_pattern(); self.dirty = true; } @@ -1217,11 +1256,11 @@ impl App { self.send_effect_params(); // Load synth state from project - self.ui.synth_active_pattern = self.project.active_synth_pattern; - self.ui.synth_active_kit = self.project.active_synth_kit; - self.synth_pattern = SynthPattern::default(); - self.project.load_synth_pattern(self.ui.synth_active_pattern, &mut self.synth_pattern); - self.project.load_synth_kit(self.ui.synth_active_kit, &mut self.synth_pattern); + self.ui.synth_a.active_pattern = self.project.active_synth_pattern; + self.ui.synth_a.active_kit = self.project.active_synth_kit; + self.synth_a_pattern = SynthPattern::default(); + self.project.load_synth_pattern(self.ui.synth_a.active_pattern, &mut self.synth_a_pattern); + self.project.load_synth_kit(self.ui.synth_a.active_kit, &mut self.synth_a_pattern); self.send_synth_pattern(); self.dirty = false; diff --git a/src/keys.rs b/src/keys.rs index aed0c62..858d5e8 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -164,11 +164,11 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Focus navigation KeyCode::Tab if !key.modifiers.contains(KeyModifiers::SHIFT) => { - app.ui.focus = app.ui.focus.next(); + app.ui.focus = app.ui.focus.next(&app.ui.panel_vis); return; } KeyCode::BackTab => { - app.ui.focus = app.ui.focus.prev(); + app.ui.focus = app.ui.focus.prev(&app.ui.panel_vis); return; } @@ -253,7 +253,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Loop length cycle (Shift+L) — focus-aware KeyCode::Char('L') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); if is_synth { app.transport.loop_config.synth_length = match app.transport.loop_config.synth_length { 8 => 16, @@ -353,7 +353,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Tube saturator: Shift+T cycles presets (Off → Warm → Hot → Crispy → Off) — focus-aware KeyCode::Char('T') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); let cur = if is_synth { app.effect_params.synth_saturator_drive } else { @@ -401,29 +401,29 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Pattern prev/next: [ ] queued, { } immediate — focus-aware KeyCode::Char('[') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; if is_synth { app.queue_synth_pattern(prev); } else { app.queue_pattern(prev); } return; } KeyCode::Char(']') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; let next = (cur + 1) % NUM_PATTERNS; if is_synth { app.queue_synth_pattern(next); } else { app.queue_pattern(next); } return; } KeyCode::Char('{') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; if is_synth { app.switch_synth_pattern(prev); } else { app.switch_pattern(prev); } return; } KeyCode::Char('}') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); - let cur = if is_synth { app.ui.synth_active_pattern } else { app.ui.active_pattern }; + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; let next = (cur + 1) % NUM_PATTERNS; if is_synth { app.switch_synth_pattern(next); } else { app.switch_pattern(next); } return; @@ -436,7 +436,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let KeyCode::Char(ch) = key.code { if let Some(idx) = pattern_key_to_index(ch) { let is_shift = key.modifiers.contains(KeyModifiers::SHIFT); - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); if is_synth { if is_shift { app.switch_synth_pattern(idx); } else { app.queue_synth_pattern(idx); } } else { @@ -450,7 +450,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let KeyCode::Char(ch) = key.code { if let Some(idx) = kit_key_to_index(ch) { if idx < NUM_KITS { - let is_synth = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); if is_synth { app.switch_synth_kit(idx); } else { app.switch_kit(idx); } } return; @@ -460,22 +460,22 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // ── Drum pad keys / Synth note keys (ZXCVBNM,) ───────────────────── if let KeyCode::Char(ch) = key.code { // When synth grid/controls is focused, use as chromatic keyboard - if matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls) { + if matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls) { if let Some(semitone) = synth_key_to_semitone(ch) { - let note = (app.ui.synth_octave * 12 + semitone).min(127); + let note = (app.ui.synth_a.octave * 12 + semitone).min(127); // Trigger synth sound let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_flash = 6; + app.ui.synth_a.flash = 6; // If on synth grid, write note at cursor - if app.ui.focus == FocusSection::SynthGrid { - let s = app.ui.synth_cursor_step; - app.synth_pattern.steps[s].note = note; - app.synth_pattern.steps[s].velocity = 100; + if app.ui.focus == FocusSection::SynthAGrid { + let s = app.ui.synth_a.cursor_step; + app.synth_a_pattern.steps[s].note = note; + app.synth_a_pattern.steps[s].velocity = 100; app.send_synth_pattern(); app.dirty = true; // Advance cursor - app.ui.synth_cursor_step = (app.ui.synth_cursor_step + 1) % SYNTH_MAX_STEPS; + app.ui.synth_a.cursor_step = (app.ui.synth_a.cursor_step + 1) % SYNTH_MAX_STEPS; } // If recording + playing, write at playhead @@ -484,8 +484,8 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { { let step = app.ui.playback_step; if step < SYNTH_MAX_STEPS { - app.synth_pattern.steps[step].note = note; - app.synth_pattern.steps[step].velocity = 100; + app.synth_a_pattern.steps[step].note = note; + app.synth_a_pattern.steps[step].velocity = 100; app.send_synth_pattern(); app.dirty = true; } @@ -520,8 +520,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { match app.ui.focus { FocusSection::DrumGrid => handle_drum_grid(app, key), FocusSection::Knobs => handle_knobs(app, key), - FocusSection::SynthGrid => handle_synth_grid(app, key), - FocusSection::SynthControls => handle_synth_controls(app, key), + FocusSection::SynthAGrid => handle_synth_grid(app, key), + FocusSection::SynthAControls => handle_synth_controls(app, key), + FocusSection::SynthBGrid => handle_synth_grid(app, key), + FocusSection::SynthBControls => handle_synth_controls(app, key), FocusSection::Transport => {} // transport keys are all global } } @@ -737,96 +739,96 @@ fn handle_synth_grid(app: &mut App, key: KeyEvent) { match key.code { // Shift+Left: decrease note length KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() && app.synth_pattern.steps[s].length > 1 { - app.synth_pattern.steps[s].length -= 1; + let s = app.ui.synth_a.cursor_step; + if app.synth_a_pattern.steps[s].is_active() && app.synth_a_pattern.steps[s].length > 1 { + app.synth_a_pattern.steps[s].length -= 1; app.send_synth_pattern(); app.dirty = true; } } // Shift+Right: increase note length KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() { + let s = app.ui.synth_a.cursor_step; + if app.synth_a_pattern.steps[s].is_active() { let loop_len = app.transport.loop_config.synth_length as usize; let max_length = (loop_len - s).min(32) as u8; - if app.synth_pattern.steps[s].length < max_length { - app.synth_pattern.steps[s].length += 1; + if app.synth_a_pattern.steps[s].length < max_length { + app.synth_a_pattern.steps[s].length += 1; app.send_synth_pattern(); app.dirty = true; } } } KeyCode::Left => { - app.ui.synth_cursor_step = if app.ui.synth_cursor_step == 0 { + app.ui.synth_a.cursor_step = if app.ui.synth_a.cursor_step == 0 { SYNTH_MAX_STEPS - 1 } else { - app.ui.synth_cursor_step - 1 + app.ui.synth_a.cursor_step - 1 }; } KeyCode::Right => { - if app.ui.synth_cursor_step == SYNTH_MAX_STEPS - 1 { + if app.ui.synth_a.cursor_step == SYNTH_MAX_STEPS - 1 { // Move into synth controls - app.ui.focus = FocusSection::SynthControls; + app.ui.focus = FocusSection::SynthAControls; } else { - app.ui.synth_cursor_step += 1; + app.ui.synth_a.cursor_step += 1; } } KeyCode::Up => { // Change note pitch up (semitone), or Shift for octave - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() { + let s = app.ui.synth_a.cursor_step; + if app.synth_a_pattern.steps[s].is_active() { let delta = if key.modifiers.contains(KeyModifiers::SHIFT) { 12 } else { 1 }; - app.synth_pattern.steps[s].note = (app.synth_pattern.steps[s].note + delta).min(127); - let note = app.synth_pattern.steps[s].note; + app.synth_a_pattern.steps[s].note = (app.synth_a_pattern.steps[s].note + delta).min(127); + let note = app.synth_a_pattern.steps[s].note; app.send_synth_pattern(); app.dirty = true; let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_flash = 6; + app.ui.synth_a.flash = 6; } } KeyCode::Down => { - let s = app.ui.synth_cursor_step; - if app.synth_pattern.steps[s].is_active() { + let s = app.ui.synth_a.cursor_step; + if app.synth_a_pattern.steps[s].is_active() { let delta = if key.modifiers.contains(KeyModifiers::SHIFT) { 12 } else { 1 }; - app.synth_pattern.steps[s].note = app.synth_pattern.steps[s].note.saturating_sub(delta).max(12); - let note = app.synth_pattern.steps[s].note; + app.synth_a_pattern.steps[s].note = app.synth_a_pattern.steps[s].note.saturating_sub(delta).max(12); + let note = app.synth_a_pattern.steps[s].note; app.send_synth_pattern(); app.dirty = true; let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_flash = 6; + app.ui.synth_a.flash = 6; } } KeyCode::Enter => { - let s = app.ui.synth_cursor_step; - let step = &mut app.synth_pattern.steps[s]; + let s = app.ui.synth_a.cursor_step; + let step = &mut app.synth_a_pattern.steps[s]; if step.is_active() { // Toggle off — reset length step.velocity = 0; step.length = 1; } else { // Toggle on with default note at current octave - step.note = app.ui.synth_octave * 12 + 12; // C at current octave + step.note = app.ui.synth_a.octave * 12 + 12; // C at current octave step.velocity = 100; step.length = 1; } app.send_synth_pattern(); app.dirty = true; // Advance cursor - app.ui.synth_cursor_step = (app.ui.synth_cursor_step + 1) % SYNTH_MAX_STEPS; + app.ui.synth_a.cursor_step = (app.ui.synth_a.cursor_step + 1) % SYNTH_MAX_STEPS; } KeyCode::Char('(') => { // Octave down - if app.ui.synth_octave > 0 { - app.ui.synth_octave -= 1; - app.show_status(format!("Synth octave: {}", app.ui.synth_octave)); + if app.ui.synth_a.octave > 0 { + app.ui.synth_a.octave -= 1; + app.show_status(format!("Synth octave: {}", app.ui.synth_a.octave)); } } KeyCode::Char(')') => { // Octave up - if app.ui.synth_octave < 8 { - app.ui.synth_octave += 1; - app.show_status(format!("Synth octave: {}", app.ui.synth_octave)); + if app.ui.synth_a.octave < 8 { + app.ui.synth_a.octave += 1; + app.show_status(format!("Synth octave: {}", app.ui.synth_a.octave)); } } _ => {} @@ -876,49 +878,49 @@ fn handle_synth_controls(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Left => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); if c > 0 { - app.ui.synth_ctrl_field = SYNTH_CTRL_ROWS[r][c - 1]; + app.ui.synth_a.ctrl_field = SYNTH_CTRL_ROWS[r][c - 1]; } // At leftmost field: do nothing (no cross-box nav) } KeyCode::Right => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); if c + 1 < SYNTH_CTRL_ROWS[r].len() { - app.ui.synth_ctrl_field = SYNTH_CTRL_ROWS[r][c + 1]; + app.ui.synth_a.ctrl_field = SYNTH_CTRL_ROWS[r][c + 1]; } // At rightmost field: do nothing (no cross-box nav) } KeyCode::Up if has_shift || has_alt => { adjust_synth_field(app, PARAM_INCREMENT); if has_alt { - let note = app.ui.synth_octave * 12 + 12; + let note = app.ui.synth_a.octave * 12 + 12; let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_flash = 6; + app.ui.synth_a.flash = 6; } } KeyCode::Down if has_shift || has_alt => { adjust_synth_field(app, -PARAM_INCREMENT); if has_alt { - let note = app.ui.synth_octave * 12 + 12; + let note = app.ui.synth_a.octave * 12 + 12; let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_flash = 6; + app.ui.synth_a.flash = 6; } } KeyCode::Up => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); if r > 0 { let new_row = &SYNTH_CTRL_ROWS[r - 1]; let new_c = c.min(new_row.len() - 1); - app.ui.synth_ctrl_field = new_row[new_c]; + app.ui.synth_a.ctrl_field = new_row[new_c]; } } KeyCode::Down => { - let (r, c) = find_synth_field_pos(app.ui.synth_ctrl_field); + let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); if r + 1 < SYNTH_CTRL_ROWS.len() { let new_row = &SYNTH_CTRL_ROWS[r + 1]; let new_c = c.min(new_row.len() - 1); - app.ui.synth_ctrl_field = new_row[new_c]; + app.ui.synth_a.ctrl_field = new_row[new_c]; } } _ => {} @@ -926,9 +928,9 @@ fn handle_synth_controls(app: &mut App, key: KeyEvent) { } fn adjust_synth_field(app: &mut App, delta: f32) { - let field = app.ui.synth_ctrl_field; + let field = app.ui.synth_a.ctrl_field; if field == SynthControlField::Mute { - app.synth_pattern.params.mute = !app.synth_pattern.params.mute; + app.synth_a_pattern.params.mute = !app.synth_a_pattern.params.mute; } else if field.is_enum() { let max_val: u8 = match field { SynthControlField::FilterType => 2, @@ -937,17 +939,17 @@ fn adjust_synth_field(app: &mut App, delta: f32) { SynthControlField::LfoDest => (crate::sequencer::synth_pattern::LFO_DEST_FIELDS.len() - 1) as u8, _ => 3, // Osc1/Osc2 waveforms }; - let cur = field.get(&app.synth_pattern.params); + let cur = field.get(&app.synth_a_pattern.params); let cur_int = (cur * max_val as f32).round() as u8; let new_int = if delta > 0.0 { (cur_int + 1).min(max_val) } else { cur_int.saturating_sub(1) }; - field.set(&mut app.synth_pattern.params, new_int as f32 / max_val as f32); + field.set(&mut app.synth_a_pattern.params, new_int as f32 / max_val as f32); } else { - let cur = field.get(&app.synth_pattern.params); - field.set(&mut app.synth_pattern.params, cur + delta); + let cur = field.get(&app.synth_a_pattern.params); + field.set(&mut app.synth_a_pattern.params, cur + delta); } app.send_synth_pattern(); app.dirty = true; @@ -1149,9 +1151,9 @@ fn preview_preset(app: &mut App) { PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { app.apply_synth_preset(params); - let note = app.ui.synth_octave * 12 + 12; + let note = app.ui.synth_a.octave * 12 + 12; let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_flash = 6; + app.ui.synth_a.flash = 6; } } PresetTarget::Pattern | PresetTarget::SynthPattern => {} // no preview for patterns diff --git a/src/mouse.rs b/src/mouse.rs index d8a041a..ea16abc 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -40,7 +40,7 @@ pub fn handle_mouse(app: &mut App, event: MouseEvent, term_size: Rect) { MouseEventKind::Up(MouseButton::Left) => { // If synth note drag ended without movement, toggle the step if let Some(ref drag) = app.ui.mouse.synth_note_drag { - if app.synth_pattern.steps[drag.step].length == drag.original_length { + if app.synth_a_pattern.steps[drag.step].length == drag.original_length { // No length change — treat as double-click toggle on second click // (first click just selects; this is handled by last_click logic) } @@ -72,10 +72,10 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) app.dirty = true; } else if hit_test_area(col, row, ly.synth_knobs) { // Scroll over synth knobs: adjust currently selected synth param - let field = app.ui.synth_ctrl_field; + let field = app.ui.synth_a.ctrl_field; if !field.is_enum() { - let current = field.get(&app.synth_pattern.params); - field.set(&mut app.synth_pattern.params, (current + delta).clamp(0.0, 1.0)); + let current = field.get(&app.synth_a_pattern.params); + field.set(&mut app.synth_a_pattern.params, (current + delta).clamp(0.0, 1.0)); app.send_synth_pattern(); app.dirty = true; } @@ -96,9 +96,9 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { if hit_test_area(col, row, ly.transport) { app.ui.focus = FocusSection::Transport; } else if hit_test_area(col, row, ly.synth_grid) { - app.ui.focus = FocusSection::SynthGrid; + app.ui.focus = FocusSection::SynthAGrid; } else if hit_test_area(col, row, ly.synth_knobs) { - app.ui.focus = FocusSection::SynthControls; + app.ui.focus = FocusSection::SynthAControls; } else if hit_test_area(col, row, ly.drum_grid) { app.ui.focus = FocusSection::DrumGrid; } else if hit_test_area(col, row, ly.knobs) { @@ -120,7 +120,7 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { } if hit_test_fader(col, row, ly.synth_fader) { let value = fader_value_from_click(row, ly.synth_fader); - app.synth_pattern.params.volume = value; + app.synth_a_pattern.params.volume = value; app.send_synth_pattern(); app.dirty = true; app.ui.mouse.fader_drag = Some(FaderDrag { @@ -137,7 +137,7 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_knobs) { handle_synth_knobs_click(app, field, row); } else if hit_test_area(col, row, ly.synth_knobs) { - app.ui.focus = FocusSection::SynthControls; + app.ui.focus = FocusSection::SynthAControls; } else if let Some((track, step)) = hit_test_grid_step(col, row, ly.drum_grid) { handle_grid_click(app, track, step); } else if let Some((track, is_mute)) = hit_test_mute_solo(col, row, ly.drum_grid) { @@ -219,12 +219,12 @@ fn handle_synth_step_click(app: &mut App, step: usize) { if is_double { // Double-click: toggle step use crate::sequencer::synth_pattern::SynthStep; - if app.synth_pattern.steps[step].is_active() { - app.synth_pattern.steps[step] = SynthStep { note: 0, velocity: 0, length: 1 }; + if app.synth_a_pattern.steps[step].is_active() { + app.synth_a_pattern.steps[step] = SynthStep { note: 0, velocity: 0, length: 1 }; } else { // Insert note at current octave - let note = 60 + (app.ui.synth_octave as u8).wrapping_sub(4) * 12; // C at current octave - app.synth_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + let note = 60 + (app.ui.synth_a.octave as u8).wrapping_sub(4) * 12; // C at current octave + app.synth_a_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; } app.send_synth_pattern(); app.dirty = true; @@ -232,22 +232,22 @@ fn handle_synth_step_click(app: &mut App, step: usize) { app.ui.mouse.synth_note_drag = None; } else { // Single click: move cursor + focus - app.ui.focus = FocusSection::SynthGrid; - app.ui.synth_cursor_step = step; + app.ui.focus = FocusSection::SynthAGrid; + app.ui.synth_a.cursor_step = step; app.ui.mouse.last_click = Some((now, 0, step)); // track=0 placeholder for synth - if app.synth_pattern.steps[step].is_active() { + if app.synth_a_pattern.steps[step].is_active() { // Active step: start a note-length drag app.ui.mouse.synth_note_drag = Some(SynthNoteDrag { step, - original_length: app.synth_pattern.steps[step].length, + original_length: app.synth_a_pattern.steps[step].length, start_col: 0, // will be set from the event col in handle_left_down }); } else { // Empty step: create a note use crate::sequencer::synth_pattern::SynthStep; - let note = 60 + (app.ui.synth_octave as u8).wrapping_sub(4) * 12; - app.synth_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + let note = 60 + (app.ui.synth_a.octave as u8).wrapping_sub(4) * 12; + app.synth_a_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; app.send_synth_pattern(); app.dirty = true; // Start drag so user can immediately extend the new note @@ -484,23 +484,23 @@ fn hit_adsr_field(col: u16, area: Rect, fields: &[SynthControlField]) -> Option< } fn handle_synth_knobs_click(app: &mut App, field: SynthControlField, start_y: u16) { - app.ui.focus = FocusSection::SynthControls; - app.ui.synth_ctrl_field = field; + app.ui.focus = FocusSection::SynthAControls; + app.ui.synth_a.ctrl_field = field; // For enum fields, just cycle on click instead of drag if field.is_enum() { let max_val: u8 = if field == SynthControlField::FilterType { 2 } else { 3 }; - let cur = field.get(&app.synth_pattern.params); + let cur = field.get(&app.synth_a_pattern.params); let cur_int = (cur * max_val as f32).round() as u8; let new_int = (cur_int + 1) % (max_val + 1); - field.set(&mut app.synth_pattern.params, new_int as f32 / max_val as f32); + field.set(&mut app.synth_a_pattern.params, new_int as f32 / max_val as f32); app.send_synth_pattern(); app.dirty = true; return; } // Start drag for continuous params - let start_value = field.get(&app.synth_pattern.params); + let start_value = field.get(&app.synth_a_pattern.params); app.ui.mouse.synth_drag = Some(SynthDrag { field, start_y, @@ -719,8 +719,8 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { let loop_len = app.transport.loop_config.synth_length as usize; let max_length = (loop_len - drag.step).min(32) as u8; let clamped = new_length.min(max_length).max(1); - if app.synth_pattern.steps[drag.step].length != clamped { - app.synth_pattern.steps[drag.step].length = clamped; + if app.synth_a_pattern.steps[drag.step].length != clamped { + app.synth_a_pattern.steps[drag.step].length = clamped; app.send_synth_pattern(); app.dirty = true; } @@ -744,7 +744,7 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { let d = d.clone(); let delta_y = d.start_y as f32 - row as f32; let new_value = (d.start_value + delta_y * DRAG_SENSITIVITY).clamp(0.0, 1.0); - d.field.set(&mut app.synth_pattern.params, new_value); + d.field.set(&mut app.synth_a_pattern.params, new_value); app.send_synth_pattern(); app.dirty = true; return; @@ -923,7 +923,7 @@ fn handle_fader_drag(app: &mut App, row: u16) { app.send_effect_params(); } FaderKind::Synth => { - app.synth_pattern.params.volume = new_value; + app.synth_a_pattern.params.volume = new_value; app.send_synth_pattern(); } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 2c369d9..486dae3 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -43,7 +43,7 @@ pub fn render(f: &mut Frame, app: &App) { if app.ui.synth_collapsed { render_synth_collapsed(f, ly.synth_section, app); } else { - render_volume_fader(f, ly.synth_fader, app.synth_pattern.params.volume, "SY"); + render_volume_fader(f, ly.synth_fader, app.synth_a_pattern.params.volume, "SY"); synth_knobs::render_synth_knobs(f, ly.synth_knobs, app); synth_grid::render_synth_grid(f, ly.synth_grid, app); } diff --git a/src/ui/synth_grid.rs b/src/ui/synth_grid.rs index c037df2..88eacca 100644 --- a/src/ui/synth_grid.rs +++ b/src/ui/synth_grid.rs @@ -17,10 +17,10 @@ const NAME_WIDTH: usize = 9; /// Renders the synth step row with note names, velocity shading, /// multi-step continuation bars, and playhead/cursor highlights. pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { - let focused = app.ui.focus == FocusSection::SynthGrid; + let focused = app.ui.focus == FocusSection::SynthAGrid; let border_style = theme::focus_border_style(focused); - let muted = app.synth_pattern.params.mute; + let muted = app.synth_a_pattern.params.mute; let loop_len = app.transport.loop_config.synth_length; let block = Block::default() @@ -83,7 +83,7 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { )); } - let playback_step = app.ui.synth_playback_step; + let playback_step = app.ui.synth_a.playback_step; let is_playing = app.transport.state == PlayState::Playing; // Multi-step note tracking: `covered_until` holds the last step index covered by @@ -100,8 +100,8 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { )); } - let step = &app.synth_pattern.steps[s]; - let is_cursor = focused && s == app.ui.synth_cursor_step; + let step = &app.synth_a_pattern.steps[s]; + let is_cursor = focused && s == app.ui.synth_a.cursor_step; let is_playhead = is_playing && s == playback_step; let out_of_loop = s >= loop_len as usize; let is_downbeat = s % 4 == 0; diff --git a/src/ui/synth_knobs.rs b/src/ui/synth_knobs.rs index b3834fe..af7a90c 100644 --- a/src/ui/synth_knobs.rs +++ b/src/ui/synth_knobs.rs @@ -67,11 +67,11 @@ const ADSR_LABELS: &[&str] = &["A", "D", "S", "R"]; /// Renders the synth parameter panel with grouped slider/ADSR sections /// laid out in four row groups: OSC, ENV+FILT, LFO, and AMP. pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { - let focused = app.ui.focus == FocusSection::SynthControls; + let focused = app.ui.focus == FocusSection::SynthAControls; let border_style = theme::focus_border_style(focused); let block = Block::default() - .title(format!(" SYNTH Oct:{} ", app.ui.synth_octave)) + .title(format!(" SYNTH Oct:{} ", app.ui.synth_a.octave)) .title_style(Style::default().fg(theme::TITLE_COLOR).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Thick) @@ -84,8 +84,8 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { return; } - let params = &app.synth_pattern.params; - let sel = app.ui.synth_ctrl_field; + let params = &app.synth_a_pattern.params; + let sel = app.ui.synth_a.ctrl_field; // Split inner into 4 row groups: OSC (8), ENV+FILT (8), LFO (3), AMP (remaining) let row_groups = Layout::default() @@ -603,7 +603,7 @@ fn render_amp_group( selected: SynthControlField, focused: bool, ) { - let params = &app.synth_pattern.params; + let params = &app.synth_a_pattern.params; let sat = app.effect_params.synth_saturator_drive; // We render 4 columns: Vol, Reverb, Delay, Sat diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 447ae38..6b36bf7 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -104,19 +104,19 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { let top_line = Line::from(top_spans); // ── Line 2: Synth machine selector + loop indicator ────────── - let synth_focused = matches!(app.ui.focus, FocusSection::SynthGrid | FocusSection::SynthControls); + let synth_focused = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); let synth_loop_str = if app.transport.loop_config.enabled { format!("Loop [ON] S:{}", app.transport.loop_config.synth_length) } else { "Loop [OFF]".to_string() }; - let synth_kit_name = app.project.synth_kits.get(app.ui.synth_active_kit) + let synth_kit_name = app.project.synth_kits.get(app.ui.synth_a.active_kit) .map(|k| k.name.as_str()).unwrap_or(""); let synth_line = machine_selector_line( "Synth", - app.ui.synth_active_pattern, - app.ui.synth_queued_pattern, - app.ui.synth_active_kit, + app.ui.synth_a.active_pattern, + app.ui.synth_a.queued_pattern, + app.ui.synth_a.active_kit, synth_kit_name, synth_focused, &synth_loop_str, From 6d9452fa10d5c0f67463f9747de0489e3e40ffab Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 20:54:54 +0100 Subject: [PATCH 04/17] refactor: LoopConfig synth_a_length + synth_b_length with serde alias MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename LoopConfig.synth_length → synth_a_length (for synth A) - Add LoopConfig.synth_b_length with default of 16 steps - Add serde derives to LoopConfig for serialization - Use #[serde(alias = "synth_length")] for backward compatibility - Update all references: keys.rs, engine.rs, transport_bar.rs, app.rs, mouse.rs, synth_grid.rs - For now, all code paths use synth_a_length (dual synth routing comes in later tasks) - Tests pass: 23 tests, all green Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 2 +- src/audio/engine.rs | 2 +- src/keys.rs | 6 +++--- src/mouse.rs | 2 +- src/sequencer/transport.rs | 14 +++++++++++--- src/ui/synth_grid.rs | 2 +- src/ui/transport_bar.rs | 2 +- 7 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/app.rs b/src/app.rs index b2411f7..ca238a5 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1189,7 +1189,7 @@ impl App { use crate::sequencer::synth_pattern::{MAX_STEPS, SynthStep}; let fill_len = if self.transport.loop_config.enabled { - self.transport.loop_config.synth_length as usize + self.transport.loop_config.synth_a_length as usize } else { MAX_STEPS }; diff --git a/src/audio/engine.rs b/src/audio/engine.rs index 3c8acaf..bdb22f6 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -243,7 +243,7 @@ impl AudioEngine { crate::sequencer::drum_pattern::MAX_STEPS }; let synth_loop_len = if self.transport.loop_config.enabled { - self.transport.loop_config.synth_length as usize + self.transport.loop_config.synth_a_length as usize } else { crate::sequencer::synth_pattern::MAX_STEPS }; diff --git a/src/keys.rs b/src/keys.rs index 858d5e8..57e850a 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -255,13 +255,13 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { KeyCode::Char('L') => { let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); if is_synth { - app.transport.loop_config.synth_length = match app.transport.loop_config.synth_length { + app.transport.loop_config.synth_a_length = match app.transport.loop_config.synth_a_length { 8 => 16, 16 => 24, 24 => 32, _ => 8, }; - app.show_status(format!("Synth loop: {} steps", app.transport.loop_config.synth_length)); + app.show_status(format!("Synth loop: {} steps", app.transport.loop_config.synth_a_length)); } else { app.transport.loop_config.drum_length = match app.transport.loop_config.drum_length { 8 => 16, @@ -750,7 +750,7 @@ fn handle_synth_grid(app: &mut App, key: KeyEvent) { KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { let s = app.ui.synth_a.cursor_step; if app.synth_a_pattern.steps[s].is_active() { - let loop_len = app.transport.loop_config.synth_length as usize; + let loop_len = app.transport.loop_config.synth_a_length as usize; let max_length = (loop_len - s).min(32) as u8; if app.synth_a_pattern.steps[s].length < max_length { app.synth_a_pattern.steps[s].length += 1; diff --git a/src/mouse.rs b/src/mouse.rs index ea16abc..ddc4e54 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -716,7 +716,7 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { let step_delta = col_delta / 2; // 2 chars per step let new_length = (drag.original_length as i32 + step_delta).clamp(1, 32) as u8; // Clamp to loop boundary - let loop_len = app.transport.loop_config.synth_length as usize; + let loop_len = app.transport.loop_config.synth_a_length as usize; let max_length = (loop_len - drag.step).min(32) as u8; let clamped = new_length.min(max_length).max(1); if app.synth_a_pattern.steps[drag.step].length != clamped { diff --git a/src/sequencer/transport.rs b/src/sequencer/transport.rs index 8e37174..1210148 100644 --- a/src/sequencer/transport.rs +++ b/src/sequencer/transport.rs @@ -1,5 +1,7 @@ //! Transport state: play/pause/stop, BPM, loop configuration, swing amount. +use serde::{Deserialize, Serialize}; + /// Sequencer playback state. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PlayState { @@ -15,19 +17,25 @@ pub enum RecordMode { } /// Per-section loop length settings (8/16/24/32 steps for drum and synth independently). -#[derive(Clone, Copy, Debug)] +#[derive(Clone, Copy, Debug, Serialize, Deserialize)] pub struct LoopConfig { pub enabled: bool, pub drum_length: u8, // 8, 16, 24, or 32 - pub synth_length: u8, // 8, 16, 24, or 32 + #[serde(alias = "synth_length")] + pub synth_a_length: u8, // 8, 16, 24, or 32 (was: synth_length) + #[serde(default = "default_synth_b_length")] + pub synth_b_length: u8, // 8, 16, 24, or 32 } +fn default_synth_b_length() -> u8 { 16 } + impl Default for LoopConfig { fn default() -> Self { Self { enabled: false, drum_length: 32, - synth_length: 32, + synth_a_length: 32, + synth_b_length: 16, } } } diff --git a/src/ui/synth_grid.rs b/src/ui/synth_grid.rs index 88eacca..87e9ff8 100644 --- a/src/ui/synth_grid.rs +++ b/src/ui/synth_grid.rs @@ -22,7 +22,7 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { let muted = app.synth_a_pattern.params.mute; - let loop_len = app.transport.loop_config.synth_length; + let loop_len = app.transport.loop_config.synth_a_length; let block = Block::default() .title(format!(" SYNTH STEPS [{} steps] ", loop_len)) .title_style(Style::default().fg(theme::TITLE_COLOR).add_modifier(Modifier::BOLD)) diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 6b36bf7..97893ff 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -106,7 +106,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { // ── Line 2: Synth machine selector + loop indicator ────────── let synth_focused = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); let synth_loop_str = if app.transport.loop_config.enabled { - format!("Loop [ON] S:{}", app.transport.loop_config.synth_length) + format!("Loop [ON] S:{}", app.transport.loop_config.synth_a_length) } else { "Loop [OFF]".to_string() }; From 61f594683afce45f46c2b2f41cde0f40f1931735 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 20:59:15 +0100 Subject: [PATCH 05/17] refactor: SynthInstance struct, dual synth in AudioEngine Bundle all per-synth state (voice, pattern, gate, LFO, saturator, reverb, delay) into SynthInstance struct. Replace individual synth fields with synth_a and synth_b instances. Rename shared reverb/delay to drum_reverb/ drum_delay for the drum FX bus. Only synth A processing is active; synth B will be wired in Task 6. Co-Authored-By: Claude Opus 4.6 --- src/audio/engine.rs | 185 +++++++++++++++++++++++++++----------------- 1 file changed, 115 insertions(+), 70 deletions(-) diff --git a/src/audio/engine.rs b/src/audio/engine.rs index bdb22f6..739134c 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -10,7 +10,7 @@ use crate::audio::drum_voice::{create_drum_voices, DrumVoiceDsp}; use crate::audio::effects::{DelayEffect, GlueCompressor, ReverbEffect, TubeSaturator}; use crate::audio::mixer::{effective_mute, soft_clip}; use crate::audio::synth_voice::SynthVoice; -use crate::messages::{AudioToUi, UiToAudio}; +use crate::messages::{AudioToUi, SynthId, UiToAudio}; use crate::params::EffectParams; use crate::sequencer::drum_pattern::{DrumPattern, DrumTrackId, NUM_DRUM_TRACKS}; use crate::sequencer::synth_pattern::{SynthPattern, LFO_DEST_FIELDS, lfo_division_multiplier}; @@ -18,7 +18,7 @@ use crate::sequencer::transport::{PlayState, Transport}; /// Tempo-synced global LFO shared across synth voices. /// Supports sine, triangle, saw (up/down), square, and exponential decay waveforms. -struct Lfo { +pub(crate) struct Lfo { phase: f64, } @@ -75,6 +75,43 @@ impl Lfo { } } +/// Bundles all per-synth state (voice, pattern, effects, LFO). +pub struct SynthInstance { + pub pattern: SynthPattern, + pub voice: SynthVoice, + pub gate_samples: u32, + pub note_end_step: Option, + pub lfo: Lfo, + pub saturator: TubeSaturator, + pub reverb: ReverbEffect, + pub delay: DelayEffect, + pub reverb_amount: f32, + pub reverb_damping: f32, + pub delay_time: f32, + pub delay_feedback: f32, + pub delay_tone: f32, +} + +impl SynthInstance { + pub fn new(sample_rate: f64) -> Self { + Self { + pattern: SynthPattern::default(), + voice: SynthVoice::new(sample_rate as f32), + gate_samples: 0, + note_end_step: None, + lfo: Lfo::new(), + saturator: TubeSaturator::new(sample_rate as f32), + reverb: ReverbEffect::new(sample_rate), + delay: DelayEffect::new(), + reverb_amount: 0.3, + reverb_damping: 0.5, + delay_time: 0.0, + delay_feedback: 0.4, + delay_tone: 0.5, + } + } +} + /// Core audio engine running on the audio thread. /// Owns all voices, effects, the sequencer clock, and handles messages from the UI thread. pub struct AudioEngine { @@ -84,24 +121,18 @@ pub struct AudioEngine { // Local copies updated from UI messages transport: Transport, drum_pattern: DrumPattern, - synth_pattern: SynthPattern, master_volume: f32, // DSP drum_voices: [Box; 8], - synth_voice: SynthVoice, - lfo: Lfo, - /// Samples remaining for current synth note gate (0 = released/idle) - synth_gate_samples: u32, - /// Step index where the current long note should end (for multi-step notes) - synth_note_end_step: Option, - - // Send effects - reverb: ReverbEffect, - delay: DelayEffect, + synth_a: SynthInstance, + synth_b: SynthInstance, + + // Send effects (drum bus) + drum_reverb: ReverbEffect, + drum_delay: DelayEffect, compressor: GlueCompressor, drum_saturator: TubeSaturator, - synth_saturator: TubeSaturator, effect_params: EffectParams, // Display buffer (shared with UI) @@ -116,10 +147,10 @@ pub struct AudioEngine { impl AudioEngine { pub fn new(sample_rate: f64, rx: Receiver, tx: Sender, display_buf: Arc) -> Self { let effect_params = EffectParams::default(); - let mut reverb = ReverbEffect::new(sample_rate); - let mut delay = DelayEffect::new(); - reverb.set_params(effect_params.reverb_amount, effect_params.reverb_damping); - delay.set_params( + let mut drum_reverb = ReverbEffect::new(sample_rate); + let mut drum_delay = DelayEffect::new(); + drum_reverb.set_params(effect_params.reverb_amount, effect_params.reverb_damping); + drum_delay.set_params( effect_params.delay_time, effect_params.delay_feedback, effect_params.delay_tone, @@ -129,25 +160,20 @@ impl AudioEngine { let compressor = GlueCompressor::new(sample_rate); let drum_saturator = TubeSaturator::new(sample_rate as f32); - let synth_saturator = TubeSaturator::new(sample_rate as f32); Self { sample_rate, clock: SequencerClock::new(), transport: Transport::default(), drum_pattern: DrumPattern::default(), - synth_pattern: SynthPattern::default(), master_volume: 0.8, drum_voices: create_drum_voices(sample_rate), - synth_voice: SynthVoice::new(sample_rate as f32), - lfo: Lfo::new(), - synth_gate_samples: 0, - synth_note_end_step: None, - reverb, - delay, + synth_a: SynthInstance::new(sample_rate), + synth_b: SynthInstance::new(sample_rate), + drum_reverb, + drum_delay, compressor, drum_saturator, - synth_saturator, effect_params, display_buf, peak_tracker: 0.0, @@ -169,13 +195,15 @@ impl AudioEngine { && prev_state != PlayState::Stopped { self.clock.reset(); - self.lfo.reset(); - self.synth_note_end_step = None; + self.synth_a.lfo.reset(); + self.synth_b.lfo.reset(); + self.synth_a.note_end_step = None; + self.synth_b.note_end_step = None; } // Update delay time when BPM changes if bpm_changed { let ep = &self.effect_params; - self.delay.set_params( + self.drum_delay.set_params( ep.delay_time, ep.delay_feedback, ep.delay_tone, @@ -187,15 +215,23 @@ impl AudioEngine { UiToAudio::SetDrumPattern(p) => { self.drum_pattern = p; } - UiToAudio::SetSynthPattern(_synth_id, p) => { - self.synth_pattern = p; - self.synth_note_end_step = None; + UiToAudio::SetSynthPattern(synth_id, p) => { + match synth_id { + SynthId::A => { + self.synth_a.pattern = p; + self.synth_a.note_end_step = None; + } + SynthId::B => { + self.synth_b.pattern = p; + self.synth_b.note_end_step = None; + } + } } UiToAudio::SetEffectParams(ep) => { self.effect_params = ep; - self.reverb + self.drum_reverb .set_params(ep.reverb_amount, ep.reverb_damping); - self.delay.set_params( + self.drum_delay.set_params( ep.delay_time, ep.delay_feedback, ep.delay_tone, @@ -206,7 +242,8 @@ impl AudioEngine { .set_amount(ep.compressor_amount, self.sample_rate); self.master_volume = ep.master_volume; self.drum_saturator.set_drive(ep.drum_saturator_drive); - self.synth_saturator.set_drive(ep.synth_saturator_drive); + self.synth_a.saturator.set_drive(ep.synth_saturator_drive); + self.synth_b.saturator.set_drive(ep.synth_saturator_drive); } UiToAudio::TriggerDrum(track_id) => { let track = track_id as usize; @@ -216,15 +253,23 @@ impl AudioEngine { self.drum_voices[DrumTrackId::OpenHiHat as usize].choke(); } } - UiToAudio::TriggerSynth(_synth_id, note) => { - self.synth_voice.trigger(&self.synth_pattern.params, note); + UiToAudio::TriggerSynth(synth_id, note) => { + let inst = match synth_id { + SynthId::A => &mut self.synth_a, + SynthId::B => &mut self.synth_b, + }; + inst.voice.trigger(&inst.pattern.params, note); // Gate for ~half a step (will be released when gate runs out) let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; - self.synth_gate_samples = samples_per_step * 3 / 4; + inst.gate_samples = samples_per_step * 3 / 4; } - UiToAudio::ReleaseSynth(_synth_id) => { - self.synth_voice.release(); - self.synth_gate_samples = 0; + UiToAudio::ReleaseSynth(synth_id) => { + let inst = match synth_id { + SynthId::A => &mut self.synth_a, + SynthId::B => &mut self.synth_b, + }; + inst.voice.release(); + inst.gate_samples = 0; } } } @@ -281,36 +326,36 @@ impl AudioEngine { // Trigger synth voice for active steps (with multi-step note length) let mut synth_triggered = false; - let synth_step_data = &self.synth_pattern.steps[synth_step]; + let synth_step_data = &self.synth_a.pattern.steps[synth_step]; let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; - if synth_step_data.is_active() && !self.synth_pattern.params.mute { + if synth_step_data.is_active() && !self.synth_a.pattern.params.mute { // New active note always takes priority (re-trigger) - self.synth_voice.trigger(&self.synth_pattern.params, synth_step_data.note); + self.synth_a.voice.trigger(&self.synth_a.pattern.params, synth_step_data.note); synth_triggered = true; let length = (synth_step_data.length as usize).max(1); if length <= 1 { // Single-step note: gate ~75% of one step - self.synth_gate_samples = samples_per_step * 3 / 4; - self.synth_note_end_step = None; + self.synth_a.gate_samples = samples_per_step * 3 / 4; + self.synth_a.note_end_step = None; } else { // Multi-step note: hold for full duration minus small release window let end_step = (synth_step + length - 1).min(synth_loop_len.max(1) - 1); - self.synth_gate_samples = samples_per_step * length as u32 - samples_per_step / 4; - self.synth_note_end_step = Some(end_step); + self.synth_a.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + self.synth_a.note_end_step = Some(end_step); } - } else if let Some(end) = self.synth_note_end_step { + } else if let Some(end) = self.synth_a.note_end_step { if synth_step == end { // Last step of a long note — set gate to expire at ~75% of this step - self.synth_gate_samples = samples_per_step * 3 / 4; - self.synth_note_end_step = None; + self.synth_a.gate_samples = samples_per_step * 3 / 4; + self.synth_a.note_end_step = None; } // Otherwise we're in the middle of a long note — do nothing, let gate continue - } else if !synth_step_data.is_active() && self.synth_gate_samples > 0 { + } else if !synth_step_data.is_active() && self.synth_a.gate_samples > 0 { // No note on this step and not covered by a long note — release - self.synth_voice.release(); - self.synth_gate_samples = 0; + self.synth_a.voice.release(); + self.synth_a.gate_samples = 0; } // Send playback position to UI (drop if channel is full) @@ -329,10 +374,10 @@ impl AudioEngine { } // Decrement synth gate counter and release when expired - if self.synth_gate_samples > 0 { - self.synth_gate_samples -= 1; - if self.synth_gate_samples == 0 { - self.synth_voice.release(); + if self.synth_a.gate_samples > 0 { + self.synth_a.gate_samples -= 1; + if self.synth_a.gate_samples == 0 { + self.synth_a.voice.release(); } } @@ -369,12 +414,12 @@ impl AudioEngine { reverb_send *= drum_vol; delay_send *= drum_vol; - // Generate synth audio with LFO modulation + per-instrument saturator - let synth_params = &self.synth_pattern.params; + // Generate synth A audio with LFO modulation + per-instrument saturator + let synth_params = &self.synth_a.pattern.params; let mut modulated_params = *synth_params; if synth_params.lfo_depth > 0.001 { let div_mult = lfo_division_multiplier(synth_params.lfo_division); - let lfo_val = self.lfo.tick( + let lfo_val = self.synth_a.lfo.tick( self.sample_rate, self.transport.bpm, div_mult, @@ -388,19 +433,19 @@ impl AudioEngine { field.set(&mut modulated_params, current + mod_amount); } } - let synth_sample = self.synth_voice.tick(&modulated_params); + let synth_sample = self.synth_a.voice.tick(&modulated_params); let mut synth_dry: f32 = 0.0; - if !self.synth_pattern.params.mute { + if !self.synth_a.pattern.params.mute { // tick() already applies params.volume, don't double-apply synth_dry = synth_sample; - reverb_send += synth_sample * self.synth_pattern.params.send_reverb; - delay_send += synth_sample * self.synth_pattern.params.send_delay; + reverb_send += synth_sample * self.synth_a.pattern.params.send_reverb; + delay_send += synth_sample * self.synth_a.pattern.params.send_delay; } - let synth_sat = self.synth_saturator.tick(synth_dry); + let synth_sat = self.synth_a.saturator.tick(synth_dry); - // Process send effects - let reverb_out = self.reverb.tick(reverb_send); - let delay_out = self.delay.tick(delay_send); + // Process send effects (drum bus — synth A uses same bus for now) + let reverb_out = self.drum_reverb.tick(reverb_send); + let delay_out = self.drum_delay.tick(delay_send); // Mix: per-instrument saturated signals + wet effects → headroom → master volume → compressor → clip // Synth + effects are mono, centered to both channels From d6c0e38ad5f899158a955ec412151896831c85f1 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:02:53 +0100 Subject: [PATCH 06/17] feat: dual synth processing, independent FX chains, dual mixing Duplicate the synth processing pipeline for synth B alongside synth A: - Independent loop lengths from LoopConfig (synth_a_length, synth_b_length) - Independent step triggering with multi-step note length support - Independent gate management (gate_samples countdown + release) - Independent LFO modulation per synth instance - Independent FX chains (saturator, reverb, delay) per synth instance - Both synths mixed mono/centered into stereo output - PlaybackPosition now reports real synth_b_step and synth_b_triggered Synth B is silent by default (empty pattern) until the user programs it. Co-Authored-By: Claude Opus 4.6 --- src/audio/engine.rs | 203 ++++++++++++++++++++++++++++++-------------- 1 file changed, 138 insertions(+), 65 deletions(-) diff --git a/src/audio/engine.rs b/src/audio/engine.rs index 739134c..ace9ca6 100644 --- a/src/audio/engine.rs +++ b/src/audio/engine.rs @@ -287,11 +287,16 @@ impl AudioEngine { } else { crate::sequencer::drum_pattern::MAX_STEPS }; - let synth_loop_len = if self.transport.loop_config.enabled { + let synth_a_loop_len = if self.transport.loop_config.enabled { self.transport.loop_config.synth_a_length as usize } else { crate::sequencer::synth_pattern::MAX_STEPS }; + let synth_b_loop_len = if self.transport.loop_config.enabled { + self.transport.loop_config.synth_b_length as usize + } else { + crate::sequencer::synth_pattern::MAX_STEPS + }; // 2. Process each sample frame (stereo interleaved) for frame in buffer.chunks_mut(2) { @@ -304,7 +309,8 @@ impl AudioEngine { ) { // Map free-running global_step into per-instrument pattern positions let drum_step = event.global_step % drum_loop_len.max(1); - let synth_step = event.global_step % synth_loop_len.max(1); + let synth_a_step = event.global_step % synth_a_loop_len.max(1); + let synth_b_step = event.global_step % synth_b_loop_len.max(1); let pattern_step = drum_step; // Trigger drum voices for active steps @@ -324,38 +330,62 @@ impl AudioEngine { } } - // Trigger synth voice for active steps (with multi-step note length) - let mut synth_triggered = false; - let synth_step_data = &self.synth_a.pattern.steps[synth_step]; let samples_per_step = (self.sample_rate * 60.0 / self.transport.bpm / 4.0) as u32; - if synth_step_data.is_active() && !self.synth_a.pattern.params.mute { - // New active note always takes priority (re-trigger) - self.synth_a.voice.trigger(&self.synth_a.pattern.params, synth_step_data.note); - synth_triggered = true; - - let length = (synth_step_data.length as usize).max(1); - if length <= 1 { - // Single-step note: gate ~75% of one step - self.synth_a.gate_samples = samples_per_step * 3 / 4; - self.synth_a.note_end_step = None; - } else { - // Multi-step note: hold for full duration minus small release window - let end_step = (synth_step + length - 1).min(synth_loop_len.max(1) - 1); - self.synth_a.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; - self.synth_a.note_end_step = Some(end_step); + // --- Synth A: trigger voice for active steps (with multi-step note length) --- + let mut synth_a_triggered = false; + { + let step_data = &self.synth_a.pattern.steps[synth_a_step]; + if step_data.is_active() && !self.synth_a.pattern.params.mute { + self.synth_a.voice.trigger(&self.synth_a.pattern.params, step_data.note); + synth_a_triggered = true; + + let length = (step_data.length as usize).max(1); + if length <= 1 { + self.synth_a.gate_samples = samples_per_step * 3 / 4; + self.synth_a.note_end_step = None; + } else { + let end_step = (synth_a_step + length - 1).min(synth_a_loop_len.max(1) - 1); + self.synth_a.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + self.synth_a.note_end_step = Some(end_step); + } + } else if let Some(end) = self.synth_a.note_end_step { + if synth_a_step == end { + self.synth_a.gate_samples = samples_per_step * 3 / 4; + self.synth_a.note_end_step = None; + } + } else if !step_data.is_active() && self.synth_a.gate_samples > 0 { + self.synth_a.voice.release(); + self.synth_a.gate_samples = 0; } - } else if let Some(end) = self.synth_a.note_end_step { - if synth_step == end { - // Last step of a long note — set gate to expire at ~75% of this step - self.synth_a.gate_samples = samples_per_step * 3 / 4; - self.synth_a.note_end_step = None; + } + + // --- Synth B: trigger voice for active steps (with multi-step note length) --- + let mut synth_b_triggered = false; + { + let step_data = &self.synth_b.pattern.steps[synth_b_step]; + if step_data.is_active() && !self.synth_b.pattern.params.mute { + self.synth_b.voice.trigger(&self.synth_b.pattern.params, step_data.note); + synth_b_triggered = true; + + let length = (step_data.length as usize).max(1); + if length <= 1 { + self.synth_b.gate_samples = samples_per_step * 3 / 4; + self.synth_b.note_end_step = None; + } else { + let end_step = (synth_b_step + length - 1).min(synth_b_loop_len.max(1) - 1); + self.synth_b.gate_samples = samples_per_step * length as u32 - samples_per_step / 4; + self.synth_b.note_end_step = Some(end_step); + } + } else if let Some(end) = self.synth_b.note_end_step { + if synth_b_step == end { + self.synth_b.gate_samples = samples_per_step * 3 / 4; + self.synth_b.note_end_step = None; + } + } else if !step_data.is_active() && self.synth_b.gate_samples > 0 { + self.synth_b.voice.release(); + self.synth_b.gate_samples = 0; } - // Otherwise we're in the middle of a long note — do nothing, let gate continue - } else if !synth_step_data.is_active() && self.synth_a.gate_samples > 0 { - // No note on this step and not covered by a long note — release - self.synth_a.voice.release(); - self.synth_a.gate_samples = 0; } // Send playback position to UI (drop if channel is full) @@ -364,22 +394,28 @@ impl AudioEngine { beat: event.beat, is_bar_start: event.is_bar_start, triggered, - synth_a_triggered: synth_triggered, + synth_a_triggered, drum_step, - synth_a_step: synth_step, - synth_b_step: 0, - synth_b_triggered: false, + synth_a_step, + synth_b_step, + synth_b_triggered, }); } } - // Decrement synth gate counter and release when expired + // Decrement synth gate counters and release when expired if self.synth_a.gate_samples > 0 { self.synth_a.gate_samples -= 1; if self.synth_a.gate_samples == 0 { self.synth_a.voice.release(); } } + if self.synth_b.gate_samples > 0 { + self.synth_b.gate_samples -= 1; + if self.synth_b.gate_samples == 0 { + self.synth_b.voice.release(); + } + } // Generate drum audio: sum all voices with per-track stereo panning let mut drum_dry_l: f32 = 0.0; @@ -414,42 +450,79 @@ impl AudioEngine { reverb_send *= drum_vol; delay_send *= drum_vol; - // Generate synth A audio with LFO modulation + per-instrument saturator - let synth_params = &self.synth_a.pattern.params; - let mut modulated_params = *synth_params; - if synth_params.lfo_depth > 0.001 { - let div_mult = lfo_division_multiplier(synth_params.lfo_division); - let lfo_val = self.synth_a.lfo.tick( - self.sample_rate, - self.transport.bpm, - div_mult, - synth_params.lfo_waveform, - ); - let mod_amount = lfo_val * synth_params.lfo_depth; - let dest_idx = synth_params.lfo_dest as usize; - if dest_idx < LFO_DEST_FIELDS.len() { - let field = LFO_DEST_FIELDS[dest_idx]; - let current = field.get(&modulated_params); - field.set(&mut modulated_params, current + mod_amount); + // --- Generate synth A audio with LFO modulation + per-instrument FX --- + let synth_a_out = { + let synth_params = &self.synth_a.pattern.params; + let mut modulated_params = *synth_params; + if synth_params.lfo_depth > 0.001 { + let div_mult = lfo_division_multiplier(synth_params.lfo_division); + let lfo_val = self.synth_a.lfo.tick( + self.sample_rate, + self.transport.bpm, + div_mult, + synth_params.lfo_waveform, + ); + let mod_amount = lfo_val * synth_params.lfo_depth; + let dest_idx = synth_params.lfo_dest as usize; + if dest_idx < LFO_DEST_FIELDS.len() { + let field = LFO_DEST_FIELDS[dest_idx]; + let current = field.get(&modulated_params); + field.set(&mut modulated_params, current + mod_amount); + } } - } - let synth_sample = self.synth_a.voice.tick(&modulated_params); - let mut synth_dry: f32 = 0.0; - if !self.synth_a.pattern.params.mute { - // tick() already applies params.volume, don't double-apply - synth_dry = synth_sample; - reverb_send += synth_sample * self.synth_a.pattern.params.send_reverb; - delay_send += synth_sample * self.synth_a.pattern.params.send_delay; - } - let synth_sat = self.synth_a.saturator.tick(synth_dry); + let synth_sample = self.synth_a.voice.tick(&modulated_params); + let mut synth_dry: f32 = 0.0; + if !self.synth_a.pattern.params.mute { + synth_dry = synth_sample; + reverb_send += synth_sample * self.synth_a.pattern.params.send_reverb; + delay_send += synth_sample * self.synth_a.pattern.params.send_delay; + } + let sa_sat = self.synth_a.saturator.tick(synth_dry); + let sa_reverb = self.synth_a.reverb.tick(sa_sat * self.synth_a.pattern.params.send_reverb); + let sa_delay = self.synth_a.delay.tick(sa_sat * self.synth_a.pattern.params.send_delay); + sa_sat + sa_reverb + sa_delay + }; + + // --- Generate synth B audio with LFO modulation + per-instrument FX --- + let synth_b_out = { + let synth_params = &self.synth_b.pattern.params; + let mut modulated_params = *synth_params; + if synth_params.lfo_depth > 0.001 { + let div_mult = lfo_division_multiplier(synth_params.lfo_division); + let lfo_val = self.synth_b.lfo.tick( + self.sample_rate, + self.transport.bpm, + div_mult, + synth_params.lfo_waveform, + ); + let mod_amount = lfo_val * synth_params.lfo_depth; + let dest_idx = synth_params.lfo_dest as usize; + if dest_idx < LFO_DEST_FIELDS.len() { + let field = LFO_DEST_FIELDS[dest_idx]; + let current = field.get(&modulated_params); + field.set(&mut modulated_params, current + mod_amount); + } + } + let synth_sample = self.synth_b.voice.tick(&modulated_params); + let mut synth_dry: f32 = 0.0; + if !self.synth_b.pattern.params.mute { + synth_dry = synth_sample; + reverb_send += synth_sample * self.synth_b.pattern.params.send_reverb; + delay_send += synth_sample * self.synth_b.pattern.params.send_delay; + } + let sb_sat = self.synth_b.saturator.tick(synth_dry); + let sb_reverb = self.synth_b.reverb.tick(sb_sat * self.synth_b.pattern.params.send_reverb); + let sb_delay = self.synth_b.delay.tick(sb_sat * self.synth_b.pattern.params.send_delay); + sb_sat + sb_reverb + sb_delay + }; - // Process send effects (drum bus — synth A uses same bus for now) + // Process send effects (drum bus) let reverb_out = self.drum_reverb.tick(reverb_send); let delay_out = self.drum_delay.tick(delay_send); // Mix: per-instrument saturated signals + wet effects → headroom → master volume → compressor → clip - // Synth + effects are mono, centered to both channels - let mono_wet = synth_sat + reverb_out + delay_out; + // Both synths centered (mono to both channels) + let mono_wet = synth_a_out + synth_b_out + reverb_out + delay_out; let mixed_l = (drum_sat_l + mono_wet) * 0.5 * self.master_volume; let mixed_r = (drum_sat_r + mono_wet) * 0.5 * self.master_volume; // Linked stereo compression: detect from mono sum, apply gain to both channels From c6071aeb6b25e1b70f34fcbc9f5214d716a27096 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:06:23 +0100 Subject: [PATCH 07/17] feat: ComputedLayout and compute_dual_layout() for dual synth panel system Add DualSynthLayout struct and compute_dual_layout() function that serves as the single source of truth for the dual-synth panel layout. Each of 7 collapsible panels (synth A knobs/grid, synth B knobs/grid, drum grid, drum knobs, waveform) gets either its expanded height or COLLAPSED_PANEL_HEIGHT (2 lines). Reclaimed vertical space from collapsed panels is distributed to growable panels (drum grid). Includes 5 unit tests. Legacy compute_layout() is preserved for existing callers until Tasks 8/10 migrate them. Co-Authored-By: Claude Opus 4.6 --- src/ui/layout.rs | 308 ++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 303 insertions(+), 5 deletions(-) diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 63f7219..1f11640 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -2,11 +2,18 @@ use ratatui::layout::{Constraint, Direction, Layout, Rect}; -/// Transport bar height (title border + 4 content lines + bottom border) +use crate::app::PanelVisibility; + +// ── Dimension constants ────────────────────────────────────────────────────── + +/// Transport bar height (title border + 4 content lines + bottom border). +/// Will increase to 7 when Task 11 adds Synth A / Synth B / Drum status lines. pub const TRANSPORT_HEIGHT: u16 = 6; /// Height of the drum knobs panel (1 label + 5 bars + 1 value + 2 border). pub const KNOBS_HEIGHT: u16 = 9; +/// Alias used by the new dual-synth layout. +pub const DRUM_KNOBS_HEIGHT: u16 = KNOBS_HEIGHT; /// Height of the synth knobs panel (OSC 8 + ENV/FILT 8 + LFO 3 + AMP 7 + 2 border = 30). pub const SYNTH_KNOBS_HEIGHT: u16 = 30; @@ -17,24 +24,32 @@ pub const SYNTH_GRID_HEIGHT: u16 = 6; /// Combined synth section height (knobs + steps). pub const SYNTH_SECTION_HEIGHT: u16 = SYNTH_KNOBS_HEIGHT + SYNTH_GRID_HEIGHT; -/// Synth section when collapsed (border + step row + border) +/// Synth section when collapsed (border + step row + border) — legacy constant. pub const SYNTH_COLLAPSED_HEIGHT: u16 = 3; +/// Minimum height for the drum grid (8 tracks + borders + header). +pub const DRUM_GRID_MIN_HEIGHT: u16 = 11; + +/// Height of a collapsed panel (1 top-border + 1 content line showing label). +pub const COLLAPSED_PANEL_HEIGHT: u16 = 2; + /// Width of the volume fader column. pub const FADER_WIDTH: u16 = 3; /// Height of the waveform/oscilloscope panel (including borders). pub const WAVEFORM_HEIGHT: u16 = 11; -/// Activity bar (bottom status line) +/// Activity bar (bottom status line). pub const ACTIVITY_BAR_HEIGHT: u16 = 1; -/// Separator line +/// Separator line. pub const SEPARATOR_HEIGHT: u16 = 1; -/// Help panel height +/// Help panel height. pub const HELP_HEIGHT: u16 = 22; +// ── Legacy ComputedLayout (used by current render + mouse code) ────────────── + /// Pre-computed layout rects, shared between render and mouse hit-testing. pub struct ComputedLayout { pub transport: Rect, @@ -55,6 +70,7 @@ pub struct ComputedLayout { } /// Compute layout for all sections. Both ui/mod.rs and mouse.rs consume this. +/// (Legacy signature — will be replaced by compute_dual_layout in Tasks 8/10.) pub fn compute_layout( size: Rect, synth_collapsed: bool, @@ -148,3 +164,285 @@ pub fn compute_layout( drum_grid, } } + +// ── Dual-synth ComputedLayout ──────────────────────────────────────────────── + +/// Pre-computed layout rects for the dual-synth panel system. +/// +/// Each panel has two rects: the expanded rect (non-empty when visible) and +/// the collapsed rect (non-empty when collapsed). They are mutually exclusive — +/// when one is set the other is `Rect::default()`. +pub struct DualSynthLayout { + pub transport: Rect, + + // Synth A + pub synth_a_knobs: Rect, + pub synth_a_grid: Rect, + pub synth_a_knobs_collapsed: Rect, + pub synth_a_grid_collapsed: Rect, + + // Synth B + pub synth_b_knobs: Rect, + pub synth_b_grid: Rect, + pub synth_b_knobs_collapsed: Rect, + pub synth_b_grid_collapsed: Rect, + + // Drums + pub drum_grid: Rect, + pub drum_knobs: Rect, + pub drum_knobs_collapsed: Rect, + + // Bottom + pub waveform: Rect, + pub waveform_collapsed: Rect, + pub activity_bar: Rect, +} + +/// Describes a single panel slot in the vertical stack. +struct PanelSlot { + expanded_height: u16, + is_visible: bool, + /// If true, this panel receives leftover space when other panels collapse. + growable: bool, +} + +/// Compute layout for the dual-synth panel system. +/// +/// Layout order (top to bottom): +/// Transport | Synth A Knobs | Synth A Grid | Synth B Knobs | Synth B Grid +/// | Drum Grid | Drum Knobs | Waveform | Activity Bar +/// +/// Collapsed panels get `COLLAPSED_PANEL_HEIGHT` (2 lines). +/// Reclaimed vertical space is given to growable panels (drum_grid). +pub fn compute_dual_layout(total: Rect, vis: &PanelVisibility) -> DualSynthLayout { + // Fixed sections: transport at top, activity bar at bottom + let fixed = TRANSPORT_HEIGHT + ACTIVITY_BAR_HEIGHT; + + // Define the 7 collapsible panels in order + let panels = [ + PanelSlot { expanded_height: SYNTH_KNOBS_HEIGHT, is_visible: vis.synth_a_knobs, growable: false }, + PanelSlot { expanded_height: SYNTH_GRID_HEIGHT, is_visible: vis.synth_a_grid, growable: false }, + PanelSlot { expanded_height: SYNTH_KNOBS_HEIGHT, is_visible: vis.synth_b_knobs, growable: false }, + PanelSlot { expanded_height: SYNTH_GRID_HEIGHT, is_visible: vis.synth_b_grid, growable: false }, + PanelSlot { expanded_height: DRUM_GRID_MIN_HEIGHT, is_visible: vis.drum_grid, growable: true }, + PanelSlot { expanded_height: DRUM_KNOBS_HEIGHT, is_visible: vis.drum_knobs, growable: false }, + PanelSlot { expanded_height: WAVEFORM_HEIGHT, is_visible: vis.waveform, growable: false }, + ]; + + // Calculate total requested height (before overflow handling) + let mut used: u16 = fixed; + for p in &panels { + if p.is_visible { + used = used.saturating_add(p.expanded_height); + } else { + used = used.saturating_add(COLLAPSED_PANEL_HEIGHT); + } + } + + // Compute extra space to distribute to growable panels + let available = total.height; + let extra = if available > used { available - used } else { 0 }; + + // Count growable visible panels + let growable_count = panels.iter().filter(|p| p.is_visible && p.growable).count() as u16; + let extra_per_growable = if growable_count > 0 { extra / growable_count } else { 0 }; + let mut extra_remainder = if growable_count > 0 { extra % growable_count } else { 0 }; + + // Assign heights for each panel + let mut heights: [u16; 7] = [0; 7]; + for (i, p) in panels.iter().enumerate() { + if p.is_visible { + heights[i] = p.expanded_height; + if p.growable { + heights[i] += extra_per_growable; + if extra_remainder > 0 { + heights[i] += 1; + extra_remainder -= 1; + } + } + } else { + heights[i] = COLLAPSED_PANEL_HEIGHT; + } + } + + // Build rects by walking y offsets + let x = total.x; + let w = total.width; + let mut y = total.y; + + // Transport + let transport = Rect::new(x, y, w, TRANSPORT_HEIGHT); + y += TRANSPORT_HEIGHT; + + // Helper: allocate a panel rect and advance y + let mut panel_rects: [(Rect, Rect); 7] = [(Rect::default(), Rect::default()); 7]; + for (i, p) in panels.iter().enumerate() { + let h = heights[i]; + let rect = Rect::new(x, y, w, h); + if p.is_visible { + panel_rects[i] = (rect, Rect::default()); // expanded, no collapsed + } else { + panel_rects[i] = (Rect::default(), rect); // no expanded, collapsed + } + y += h; + } + + // Activity bar at the bottom + let activity_bar = Rect::new(x, y, w, ACTIVITY_BAR_HEIGHT); + + DualSynthLayout { + transport, + + synth_a_knobs: panel_rects[0].0, + synth_a_knobs_collapsed: panel_rects[0].1, + synth_a_grid: panel_rects[1].0, + synth_a_grid_collapsed: panel_rects[1].1, + + synth_b_knobs: panel_rects[2].0, + synth_b_knobs_collapsed: panel_rects[2].1, + synth_b_grid: panel_rects[3].0, + synth_b_grid_collapsed: panel_rects[3].1, + + drum_grid: panel_rects[4].0, + drum_knobs: panel_rects[5].0, + drum_knobs_collapsed: panel_rects[5].1, + + waveform: panel_rects[6].0, + waveform_collapsed: panel_rects[6].1, + activity_bar, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::app::PanelVisibility; + + fn term(h: u16) -> Rect { + Rect::new(0, 0, 120, h) + } + + #[test] + fn all_expanded_fills_terminal() { + let vis = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: true, + synth_b_grid: true, + drum_grid: true, + drum_knobs: true, + waveform: true, + }; + // Minimum needed: 6 + 30 + 6 + 30 + 6 + 11 + 9 + 11 + 1 = 110 + let ly = compute_dual_layout(term(110), &vis); + + // Transport and activity bar should be at expected positions + assert_eq!(ly.transport.height, TRANSPORT_HEIGHT); + assert_eq!(ly.activity_bar.height, ACTIVITY_BAR_HEIGHT); + assert_eq!(ly.activity_bar.y + ly.activity_bar.height, 110); + + // All expanded rects should be non-empty + assert!(ly.synth_a_knobs.height > 0); + assert!(ly.synth_a_grid.height > 0); + assert!(ly.synth_b_knobs.height > 0); + assert!(ly.synth_b_grid.height > 0); + assert!(ly.drum_grid.height > 0); + assert!(ly.drum_knobs.height > 0); + assert!(ly.waveform.height > 0); + + // All collapsed rects should be empty + assert_eq!(ly.synth_a_knobs_collapsed, Rect::default()); + assert_eq!(ly.synth_b_grid_collapsed, Rect::default()); + assert_eq!(ly.waveform_collapsed, Rect::default()); + } + + #[test] + fn collapsed_panels_give_space_to_drum_grid() { + let vis_expanded = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: true, + synth_b_grid: true, + drum_grid: true, + drum_knobs: true, + waveform: true, + }; + let vis_collapsed = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: false, // collapsed + synth_b_grid: false, // collapsed + drum_grid: true, + drum_knobs: true, + waveform: true, + }; + let h = 120; + let ly_exp = compute_dual_layout(term(h), &vis_expanded); + let ly_col = compute_dual_layout(term(h), &vis_collapsed); + + // Drum grid should be bigger when synth B is collapsed + assert!(ly_col.drum_grid.height > ly_exp.drum_grid.height); + } + + #[test] + fn collapsed_panel_has_collapsed_rect() { + let vis = PanelVisibility { + synth_a_knobs: true, + synth_a_grid: true, + synth_b_knobs: false, + synth_b_grid: false, + drum_grid: true, + drum_knobs: false, + waveform: false, + }; + let ly = compute_dual_layout(term(80), &vis); + + // Synth B knobs: expanded empty, collapsed non-empty + assert_eq!(ly.synth_b_knobs, Rect::default()); + assert_eq!(ly.synth_b_knobs_collapsed.height, COLLAPSED_PANEL_HEIGHT); + + // Drum knobs: collapsed + assert_eq!(ly.drum_knobs, Rect::default()); + assert_eq!(ly.drum_knobs_collapsed.height, COLLAPSED_PANEL_HEIGHT); + + // Waveform: collapsed + assert_eq!(ly.waveform, Rect::default()); + assert_eq!(ly.waveform_collapsed.height, COLLAPSED_PANEL_HEIGHT); + } + + #[test] + fn panels_are_contiguous_vertically() { + let vis = PanelVisibility::default(); + let ly = compute_dual_layout(term(100), &vis); + + // Transport starts at 0 + assert_eq!(ly.transport.y, 0); + + // Each panel starts where the previous one ends + // synth_a_knobs follows transport + let after_transport = ly.transport.y + ly.transport.height; + let sa_knobs_y = if ly.synth_a_knobs.height > 0 { + ly.synth_a_knobs.y + } else { + ly.synth_a_knobs_collapsed.y + }; + assert_eq!(sa_knobs_y, after_transport); + } + + #[test] + fn default_visibility_layout() { + // Default: synth B collapsed, everything else expanded + let vis = PanelVisibility::default(); + let ly = compute_dual_layout(term(100), &vis); + + assert!(ly.synth_a_knobs.height > 0); + assert!(ly.synth_a_grid.height > 0); + assert_eq!(ly.synth_b_knobs, Rect::default()); + assert_eq!(ly.synth_b_grid, Rect::default()); + assert!(ly.synth_b_knobs_collapsed.height > 0); + assert!(ly.synth_b_grid_collapsed.height > 0); + assert!(ly.drum_grid.height > 0); + assert!(ly.drum_knobs.height > 0); + assert!(ly.waveform.height > 0); + } +} From 525b7789114488defdea0e77f879d7ac381fc9ee Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:10:16 +0100 Subject: [PATCH 08/17] feat: render dispatch with per-panel collapse and [.] toggles Replace compute_layout() with compute_dual_layout() in the render function. Each panel (Synth A knobs/grid, Synth B knobs/grid, drum grid, drum knobs, waveform) now renders expanded or as a collapsed bar based on PanelVisibility. Collapsed bars show "[.] PANEL NAME" with focus-aware styling. Help overlay renders as a centered overlay instead of a layout slot. Synth B panels render as collapsed bars for now (Task 9 will parameterize rendering by SynthId). Co-Authored-By: Claude Opus 4.6 --- src/ui/mod.rs | 122 +++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 96 insertions(+), 26 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 486dae3..d32ed29 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -23,6 +23,23 @@ use crate::app::{App, DrumControlField, FocusSection, ModalState, SplashPhase}; use crate::presets::PresetTarget; use crate::sequencer::drum_pattern::{NUM_DRUM_TRACKS, TRACK_IDS}; +/// Render a collapsed panel bar with [.] indicator showing it can be expanded. +fn render_collapsed_bar(f: &mut Frame, area: Rect, label: &str, focused: bool) { + if area.height == 0 || area.width == 0 { + return; + } + let style = if focused { + Style::default().fg(theme::CYAN) + } else { + Style::default().fg(theme::DIM_TEXT) + }; + let block = Block::default() + .borders(Borders::TOP) + .title(format!("[.] {}", label)) + .title_style(style); + f.render_widget(block, area); +} + /// Top-level render function. pub fn render(f: &mut Frame, app: &App) { let size = f.area(); @@ -36,40 +53,90 @@ pub fn render(f: &mut Frame, app: &App) { // Matrix reveal: render real UI first, then overlay matrix rain on unrevealed cells let matrix_active = app.ui.splash.phase == SplashPhase::MatrixReveal; - // Compute layout once — shared structure between render and mouse - let ly = compute_layout(size, app.ui.synth_collapsed, app.ui.show_help, app.ui.show_waveform); + // Compute dual-synth layout from panel visibility + let ly = compute_dual_layout(size, &app.ui.panel_vis); + // ── Transport ──────────────────────────────────────────────── transport_bar::render_transport(f, ly.transport, app); - if app.ui.synth_collapsed { - render_synth_collapsed(f, ly.synth_section, app); + + // ── Synth A Knobs ──────────────────────────────────────────── + if app.ui.panel_vis.synth_a_knobs { + synth_knobs::render_synth_knobs(f, ly.synth_a_knobs, app); } else { - render_volume_fader(f, ly.synth_fader, app.synth_a_pattern.params.volume, "SY"); - synth_knobs::render_synth_knobs(f, ly.synth_knobs, app); - synth_grid::render_synth_grid(f, ly.synth_grid, app); + let focused = matches!(app.ui.focus, FocusSection::SynthAControls); + render_collapsed_bar(f, ly.synth_a_knobs_collapsed, "SYNTH A KNOBS", focused); } - render_separator(f, ly.separator); - render_volume_fader(f, ly.drum_fader, app.effect_params.drum_volume, "DR"); - drum_grid::render_drum_grid(f, ly.drum_grid, app); - knobs::render_knobs(f, ly.knobs, app); - - if let Some(extra) = ly.extra { - if app.ui.show_help { - help_overlay::render_help(f, extra); - } else if app.ui.show_waveform { - let wave_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Length(3), // VU meter - Constraint::Min(20), // Oscilloscope - ]) - .split(extra); - waveform::render_vu_meter(f, wave_chunks[0], &app.display_buf); - waveform::render_scope_bars(f, wave_chunks[1], &app.ui.scope_bars, &app.ui.scope_intensity); - } + + // ── Synth A Grid ───────────────────────────────────────────── + if app.ui.panel_vis.synth_a_grid { + synth_grid::render_synth_grid(f, ly.synth_a_grid, app); + } else { + let focused = matches!(app.ui.focus, FocusSection::SynthAGrid); + render_collapsed_bar(f, ly.synth_a_grid_collapsed, "SYNTH A GRID", focused); + } + + // ── Synth B Knobs ──────────────────────────────────────────── + // Synth B rendering is collapsed-only for now (Task 9 parameterizes rendering) + if app.ui.panel_vis.synth_b_knobs { + // Temporary: render collapsed bar even when expanded until Task 9 parameterizes + let focused = matches!(app.ui.focus, FocusSection::SynthBControls); + render_collapsed_bar(f, ly.synth_b_knobs, "SYNTH B KNOBS (expand pending)", focused); + } else { + let focused = matches!(app.ui.focus, FocusSection::SynthBControls); + render_collapsed_bar(f, ly.synth_b_knobs_collapsed, "SYNTH B KNOBS", focused); + } + + // ── Synth B Grid ───────────────────────────────────────────── + if app.ui.panel_vis.synth_b_grid { + let focused = matches!(app.ui.focus, FocusSection::SynthBGrid); + render_collapsed_bar(f, ly.synth_b_grid, "SYNTH B GRID (expand pending)", focused); + } else { + let focused = matches!(app.ui.focus, FocusSection::SynthBGrid); + render_collapsed_bar(f, ly.synth_b_grid_collapsed, "SYNTH B GRID", focused); + } + + // ── Drum Grid ──────────────────────────────────────────────── + if app.ui.panel_vis.drum_grid { + drum_grid::render_drum_grid(f, ly.drum_grid, app); + } + + // ── Drum Knobs ─────────────────────────────────────────────── + if app.ui.panel_vis.drum_knobs { + knobs::render_knobs(f, ly.drum_knobs, app); + } else { + let focused = matches!(app.ui.focus, FocusSection::Knobs); + render_collapsed_bar(f, ly.drum_knobs_collapsed, "DRUM KNOBS", focused); } + // ── Waveform ───────────────────────────────────────────────── + if app.ui.panel_vis.waveform { + let wave_area = ly.waveform; + let wave_chunks = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Length(3), // VU meter + Constraint::Min(20), // Oscilloscope + ]) + .split(wave_area); + waveform::render_vu_meter(f, wave_chunks[0], &app.display_buf); + waveform::render_scope_bars(f, wave_chunks[1], &app.ui.scope_bars, &app.ui.scope_intensity); + } else { + render_collapsed_bar(f, ly.waveform_collapsed, "WAVEFORM", false); + } + + // ── Activity bar ───────────────────────────────────────────── render_activity_bar(f, ly.activity_bar, app); + // ── Help overlay (rendered on top, like a modal) ───────────── + if app.ui.show_help { + // Render help as a centered overlay + let help_h = HELP_HEIGHT.min(size.height.saturating_sub(2)); + let help_y = size.y + (size.height.saturating_sub(help_h)) / 2; + let help_area = Rect::new(size.x, help_y, size.width, help_h); + f.render_widget(Clear, help_area); + help_overlay::render_help(f, help_area); + } + // Matrix rain overlay (covers unrevealed cells) if matrix_active { splash::render_splash(f, size, &app.ui.splash); @@ -95,6 +162,7 @@ pub fn render(f: &mut Frame, app: &App) { // ── Separator ──────────────────────────────────────────────────────────────── +#[allow(dead_code)] fn render_separator(f: &mut Frame, area: Rect) { let line = "─".repeat(area.width as usize); f.render_widget( @@ -106,6 +174,7 @@ fn render_separator(f: &mut Frame, area: Rect) { // ── Volume faders ──────────────────────────────────────────────────────────── /// Render the synth section in collapsed mode: just a title bar with label. +#[allow(dead_code)] fn render_synth_collapsed(f: &mut Frame, area: Rect, _app: &App) { let block = Block::default() .title(" SYNTH [F2 expand] ") @@ -115,6 +184,7 @@ fn render_synth_collapsed(f: &mut Frame, area: Rect, _app: &App) { } /// Render a vertical volume fader (Hi-Fi LED style, same as VU meter). +#[allow(dead_code)] fn render_volume_fader(f: &mut Frame, area: Rect, volume: f32, _label: &str) { let block = Block::default() .borders(Borders::ALL) From 39ecd9ea12bf2be1cda10b0c8715e81aea2ffa6b Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:12:56 +0100 Subject: [PATCH 09/17] refactor: parameterize synth rendering with SynthId Both render_synth_knobs() and render_synth_grid() now accept a SynthId parameter to render either Synth A or Synth B. Updated function signatures to dynamically route pattern data and UI state based on synth_id. Changes: - synth_knobs.rs: Added synth_id parameter, routes to synth_a/synth_b pattern and UI state, updates title to show "SYNTH A" or "SYNTH B" - synth_grid.rs: Same parameterization pattern, routes pattern/ui_state/ focus_section/loop_length based on synth_id - ui/mod.rs: Updated call sites to pass SynthId::A or SynthId::B, removed placeholder collapsed bars for Synth B expanded state Both synth sections now render correctly when expanded. Co-Authored-By: Claude Opus 4.6 --- src/ui/mod.rs | 13 +++++-------- src/ui/synth_grid.rs | 36 ++++++++++++++++++++++++++++-------- src/ui/synth_knobs.rs | 28 +++++++++++++++++++--------- 3 files changed, 52 insertions(+), 25 deletions(-) diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d32ed29..d0e0065 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -20,6 +20,7 @@ use ratatui::widgets::{Block, Borders, Clear, Paragraph}; use layout::*; use crate::app::{App, DrumControlField, FocusSection, ModalState, SplashPhase}; +use crate::messages::SynthId; use crate::presets::PresetTarget; use crate::sequencer::drum_pattern::{NUM_DRUM_TRACKS, TRACK_IDS}; @@ -61,7 +62,7 @@ pub fn render(f: &mut Frame, app: &App) { // ── Synth A Knobs ──────────────────────────────────────────── if app.ui.panel_vis.synth_a_knobs { - synth_knobs::render_synth_knobs(f, ly.synth_a_knobs, app); + synth_knobs::render_synth_knobs(f, ly.synth_a_knobs, app, SynthId::A); } else { let focused = matches!(app.ui.focus, FocusSection::SynthAControls); render_collapsed_bar(f, ly.synth_a_knobs_collapsed, "SYNTH A KNOBS", focused); @@ -69,18 +70,15 @@ pub fn render(f: &mut Frame, app: &App) { // ── Synth A Grid ───────────────────────────────────────────── if app.ui.panel_vis.synth_a_grid { - synth_grid::render_synth_grid(f, ly.synth_a_grid, app); + synth_grid::render_synth_grid(f, ly.synth_a_grid, app, SynthId::A); } else { let focused = matches!(app.ui.focus, FocusSection::SynthAGrid); render_collapsed_bar(f, ly.synth_a_grid_collapsed, "SYNTH A GRID", focused); } // ── Synth B Knobs ──────────────────────────────────────────── - // Synth B rendering is collapsed-only for now (Task 9 parameterizes rendering) if app.ui.panel_vis.synth_b_knobs { - // Temporary: render collapsed bar even when expanded until Task 9 parameterizes - let focused = matches!(app.ui.focus, FocusSection::SynthBControls); - render_collapsed_bar(f, ly.synth_b_knobs, "SYNTH B KNOBS (expand pending)", focused); + synth_knobs::render_synth_knobs(f, ly.synth_b_knobs, app, SynthId::B); } else { let focused = matches!(app.ui.focus, FocusSection::SynthBControls); render_collapsed_bar(f, ly.synth_b_knobs_collapsed, "SYNTH B KNOBS", focused); @@ -88,8 +86,7 @@ pub fn render(f: &mut Frame, app: &App) { // ── Synth B Grid ───────────────────────────────────────────── if app.ui.panel_vis.synth_b_grid { - let focused = matches!(app.ui.focus, FocusSection::SynthBGrid); - render_collapsed_bar(f, ly.synth_b_grid, "SYNTH B GRID (expand pending)", focused); + synth_grid::render_synth_grid(f, ly.synth_b_grid, app, SynthId::B); } else { let focused = matches!(app.ui.focus, FocusSection::SynthBGrid); render_collapsed_bar(f, ly.synth_b_grid_collapsed, "SYNTH B GRID", focused); diff --git a/src/ui/synth_grid.rs b/src/ui/synth_grid.rs index 87e9ff8..6712e36 100644 --- a/src/ui/synth_grid.rs +++ b/src/ui/synth_grid.rs @@ -7,6 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; +use crate::messages::SynthId; use crate::sequencer::synth_pattern::MAX_STEPS; use crate::sequencer::transport::PlayState; use crate::ui::theme; @@ -16,15 +17,34 @@ const NAME_WIDTH: usize = 9; /// Renders the synth step row with note names, velocity shading, /// multi-step continuation bars, and playhead/cursor highlights. -pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { - let focused = app.ui.focus == FocusSection::SynthAGrid; +pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App, synth_id: SynthId) { + let (pattern, ui_state, focus_section, loop_len) = match synth_id { + SynthId::A => ( + &app.synth_a_pattern, + &app.ui.synth_a, + FocusSection::SynthAGrid, + app.transport.loop_config.synth_a_length, + ), + SynthId::B => ( + &app.synth_b_pattern, + &app.ui.synth_b, + FocusSection::SynthBGrid, + app.transport.loop_config.synth_b_length, + ), + }; + + let focused = app.ui.focus == focus_section; let border_style = theme::focus_border_style(focused); - let muted = app.synth_a_pattern.params.mute; + let muted = pattern.params.mute; + + let title = match synth_id { + SynthId::A => format!(" SYNTH A STEPS [{} steps] ", loop_len), + SynthId::B => format!(" SYNTH B STEPS [{} steps] ", loop_len), + }; - let loop_len = app.transport.loop_config.synth_a_length; let block = Block::default() - .title(format!(" SYNTH STEPS [{} steps] ", loop_len)) + .title(title) .title_style(Style::default().fg(theme::TITLE_COLOR).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Thick) @@ -83,7 +103,7 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { )); } - let playback_step = app.ui.synth_a.playback_step; + let playback_step = ui_state.playback_step; let is_playing = app.transport.state == PlayState::Playing; // Multi-step note tracking: `covered_until` holds the last step index covered by @@ -100,8 +120,8 @@ pub fn render_synth_grid(f: &mut Frame, area: Rect, app: &App) { )); } - let step = &app.synth_a_pattern.steps[s]; - let is_cursor = focused && s == app.ui.synth_a.cursor_step; + let step = &pattern.steps[s]; + let is_cursor = focused && s == ui_state.cursor_step; let is_playhead = is_playing && s == playback_step; let out_of_loop = s >= loop_len as usize; let is_downbeat = s % 4 == 0; diff --git a/src/ui/synth_knobs.rs b/src/ui/synth_knobs.rs index af7a90c..97b2f65 100644 --- a/src/ui/synth_knobs.rs +++ b/src/ui/synth_knobs.rs @@ -7,6 +7,7 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; +use crate::messages::SynthId; use crate::sequencer::synth_pattern::{SynthControlField, lfo_waveform_name, lfo_division_name, lfo_dest_name}; use crate::ui::theme; @@ -66,12 +67,22 @@ const ADSR_LABELS: &[&str] = &["A", "D", "S", "R"]; /// Renders the synth parameter panel with grouped slider/ADSR sections /// laid out in four row groups: OSC, ENV+FILT, LFO, and AMP. -pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { - let focused = app.ui.focus == FocusSection::SynthAControls; +pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App, synth_id: SynthId) { + let (pattern, ui, focus_section) = match synth_id { + SynthId::A => (&app.synth_a_pattern, &app.ui.synth_a, FocusSection::SynthAControls), + SynthId::B => (&app.synth_b_pattern, &app.ui.synth_b, FocusSection::SynthBControls), + }; + + let focused = app.ui.focus == focus_section; let border_style = theme::focus_border_style(focused); + let title = match synth_id { + SynthId::A => format!(" SYNTH A Oct:{} ", ui.octave), + SynthId::B => format!(" SYNTH B Oct:{} ", ui.octave), + }; + let block = Block::default() - .title(format!(" SYNTH Oct:{} ", app.ui.synth_a.octave)) + .title(title) .title_style(Style::default().fg(theme::TITLE_COLOR).add_modifier(Modifier::BOLD)) .borders(Borders::ALL) .border_type(BorderType::Thick) @@ -84,8 +95,8 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { return; } - let params = &app.synth_a_pattern.params; - let sel = app.ui.synth_a.ctrl_field; + let params = &pattern.params; + let sel = ui.ctrl_field; // Split inner into 4 row groups: OSC (8), ENV+FILT (8), LFO (3), AMP (remaining) let row_groups = Layout::default() @@ -216,7 +227,7 @@ pub fn render_synth_knobs(f: &mut Frame, area: Rect, app: &App) { row_groups[3].height.saturating_sub(1), ); - render_amp_group(f, amp_body, app, sel, focused); + render_amp_group(f, amp_body, params, app.effect_params.synth_saturator_drive, sel, focused); } } @@ -599,12 +610,11 @@ fn render_lfo_row( fn render_amp_group( f: &mut Frame, area: Rect, - app: &App, + params: &crate::sequencer::synth_pattern::SynthParams, + sat: f32, selected: SynthControlField, focused: bool, ) { - let params = &app.synth_a_pattern.params; - let sat = app.effect_params.synth_saturator_drive; // We render 4 columns: Vol, Reverb, Delay, Sat let fields_with_sat: &[(&str, f32, Option)] = &[ From 93d9a37b146c076c62bcebf514dc33e5b296ed61 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:21:06 +0100 Subject: [PATCH 10/17] feat: mouse hit-testing with compute_dual_layout, panel toggles, dual synth - Replace compute_layout() with compute_dual_layout() in mouse handler - Add panel toggle click handling ([X] to collapse, click collapsed bar to expand) - Route synth interactions to A or B based on which DualSynthLayout rect was hit - Add SynthId field to SynthDrag and SynthNoteDrag for correct drag routing - Add send_synth_b_pattern() helper to App - Update scroll, click, and drag handlers for dual synth support Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 9 ++ src/mouse.rs | 281 +++++++++++++++++++++++++++++++++++---------------- 2 files changed, 204 insertions(+), 86 deletions(-) diff --git a/src/app.rs b/src/app.rs index ca238a5..bd91d7d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -398,6 +398,7 @@ pub enum FaderKind { /// State for synth knob drag. #[derive(Clone, Debug)] pub struct SynthDrag { + pub synth_id: crate::messages::SynthId, pub field: crate::sequencer::synth_pattern::SynthControlField, pub start_y: u16, pub start_value: f32, @@ -406,6 +407,7 @@ pub struct SynthDrag { /// State for synth note length drag (horizontal resize). #[derive(Clone, Debug)] pub struct SynthNoteDrag { + pub synth_id: crate::messages::SynthId, pub step: usize, pub original_length: u8, pub start_col: u16, @@ -827,6 +829,13 @@ impl App { .send(UiToAudio::SetSynthPattern(SynthId::A, self.synth_a_pattern.clone())); } + /// Send the synth B pattern to the audio thread. + pub fn send_synth_b_pattern(&self) { + let _ = self + .tx_to_audio + .send(UiToAudio::SetSynthPattern(SynthId::B, self.synth_b_pattern.clone())); + } + /// Send effect params to the audio thread. pub fn send_effect_params(&self) { let _ = self diff --git a/src/mouse.rs b/src/mouse.rs index ddc4e54..4e5fe9b 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -7,10 +7,10 @@ use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; use ratatui::layout::{Constraint, Direction, Layout, Rect}; use crate::app::{App, CompressorDrag, DragState, DrumControlField, FocusSection, KNOB_FIELDS, ModalState, SynthDrag, SynthNoteDrag}; -use crate::messages::UiToAudio; +use crate::messages::{SynthId, UiToAudio}; use crate::sequencer::drum_pattern::{NUM_DRUM_TRACKS, TRACK_IDS}; use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; -use crate::ui::layout::compute_layout; +use crate::ui::layout::{compute_dual_layout, DualSynthLayout}; /// Threshold for double-click detection. const DOUBLE_CLICK_MS: u128 = 300; @@ -60,9 +60,9 @@ pub fn handle_mouse(app: &mut App, event: MouseEvent, term_size: Rect) { } fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) { - let ly = compute_layout(term_size, app.ui.synth_collapsed, app.ui.show_help, app.ui.show_waveform); + let ly = compute_dual_layout(term_size, &app.ui.panel_vis); - if hit_test_area(col, row, ly.knobs) { + if hit_test_area(col, row, ly.drum_knobs) { // Scroll over drum knobs: adjust currently selected drum param let track = app.ui.drum_ctrl_track; let field = app.ui.drum_ctrl_field; @@ -70,8 +70,8 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) set_param_value(&mut app.drum_pattern.params[track], field, current + delta); app.send_drum_pattern(); app.dirty = true; - } else if hit_test_area(col, row, ly.synth_knobs) { - // Scroll over synth knobs: adjust currently selected synth param + } else if hit_test_area(col, row, ly.synth_a_knobs) { + // Scroll over synth A knobs let field = app.ui.synth_a.ctrl_field; if !field.is_enum() { let current = field.get(&app.synth_a_pattern.params); @@ -79,6 +79,15 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) app.send_synth_pattern(); app.dirty = true; } + } else if hit_test_area(col, row, ly.synth_b_knobs) { + // Scroll over synth B knobs + let field = app.ui.synth_b.ctrl_field; + if !field.is_enum() { + let current = field.get(&app.synth_b_pattern.params); + field.set(&mut app.synth_b_pattern.params, (current + delta).clamp(0.0, 1.0)); + app.send_synth_b_pattern(); + app.dirty = true; + } } else if hit_test_compressor_gauge(col, row, ly.transport) { // Scroll over compressor gauge app.effect_params.compressor_amount = (app.effect_params.compressor_amount + delta).clamp(0.0, 1.0); @@ -88,62 +97,48 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) } fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { - use crate::app::{FaderDrag, FaderKind}; + let ly = compute_dual_layout(term_size, &app.ui.panel_vis); - let ly = compute_layout(term_size, app.ui.synth_collapsed, app.ui.show_help, app.ui.show_waveform); + // ── Panel toggle clicks ([X] on expanded, anywhere on collapsed) ── + if check_panel_toggle(col, row, &ly, &mut app.ui.panel_vis) { + return; + } // ── Click-to-focus on sections ────────────────────────────────── if hit_test_area(col, row, ly.transport) { app.ui.focus = FocusSection::Transport; - } else if hit_test_area(col, row, ly.synth_grid) { + } else if hit_test_area(col, row, ly.synth_a_grid) { app.ui.focus = FocusSection::SynthAGrid; - } else if hit_test_area(col, row, ly.synth_knobs) { + } else if hit_test_area(col, row, ly.synth_a_knobs) { app.ui.focus = FocusSection::SynthAControls; + } else if hit_test_area(col, row, ly.synth_b_grid) { + app.ui.focus = FocusSection::SynthBGrid; + } else if hit_test_area(col, row, ly.synth_b_knobs) { + app.ui.focus = FocusSection::SynthBControls; } else if hit_test_area(col, row, ly.drum_grid) { app.ui.focus = FocusSection::DrumGrid; - } else if hit_test_area(col, row, ly.knobs) { + } else if hit_test_area(col, row, ly.drum_knobs) { app.ui.focus = FocusSection::Knobs; } - // Check volume faders first - if hit_test_fader(col, row, ly.drum_fader) { - let value = fader_value_from_click(row, ly.drum_fader); - app.effect_params.drum_volume = value; - app.send_effect_params(); - app.dirty = true; - app.ui.mouse.fader_drag = Some(FaderDrag { - kind: FaderKind::Drum, - start_y: row, - start_value: value, - }); - return; - } - if hit_test_fader(col, row, ly.synth_fader) { - let value = fader_value_from_click(row, ly.synth_fader); - app.synth_a_pattern.params.volume = value; - app.send_synth_pattern(); - app.dirty = true; - app.ui.mouse.fader_drag = Some(FaderDrag { - kind: FaderKind::Synth, - start_y: row, - start_value: value, - }); - return; - } - - // Check each zone - if let Some(step) = hit_test_synth_step(col, row, ly.synth_grid) { - handle_synth_step_click_with_col(app, step, col); - } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_knobs) { - handle_synth_knobs_click(app, field, row); - } else if hit_test_area(col, row, ly.synth_knobs) { - app.ui.focus = FocusSection::SynthAControls; + // ── Synth A zones ─────────────────────────────────────────────── + if let Some(step) = hit_test_synth_step(col, row, ly.synth_a_grid) { + handle_synth_step_click_with_col(app, SynthId::A, step, col); + } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_a_knobs) { + handle_synth_knobs_click(app, SynthId::A, field, row); + // ── Synth B zones ─────────────────────────────────────────────── + } else if let Some(step) = hit_test_synth_step(col, row, ly.synth_b_grid) { + handle_synth_step_click_with_col(app, SynthId::B, step, col); + } else if let Some(field) = hit_test_synth_knobs(col, row, ly.synth_b_knobs) { + handle_synth_knobs_click(app, SynthId::B, field, row); + // ── Drum zones ────────────────────────────────────────────────── } else if let Some((track, step)) = hit_test_grid_step(col, row, ly.drum_grid) { handle_grid_click(app, track, step); } else if let Some((track, is_mute)) = hit_test_mute_solo(col, row, ly.drum_grid) { handle_mute_solo_click(app, track, is_mute); - } else if let Some(field) = hit_test_knobs_panel(col, row, ly.knobs) { + } else if let Some(field) = hit_test_knobs_panel(col, row, ly.drum_knobs) { handle_knobs_click(app, field, row); + // ── Bottom / transport zones ──────────────────────────────────── } else if let Some(track) = hit_test_activity_pad(col, row, ly.activity_bar) { handle_pad_click(app, track); } else if hit_test_compressor_gauge(col, row, ly.transport) { @@ -153,7 +148,6 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { } else if let Some((idx, is_synth)) = hit_test_kit_selector(col, row, ly.transport) { if is_synth { app.switch_synth_kit(idx); } else { app.switch_kit(idx); } } else if hit_test_play_button(col, row, ly.transport) { - // Task 12: Clickable play/stop toggle use crate::sequencer::transport::PlayState; app.transport.state = match app.transport.state { PlayState::Stopped => PlayState::Playing, @@ -162,7 +156,6 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { }; app.send_transport(); } else if hit_test_record_button(col, row, ly.transport) { - // Task 12: Clickable record toggle use crate::sequencer::transport::RecordMode; app.transport.record_mode = match app.transport.record_mode { RecordMode::Off => RecordMode::On, @@ -172,6 +165,86 @@ fn handle_left_down(app: &mut App, col: u16, row: u16, term_size: Rect) { } } +// ── Panel toggle click detection ───────────────────────────────────────────── + +/// Check if a click is on a panel toggle control. +/// Clicking [X] (first 4 chars) of an expanded panel's title bar collapses it. +/// Clicking anywhere on a collapsed bar expands that panel. +/// Returns true if a toggle was performed. +fn check_panel_toggle(col: u16, row: u16, ly: &DualSynthLayout, vis: &mut crate::app::PanelVisibility) -> bool { + // Helper: check collapsed bar (clicking anywhere expands) + fn check_collapsed(col: u16, row: u16, rect: Rect) -> bool { + rect.height > 0 && row >= rect.y && row < rect.y + rect.height + && col >= rect.x && col < rect.x + rect.width + } + // Helper: check expanded title bar [X] region (first 4 chars of first row) + fn check_expanded_toggle(col: u16, row: u16, rect: Rect) -> bool { + rect.height > 0 && row == rect.y && col >= rect.x && col < rect.x + 4 + } + + // Synth A Knobs + if vis.synth_a_knobs && check_expanded_toggle(col, row, ly.synth_a_knobs) { + vis.synth_a_knobs = false; + return true; + } + if !vis.synth_a_knobs && check_collapsed(col, row, ly.synth_a_knobs_collapsed) { + vis.synth_a_knobs = true; + return true; + } + + // Synth A Grid + if vis.synth_a_grid && check_expanded_toggle(col, row, ly.synth_a_grid) { + vis.synth_a_grid = false; + return true; + } + if !vis.synth_a_grid && check_collapsed(col, row, ly.synth_a_grid_collapsed) { + vis.synth_a_grid = true; + return true; + } + + // Synth B Knobs + if vis.synth_b_knobs && check_expanded_toggle(col, row, ly.synth_b_knobs) { + vis.synth_b_knobs = false; + return true; + } + if !vis.synth_b_knobs && check_collapsed(col, row, ly.synth_b_knobs_collapsed) { + vis.synth_b_knobs = true; + return true; + } + + // Synth B Grid + if vis.synth_b_grid && check_expanded_toggle(col, row, ly.synth_b_grid) { + vis.synth_b_grid = false; + return true; + } + if !vis.synth_b_grid && check_collapsed(col, row, ly.synth_b_grid_collapsed) { + vis.synth_b_grid = true; + return true; + } + + // Drum Knobs + if vis.drum_knobs && check_expanded_toggle(col, row, ly.drum_knobs) { + vis.drum_knobs = false; + return true; + } + if !vis.drum_knobs && check_collapsed(col, row, ly.drum_knobs_collapsed) { + vis.drum_knobs = true; + return true; + } + + // Waveform + if vis.waveform && check_expanded_toggle(col, row, ly.waveform) { + vis.waveform = false; + return true; + } + if !vis.waveform && check_collapsed(col, row, ly.waveform_collapsed) { + vis.waveform = true; + return true; + } + + false +} + // ── Synth step hit testing ─────────────────────────────────────────────────── /// Simple area containment check. @@ -206,7 +279,7 @@ fn hit_test_synth_step(col: u16, row: u16, grid_area: Rect) -> Option { } } -fn handle_synth_step_click(app: &mut App, step: usize) { +fn handle_synth_step_click(app: &mut App, synth_id: SynthId, step: usize) { let now = Instant::now(); let is_double = if let Some((prev_time, _prev_track, prev_step)) = app.ui.mouse.last_click { @@ -216,42 +289,52 @@ fn handle_synth_step_click(app: &mut App, step: usize) { false }; + // Route to the correct synth pattern and UI state + let (pattern, ui_state, focus) = match synth_id { + SynthId::A => (&mut app.synth_a_pattern, &mut app.ui.synth_a, FocusSection::SynthAGrid), + SynthId::B => (&mut app.synth_b_pattern, &mut app.ui.synth_b, FocusSection::SynthBGrid), + }; + if is_double { // Double-click: toggle step use crate::sequencer::synth_pattern::SynthStep; - if app.synth_a_pattern.steps[step].is_active() { - app.synth_a_pattern.steps[step] = SynthStep { note: 0, velocity: 0, length: 1 }; + if pattern.steps[step].is_active() { + pattern.steps[step] = SynthStep { note: 0, velocity: 0, length: 1 }; } else { - // Insert note at current octave - let note = 60 + (app.ui.synth_a.octave as u8).wrapping_sub(4) * 12; // C at current octave - app.synth_a_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + let note = 60 + (ui_state.octave as u8).wrapping_sub(4) * 12; + pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + } + match synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), } - app.send_synth_pattern(); app.dirty = true; app.ui.mouse.last_click = None; app.ui.mouse.synth_note_drag = None; } else { // Single click: move cursor + focus - app.ui.focus = FocusSection::SynthAGrid; - app.ui.synth_a.cursor_step = step; - app.ui.mouse.last_click = Some((now, 0, step)); // track=0 placeholder for synth + app.ui.focus = focus; + ui_state.cursor_step = step; + app.ui.mouse.last_click = Some((now, 0, step)); - if app.synth_a_pattern.steps[step].is_active() { - // Active step: start a note-length drag + if pattern.steps[step].is_active() { app.ui.mouse.synth_note_drag = Some(SynthNoteDrag { + synth_id, step, - original_length: app.synth_a_pattern.steps[step].length, - start_col: 0, // will be set from the event col in handle_left_down + original_length: pattern.steps[step].length, + start_col: 0, }); } else { - // Empty step: create a note use crate::sequencer::synth_pattern::SynthStep; - let note = 60 + (app.ui.synth_a.octave as u8).wrapping_sub(4) * 12; - app.synth_a_pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; - app.send_synth_pattern(); + let note = 60 + (ui_state.octave as u8).wrapping_sub(4) * 12; + pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; + match synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } app.dirty = true; - // Start drag so user can immediately extend the new note app.ui.mouse.synth_note_drag = Some(SynthNoteDrag { + synth_id, step, original_length: 1, start_col: 0, @@ -261,9 +344,8 @@ fn handle_synth_step_click(app: &mut App, step: usize) { } /// Variant that also records the click column for drag calculation. -fn handle_synth_step_click_with_col(app: &mut App, step: usize, col: u16) { - handle_synth_step_click(app, step); - // Update the start_col if a drag was just started +fn handle_synth_step_click_with_col(app: &mut App, synth_id: SynthId, step: usize, col: u16) { + handle_synth_step_click(app, synth_id, step); if let Some(ref mut drag) = app.ui.mouse.synth_note_drag { drag.start_col = col; } @@ -483,25 +565,34 @@ fn hit_adsr_field(col: u16, area: Rect, fields: &[SynthControlField]) -> Option< Some(fields[idx]) } -fn handle_synth_knobs_click(app: &mut App, field: SynthControlField, start_y: u16) { - app.ui.focus = FocusSection::SynthAControls; - app.ui.synth_a.ctrl_field = field; +fn handle_synth_knobs_click(app: &mut App, synth_id: SynthId, field: SynthControlField, start_y: u16) { + let (pattern, ui_state, focus) = match synth_id { + SynthId::A => (&mut app.synth_a_pattern, &mut app.ui.synth_a, FocusSection::SynthAControls), + SynthId::B => (&mut app.synth_b_pattern, &mut app.ui.synth_b, FocusSection::SynthBControls), + }; + + app.ui.focus = focus; + ui_state.ctrl_field = field; // For enum fields, just cycle on click instead of drag if field.is_enum() { let max_val: u8 = if field == SynthControlField::FilterType { 2 } else { 3 }; - let cur = field.get(&app.synth_a_pattern.params); + let cur = field.get(&pattern.params); let cur_int = (cur * max_val as f32).round() as u8; let new_int = (cur_int + 1) % (max_val + 1); - field.set(&mut app.synth_a_pattern.params, new_int as f32 / max_val as f32); - app.send_synth_pattern(); + field.set(&mut pattern.params, new_int as f32 / max_val as f32); + match synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } app.dirty = true; return; } // Start drag for continuous params - let start_value = field.get(&app.synth_a_pattern.params); + let start_value = field.get(&pattern.params); app.ui.mouse.synth_drag = Some(SynthDrag { + synth_id, field, start_y, start_value, @@ -710,18 +801,25 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { // Check if dragging a synth note (horizontal resize) if let Some(ref drag) = app.ui.mouse.synth_note_drag { let drag = drag.clone(); - // Compute the target step from the column delta - // Each step is 2 chars wide in the grid let col_delta = col as i32 - drag.start_col as i32; - let step_delta = col_delta / 2; // 2 chars per step + let step_delta = col_delta / 2; let new_length = (drag.original_length as i32 + step_delta).clamp(1, 32) as u8; - // Clamp to loop boundary - let loop_len = app.transport.loop_config.synth_a_length as usize; + let loop_len = match drag.synth_id { + SynthId::A => app.transport.loop_config.synth_a_length as usize, + SynthId::B => app.transport.loop_config.synth_b_length as usize, + }; let max_length = (loop_len - drag.step).min(32) as u8; let clamped = new_length.min(max_length).max(1); - if app.synth_a_pattern.steps[drag.step].length != clamped { - app.synth_a_pattern.steps[drag.step].length = clamped; - app.send_synth_pattern(); + let pattern = match drag.synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; + if pattern.steps[drag.step].length != clamped { + pattern.steps[drag.step].length = clamped; + match drag.synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } app.dirty = true; } return; @@ -744,8 +842,15 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { let d = d.clone(); let delta_y = d.start_y as f32 - row as f32; let new_value = (d.start_value + delta_y * DRAG_SENSITIVITY).clamp(0.0, 1.0); - d.field.set(&mut app.synth_a_pattern.params, new_value); - app.send_synth_pattern(); + let pattern = match d.synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; + d.field.set(&mut pattern.params, new_value); + match d.synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } app.dirty = true; return; } @@ -886,6 +991,8 @@ fn hit_test_pattern_selector(col: u16, row: u16, transport_area: Rect) -> Option // ── Volume fader hit testing ───────────────────────────────────────────────── /// Check if click is within a fader area. +/// (Currently unused — faders not in DualSynthLayout yet, kept for future re-use.) +#[allow(dead_code)] fn hit_test_fader(col: u16, row: u16, fader_area: Rect) -> bool { col >= fader_area.x && col < fader_area.x + fader_area.width @@ -894,6 +1001,8 @@ fn hit_test_fader(col: u16, row: u16, fader_area: Rect) -> bool { } /// Convert a click row to a volume value (0.0 at bottom, 1.0 at top). +/// (Currently unused — faders not in DualSynthLayout yet, kept for future re-use.) +#[allow(dead_code)] fn fader_value_from_click(row: u16, fader_area: Rect) -> f32 { // Inner area (inside border) let inner_top = fader_area.y + 1; From 5089dc9d73a7598afdb5bda2afff6a7a0c8a1fa5 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:23:54 +0100 Subject: [PATCH 11/17] feat: transport bar with three status lines (Synth A, Synth B, Drum) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Increased TRANSPORT_HEIGHT from 6 to 7 to accommodate 3 status lines - Replaced verbose machine_selector_line with compact status_line format - Each line shows: Label (SA/SB/DR), Pat[N], Kit[N], Loop[N] - Focused section's status line is highlighted in cyan/amber - Pattern queuing shown as Pat[N→M] when queued - Loop indicator shows actual length or "--" when disabled - Updated layout test to reflect new transport height Co-Authored-By: Claude Opus 4.6 --- src/ui/layout.rs | 14 ++-- src/ui/transport_bar.rs | 156 +++++++++++++++++++--------------------- 2 files changed, 82 insertions(+), 88 deletions(-) diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 1f11640..0a373b4 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -6,9 +6,11 @@ use crate::app::PanelVisibility; // ── Dimension constants ────────────────────────────────────────────────────── -/// Transport bar height (title border + 4 content lines + bottom border). -/// Will increase to 7 when Task 11 adds Synth A / Synth B / Drum status lines. -pub const TRANSPORT_HEIGHT: u16 = 6; +/// Transport bar height (title border + 5 content lines + bottom border). +/// Line 1: play state + BPM + beat LEDs + swing + record +/// Line 2-4: Synth A / Synth B / Drum status lines (pattern/kit/loop) +/// Line 5: master gauges +pub const TRANSPORT_HEIGHT: u16 = 7; /// Height of the drum knobs panel (1 label + 5 bars + 1 value + 2 border). pub const KNOBS_HEIGHT: u16 = 9; @@ -333,13 +335,13 @@ mod tests { drum_knobs: true, waveform: true, }; - // Minimum needed: 6 + 30 + 6 + 30 + 6 + 11 + 9 + 11 + 1 = 110 - let ly = compute_dual_layout(term(110), &vis); + // Minimum needed: 7 + 30 + 6 + 30 + 6 + 11 + 9 + 11 + 1 = 111 + let ly = compute_dual_layout(term(111), &vis); // Transport and activity bar should be at expected positions assert_eq!(ly.transport.height, TRANSPORT_HEIGHT); assert_eq!(ly.activity_bar.height, ACTIVITY_BAR_HEIGHT); - assert_eq!(ly.activity_bar.y + ly.activity_bar.height, 110); + assert_eq!(ly.activity_bar.y + ly.activity_bar.height, 111); // All expanded rects should be non-empty assert!(ly.synth_a_knobs.height > 0); diff --git a/src/ui/transport_bar.rs b/src/ui/transport_bar.rs index 97893ff..229c0c4 100644 --- a/src/ui/transport_bar.rs +++ b/src/ui/transport_bar.rs @@ -7,13 +7,9 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::{Block, BorderType, Borders, Paragraph}; use crate::app::{App, FocusSection}; -use crate::sequencer::project::{NUM_KITS, NUM_PATTERNS}; use crate::sequencer::transport::{PlayState, RecordMode}; use crate::ui::theme; -const PATTERN_KEYS: [char; 10] = ['q', 'w', 'e', 'r', 't', 'y', 'u', 'i', 'o', 'p']; -const KIT_KEYS: [char; 8] = ['1', '2', '3', '4', '5', '6', '7', '8']; - /// Draws the transport bar: play state, BPM, beat LEDs, swing, record toggle, /// pattern/kit selectors, loop indicators, and master level gauges. pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { @@ -103,35 +99,54 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { let top_line = Line::from(top_spans); - // ── Line 2: Synth machine selector + loop indicator ────────── - let synth_focused = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let synth_loop_str = if app.transport.loop_config.enabled { - format!("Loop [ON] S:{}", app.transport.loop_config.synth_a_length) + // ── Line 2: Synth A status line ─────────────────────────────── + let synth_a_focused = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let synth_a_loop_str = if app.transport.loop_config.enabled { + format!("Loop[{}]", app.transport.loop_config.synth_a_length) } else { - "Loop [OFF]".to_string() + "Loop[--]".to_string() }; - let synth_kit_name = app.project.synth_kits.get(app.ui.synth_a.active_kit) + let synth_a_kit_name = app.project.synth_kits.get(app.ui.synth_a.active_kit) .map(|k| k.name.as_str()).unwrap_or(""); - let synth_line = machine_selector_line( - "Synth", + let synth_a_line = status_line( + "SA", app.ui.synth_a.active_pattern, app.ui.synth_a.queued_pattern, app.ui.synth_a.active_kit, - synth_kit_name, - synth_focused, - &synth_loop_str, + synth_a_kit_name, + synth_a_focused, + &synth_a_loop_str, + ); + + // ── Line 3: Synth B status line ─────────────────────────────── + let synth_b_focused = matches!(app.ui.focus, FocusSection::SynthBGrid | FocusSection::SynthBControls); + let synth_b_loop_str = if app.transport.loop_config.enabled { + format!("Loop[{}]", app.transport.loop_config.synth_b_length) + } else { + "Loop[--]".to_string() + }; + let synth_b_kit_name = app.project.synth_kits.get(app.ui.synth_b.active_kit) + .map(|k| k.name.as_str()).unwrap_or(""); + let synth_b_line = status_line( + "SB", + app.ui.synth_b.active_pattern, + app.ui.synth_b.queued_pattern, + app.ui.synth_b.active_kit, + synth_b_kit_name, + synth_b_focused, + &synth_b_loop_str, ); - // ── Line 3: Drum machine selector + loop indicator ─────────── + // ── Line 4: Drum status line ────────────────────────────────── let drum_focused = matches!(app.ui.focus, FocusSection::DrumGrid | FocusSection::Knobs); let drum_loop_str = if app.transport.loop_config.enabled { - format!("Loop [ON] D:{}", app.transport.loop_config.drum_length) + format!("Loop[{}]", app.transport.loop_config.drum_length) } else { - "Loop [OFF]".to_string() + "Loop[--]".to_string() }; let drum_kit_name = app.current_kit_name(); - let drum_line = machine_selector_line( - "Drum ", + let drum_line = status_line( + "DR", app.ui.active_pattern, app.ui.queued_pattern, app.ui.active_kit, @@ -140,7 +155,7 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { &drum_loop_str, ); - // ── Line 4: Master gauges ──────────────────────────────────── + // ── Line 5: Master gauges ──────────────────────────────────── let gauge_label_style = Style::default().fg(theme::DIM_TEXT); let gauge_fill_style = Style::default().fg(theme::AMBER); let gauge_empty_style = Style::default().fg(theme::SURFACE); @@ -160,7 +175,13 @@ pub fn render_transport(f: &mut Frame, area: Rect, app: &App) { gauge_spans(sat, 4, gauge_fill_style, gauge_empty_style), ]); - let paragraph = Paragraph::new(vec![top_line, synth_line, drum_line, gauge_line]).block(block); + let paragraph = Paragraph::new(vec![ + top_line, + synth_a_line, + synth_b_line, + drum_line, + gauge_line, + ]).block(block); f.render_widget(paragraph, area); } @@ -180,19 +201,19 @@ fn gauge_spans<'a>(value: f32, width: usize, fill_style: Style, empty_style: Sty } } -/// Build a Line for a machine's pattern + kit selector row with loop indicator. -fn machine_selector_line<'a>( +/// Build a compact status line showing: Label Pat[N] Kit[N] Loop[N] +fn status_line<'a>( label: &str, active_pattern: usize, queued_pattern: Option, active_kit: usize, - kit_name: &str, + _kit_name: &str, is_focused: bool, loop_info: &str, ) -> Line<'a> { let mut spans: Vec> = Vec::new(); - // Machine label — highlighted when focused + // Section label — highlighted when focused let label_style = if is_focused { Style::default() .fg(theme::CYAN) @@ -200,69 +221,40 @@ fn machine_selector_line<'a>( } else { Style::default().fg(theme::DIM_TEXT) }; - spans.push(Span::styled(format!("{} ", label), label_style)); - - // Pattern selector - spans.push(Span::styled("Pattern: ", Style::default().fg(theme::TEXT))); - for i in 0..NUM_PATTERNS { - let is_active = active_pattern == i; - let is_queued = queued_pattern == Some(i); - let key = PATTERN_KEYS[i]; - - if is_active { - spans.push(Span::styled( - format!("[{}]", key), - Style::default() - .fg(theme::BG) - .bg(theme::AMBER) - .add_modifier(Modifier::BOLD), - )); - } else if is_queued { - spans.push(Span::styled( - format!("[{}]", key), - Style::default() - .fg(theme::BG) - .bg(theme::GOLD) - .add_modifier(Modifier::BOLD), - )); - } else { - spans.push(Span::styled(format!(" {} ", key), Style::default().fg(theme::DIM_TEXT))); - } - } + spans.push(Span::styled(format!("{}: ", label), label_style)); - spans.push(Span::styled(" Kit: ", Style::default().fg(theme::TEXT))); - for i in 0..NUM_KITS { - let is_active = active_kit == i; - let key = KIT_KEYS[i]; + // Pattern indicator (compact) + let pattern_display = if let Some(queued) = queued_pattern { + format!("Pat[{}→{}]", active_pattern + 1, queued + 1) + } else { + format!("Pat[{}]", active_pattern + 1) + }; + let pattern_style = if is_focused { + Style::default().fg(theme::AMBER) + } else { + Style::default().fg(theme::TEXT) + }; + spans.push(Span::styled(pattern_display, pattern_style)); - if is_active { - spans.push(Span::styled( - format!("[{}]", key), - Style::default() - .fg(theme::BG) - .bg(theme::AMBER) - .add_modifier(Modifier::BOLD), - )); + // Kit indicator (compact) + spans.push(Span::styled( + format!(" Kit[{}]", active_kit + 1), + if is_focused { + Style::default().fg(theme::AMBER) } else { - spans.push(Span::styled(format!(" {} ", key), Style::default().fg(theme::DIM_TEXT))); - } - } + Style::default().fg(theme::TEXT) + }, + )); - // Kit name after selector - if !kit_name.is_empty() { - spans.push(Span::styled( - format!(" {}", kit_name), - Style::default().fg(theme::AMBER), - )); - } - - // Loop indicator at end of row - let loop_style = if loop_info.contains("[ON]") { + // Loop indicator + let loop_style = if loop_info.contains("--") { + Style::default().fg(theme::DIM_TEXT) + } else if is_focused { Style::default().fg(theme::CYAN) } else { - Style::default().fg(theme::DIM_TEXT) + Style::default().fg(theme::TEXT) }; - spans.push(Span::styled(format!(" {}", loop_info), loop_style)); + spans.push(Span::styled(format!(" {}", loop_info), loop_style)); Line::from(spans) } From c5387fadaedbbcd56c4cd98f17d6d610883c8789 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:32:13 +0100 Subject: [PATCH 12/17] feat: focus-aware key bindings for dual synth, F2 bulk toggle All keyboard handlers that previously hardcoded Synth A now route to the focused synth (A or B) via a new focused_synth() helper. This covers: - F2: bulk toggle all synth panels via panel_vis - Pattern selection (Q-P), prev/next ([]{}) - Kit selection (1-8) - Loop length cycle (Shift+L) - Synth note triggers (ZXCVBNM,) - Synth grid cursor/pitch navigation - Synth control knob navigation and value adjustment - Tube saturator (Shift+T) Added App helper methods: switch_synth_pattern_for, queue_synth_pattern_for, switch_synth_kit_for for synth-id-parameterized operations. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 56 +++++++++ src/keys.rs | 349 ++++++++++++++++++++++++++++++++++------------------ 2 files changed, 288 insertions(+), 117 deletions(-) diff --git a/src/app.rs b/src/app.rs index bd91d7d..820d14f 100644 --- a/src/app.rs +++ b/src/app.rs @@ -962,6 +962,62 @@ impl App { self.send_synth_pattern(); } + // ── Focus-aware synth helpers (dual synth) ───────────────────────── + + /// Switch to a different synth pattern for the specified synth. + pub fn switch_synth_pattern_for(&mut self, synth_id: SynthId, index: usize) { + if index >= NUM_PATTERNS { return; } + match synth_id { + SynthId::A => { + self.project.save_synth_pattern(self.ui.synth_a.active_pattern, &self.synth_a_pattern); + self.ui.synth_a.active_pattern = index; + self.project.active_synth_pattern = index; + self.synth_a_pattern = SynthPattern::default(); + self.project.load_synth_pattern(index, &mut self.synth_a_pattern); + self.send_synth_pattern(); + } + SynthId::B => { + // For synth B, use synth_b_pattern (project B storage is a future task) + self.ui.synth_b.active_pattern = index; + self.synth_b_pattern = SynthPattern::default(); + self.send_synth_b_pattern(); + } + } + } + + /// Queue a synth pattern for the specified synth. + pub fn queue_synth_pattern_for(&mut self, synth_id: SynthId, index: usize) { + if index >= NUM_PATTERNS { return; } + let ui = match synth_id { + SynthId::A => &mut self.ui.synth_a, + SynthId::B => &mut self.ui.synth_b, + }; + if index == ui.active_pattern { + ui.queued_pattern = None; + } else { + ui.queued_pattern = Some(index); + } + } + + /// Switch to a different synth kit for the specified synth. + pub fn switch_synth_kit_for(&mut self, synth_id: SynthId, index: usize) { + if index >= NUM_KITS { return; } + match synth_id { + SynthId::A => { + self.project.save_synth_kit(self.ui.synth_a.active_kit, &self.synth_a_pattern.params); + self.ui.synth_a.active_kit = index; + self.project.active_synth_kit = index; + self.project.load_synth_kit(index, &mut self.synth_a_pattern); + self.send_synth_pattern(); + } + SynthId::B => { + // For synth B, just update UI state (project B storage is a future task) + self.ui.synth_b.active_kit = index; + self.send_synth_b_pattern(); + } + } + } + /// Show a brief status message. pub fn show_status(&mut self, text: String) { self.ui.status_msg = Some(StatusMessage { diff --git a/src/keys.rs b/src/keys.rs index 57e850a..a57a174 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -53,6 +53,40 @@ fn kit_key_to_index(ch: char) -> Option { } } +/// Determine which synth (A or B) is targeted by the current focus section. +/// Returns None if focus is on drums or transport. +fn focused_synth(focus: FocusSection) -> Option { + match focus { + FocusSection::SynthAGrid | FocusSection::SynthAControls => Some(SynthId::A), + FocusSection::SynthBGrid | FocusSection::SynthBControls => Some(SynthId::B), + _ => None, + } +} + +/// Get mutable reference to the SynthUiState for the given synth. +fn synth_ui_mut(app: &mut App, synth_id: SynthId) -> &mut crate::app::SynthUiState { + match synth_id { + SynthId::A => &mut app.ui.synth_a, + SynthId::B => &mut app.ui.synth_b, + } +} + +/// Get mutable references to both the SynthUiState and SynthPattern for the given synth. +fn synth_ui_and_pattern(app: &mut App, synth_id: SynthId) -> (&mut crate::app::SynthUiState, &mut crate::sequencer::synth_pattern::SynthPattern) { + match synth_id { + SynthId::A => (&mut app.ui.synth_a, &mut app.synth_a_pattern), + SynthId::B => (&mut app.ui.synth_b, &mut app.synth_b_pattern), + } +} + +/// Send the appropriate synth pattern to the audio thread. +fn send_synth(app: &App, synth_id: SynthId) { + match synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } +} + /// Main key event handler — dispatches based on modal state first. pub fn handle_key(app: &mut App, key: KeyEvent) { // Modal dialogs take priority @@ -156,9 +190,17 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { return; } - // Synth section collapse/expand toggle + // Synth section collapse/expand toggle (bulk toggle all synth panels) KeyCode::F(2) => { - app.ui.synth_collapsed = !app.ui.synth_collapsed; + let all_synth_visible = app.ui.panel_vis.synth_a_knobs + && app.ui.panel_vis.synth_a_grid + && app.ui.panel_vis.synth_b_knobs + && app.ui.panel_vis.synth_b_grid; + let new_state = !all_synth_visible; + app.ui.panel_vis.synth_a_knobs = new_state; + app.ui.panel_vis.synth_a_grid = new_state; + app.ui.panel_vis.synth_b_knobs = new_state; + app.ui.panel_vis.synth_b_grid = new_state; return; } @@ -253,15 +295,15 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Loop length cycle (Shift+L) — focus-aware KeyCode::Char('L') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - if is_synth { - app.transport.loop_config.synth_a_length = match app.transport.loop_config.synth_a_length { - 8 => 16, - 16 => 24, - 24 => 32, - _ => 8, + if let Some(synth_id) = focused_synth(app.ui.focus) { + let len_ref = match synth_id { + SynthId::A => &mut app.transport.loop_config.synth_a_length, + SynthId::B => &mut app.transport.loop_config.synth_b_length, }; - app.show_status(format!("Synth loop: {} steps", app.transport.loop_config.synth_a_length)); + *len_ref = match *len_ref { 8 => 16, 16 => 24, 24 => 32, _ => 8 }; + let new_len = *len_ref; + let label = match synth_id { SynthId::A => "Synth A", SynthId::B => "Synth B" }; + app.show_status(format!("{} loop: {} steps", label, new_len)); } else { app.transport.loop_config.drum_length = match app.transport.loop_config.drum_length { 8 => 16, @@ -353,7 +395,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Tube saturator: Shift+T cycles presets (Off → Warm → Hot → Crispy → Off) — focus-aware KeyCode::Char('T') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); + let is_synth = focused_synth(app.ui.focus).is_some(); let cur = if is_synth { app.effect_params.synth_saturator_drive } else { @@ -401,31 +443,49 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // Pattern prev/next: [ ] queued, { } immediate — focus-aware KeyCode::Char('[') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; - let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; - if is_synth { app.queue_synth_pattern(prev); } else { app.queue_pattern(prev); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let prev = if ui.active_pattern == 0 { NUM_PATTERNS - 1 } else { ui.active_pattern - 1 }; + app.queue_synth_pattern_for(synth_id, prev); + } else { + let cur = app.ui.active_pattern; + let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; + app.queue_pattern(prev); + } return; } KeyCode::Char(']') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; - let next = (cur + 1) % NUM_PATTERNS; - if is_synth { app.queue_synth_pattern(next); } else { app.queue_pattern(next); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let next = (ui.active_pattern + 1) % NUM_PATTERNS; + app.queue_synth_pattern_for(synth_id, next); + } else { + let next = (app.ui.active_pattern + 1) % NUM_PATTERNS; + app.queue_pattern(next); + } return; } KeyCode::Char('{') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; - let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; - if is_synth { app.switch_synth_pattern(prev); } else { app.switch_pattern(prev); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let prev = if ui.active_pattern == 0 { NUM_PATTERNS - 1 } else { ui.active_pattern - 1 }; + app.switch_synth_pattern_for(synth_id, prev); + } else { + let cur = app.ui.active_pattern; + let prev = if cur == 0 { NUM_PATTERNS - 1 } else { cur - 1 }; + app.switch_pattern(prev); + } return; } KeyCode::Char('}') => { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let cur = if is_synth { app.ui.synth_a.active_pattern } else { app.ui.active_pattern }; - let next = (cur + 1) % NUM_PATTERNS; - if is_synth { app.switch_synth_pattern(next); } else { app.switch_pattern(next); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + let ui = match synth_id { SynthId::A => &app.ui.synth_a, SynthId::B => &app.ui.synth_b }; + let next = (ui.active_pattern + 1) % NUM_PATTERNS; + app.switch_synth_pattern_for(synth_id, next); + } else { + let next = (app.ui.active_pattern + 1) % NUM_PATTERNS; + app.switch_pattern(next); + } return; } @@ -436,9 +496,8 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let KeyCode::Char(ch) = key.code { if let Some(idx) = pattern_key_to_index(ch) { let is_shift = key.modifiers.contains(KeyModifiers::SHIFT); - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - if is_synth { - if is_shift { app.switch_synth_pattern(idx); } else { app.queue_synth_pattern(idx); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + if is_shift { app.switch_synth_pattern_for(synth_id, idx); } else { app.queue_synth_pattern_for(synth_id, idx); } } else { if is_shift { app.switch_pattern(idx); } else { app.queue_pattern(idx); } } @@ -450,8 +509,11 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { if let KeyCode::Char(ch) = key.code { if let Some(idx) = kit_key_to_index(ch) { if idx < NUM_KITS { - let is_synth = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - if is_synth { app.switch_synth_kit(idx); } else { app.switch_kit(idx); } + if let Some(synth_id) = focused_synth(app.ui.focus) { + app.switch_synth_kit_for(synth_id, idx); + } else { + app.switch_kit(idx); + } } return; } @@ -460,22 +522,35 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { // ── Drum pad keys / Synth note keys (ZXCVBNM,) ───────────────────── if let KeyCode::Char(ch) = key.code { // When synth grid/controls is focused, use as chromatic keyboard - if matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls) { + if let Some(synth_id) = focused_synth(app.ui.focus) { if let Some(semitone) = synth_key_to_semitone(ch) { - let note = (app.ui.synth_a.octave * 12 + semitone).min(127); + let (ui, pattern) = match synth_id { + SynthId::A => (&mut app.ui.synth_a, &mut app.synth_a_pattern), + SynthId::B => (&mut app.ui.synth_b, &mut app.synth_b_pattern), + }; + let note = (ui.octave * 12 + semitone).min(127); // Trigger synth sound - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_a.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); + ui.flash = 6; // If on synth grid, write note at cursor - if app.ui.focus == FocusSection::SynthAGrid { - let s = app.ui.synth_a.cursor_step; - app.synth_a_pattern.steps[s].note = note; - app.synth_a_pattern.steps[s].velocity = 100; - app.send_synth_pattern(); + let is_grid = matches!(app.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthBGrid); + if is_grid { + let s = ui.cursor_step; + pattern.steps[s].note = note; + pattern.steps[s].velocity = 100; + match synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } app.dirty = true; // Advance cursor - app.ui.synth_a.cursor_step = (app.ui.synth_a.cursor_step + 1) % SYNTH_MAX_STEPS; + // Re-borrow to avoid conflict — we already wrote above + let ui2 = match synth_id { + SynthId::A => &mut app.ui.synth_a, + SynthId::B => &mut app.ui.synth_b, + }; + ui2.cursor_step = (ui2.cursor_step + 1) % SYNTH_MAX_STEPS; } // If recording + playing, write at playhead @@ -484,9 +559,16 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { { let step = app.ui.playback_step; if step < SYNTH_MAX_STEPS { - app.synth_a_pattern.steps[step].note = note; - app.synth_a_pattern.steps[step].velocity = 100; - app.send_synth_pattern(); + let pattern = match synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; + pattern.steps[step].note = note; + pattern.steps[step].velocity = 100; + match synth_id { + SynthId::A => app.send_synth_pattern(), + SynthId::B => app.send_synth_b_pattern(), + } app.dirty = true; } } @@ -520,10 +602,10 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { match app.ui.focus { FocusSection::DrumGrid => handle_drum_grid(app, key), FocusSection::Knobs => handle_knobs(app, key), - FocusSection::SynthAGrid => handle_synth_grid(app, key), - FocusSection::SynthAControls => handle_synth_controls(app, key), - FocusSection::SynthBGrid => handle_synth_grid(app, key), - FocusSection::SynthBControls => handle_synth_controls(app, key), + FocusSection::SynthAGrid => handle_synth_grid(app, key, SynthId::A), + FocusSection::SynthAControls => handle_synth_controls(app, key, SynthId::A), + FocusSection::SynthBGrid => handle_synth_grid(app, key, SynthId::B), + FocusSection::SynthBControls => handle_synth_controls(app, key, SynthId::B), FocusSection::Transport => {} // transport keys are all global } } @@ -735,100 +817,124 @@ fn handle_knobs(app: &mut App, key: KeyEvent) { // ── Synth Grid ───────────────────────────────────────────────────────────── -fn handle_synth_grid(app: &mut App, key: KeyEvent) { +fn handle_synth_grid(app: &mut App, key: KeyEvent, synth_id: SynthId) { + // Helper macro-like closures aren't ideal; use direct match for each synth + let controls_focus = match synth_id { + SynthId::A => FocusSection::SynthAControls, + SynthId::B => FocusSection::SynthBControls, + }; + let label = match synth_id { SynthId::A => "Synth A", SynthId::B => "Synth B" }; + match key.code { // Shift+Left: decrease note length KeyCode::Left if key.modifiers.contains(KeyModifiers::SHIFT) => { - let s = app.ui.synth_a.cursor_step; - if app.synth_a_pattern.steps[s].is_active() && app.synth_a_pattern.steps[s].length > 1 { - app.synth_a_pattern.steps[s].length -= 1; - app.send_synth_pattern(); + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() && pattern.steps[s].length > 1 { + pattern.steps[s].length -= 1; + send_synth(app, synth_id); app.dirty = true; } } // Shift+Right: increase note length KeyCode::Right if key.modifiers.contains(KeyModifiers::SHIFT) => { - let s = app.ui.synth_a.cursor_step; - if app.synth_a_pattern.steps[s].is_active() { - let loop_len = app.transport.loop_config.synth_a_length as usize; + let loop_len = match synth_id { + SynthId::A => app.transport.loop_config.synth_a_length as usize, + SynthId::B => app.transport.loop_config.synth_b_length as usize, + }; + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() { let max_length = (loop_len - s).min(32) as u8; - if app.synth_a_pattern.steps[s].length < max_length { - app.synth_a_pattern.steps[s].length += 1; - app.send_synth_pattern(); + if pattern.steps[s].length < max_length { + pattern.steps[s].length += 1; + send_synth(app, synth_id); app.dirty = true; } } } KeyCode::Left => { - app.ui.synth_a.cursor_step = if app.ui.synth_a.cursor_step == 0 { + let ui = synth_ui_mut(app, synth_id); + ui.cursor_step = if ui.cursor_step == 0 { SYNTH_MAX_STEPS - 1 } else { - app.ui.synth_a.cursor_step - 1 + ui.cursor_step - 1 }; } KeyCode::Right => { - if app.ui.synth_a.cursor_step == SYNTH_MAX_STEPS - 1 { + let ui = synth_ui_mut(app, synth_id); + if ui.cursor_step == SYNTH_MAX_STEPS - 1 { // Move into synth controls - app.ui.focus = FocusSection::SynthAControls; + app.ui.focus = controls_focus; } else { - app.ui.synth_a.cursor_step += 1; + let ui = synth_ui_mut(app, synth_id); + ui.cursor_step += 1; } } KeyCode::Up => { // Change note pitch up (semitone), or Shift for octave - let s = app.ui.synth_a.cursor_step; - if app.synth_a_pattern.steps[s].is_active() { + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() { let delta = if key.modifiers.contains(KeyModifiers::SHIFT) { 12 } else { 1 }; - app.synth_a_pattern.steps[s].note = (app.synth_a_pattern.steps[s].note + delta).min(127); - let note = app.synth_a_pattern.steps[s].note; - app.send_synth_pattern(); + pattern.steps[s].note = (pattern.steps[s].note + delta).min(127); + let note = pattern.steps[s].note; + ui.flash = 6; + send_synth(app, synth_id); app.dirty = true; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_a.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Down => { - let s = app.ui.synth_a.cursor_step; - if app.synth_a_pattern.steps[s].is_active() { + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + if pattern.steps[s].is_active() { let delta = if key.modifiers.contains(KeyModifiers::SHIFT) { 12 } else { 1 }; - app.synth_a_pattern.steps[s].note = app.synth_a_pattern.steps[s].note.saturating_sub(delta).max(12); - let note = app.synth_a_pattern.steps[s].note; - app.send_synth_pattern(); + pattern.steps[s].note = pattern.steps[s].note.saturating_sub(delta).max(12); + let note = pattern.steps[s].note; + ui.flash = 6; + send_synth(app, synth_id); app.dirty = true; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_a.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Enter => { - let s = app.ui.synth_a.cursor_step; - let step = &mut app.synth_a_pattern.steps[s]; + let (ui, pattern) = synth_ui_and_pattern(app, synth_id); + let s = ui.cursor_step; + let octave = ui.octave; + let step = &mut pattern.steps[s]; if step.is_active() { // Toggle off — reset length step.velocity = 0; step.length = 1; } else { // Toggle on with default note at current octave - step.note = app.ui.synth_a.octave * 12 + 12; // C at current octave + step.note = octave * 12 + 12; // C at current octave step.velocity = 100; step.length = 1; } - app.send_synth_pattern(); + send_synth(app, synth_id); app.dirty = true; // Advance cursor - app.ui.synth_a.cursor_step = (app.ui.synth_a.cursor_step + 1) % SYNTH_MAX_STEPS; + let ui = synth_ui_mut(app, synth_id); + ui.cursor_step = (ui.cursor_step + 1) % SYNTH_MAX_STEPS; } KeyCode::Char('(') => { // Octave down - if app.ui.synth_a.octave > 0 { - app.ui.synth_a.octave -= 1; - app.show_status(format!("Synth octave: {}", app.ui.synth_a.octave)); + let ui = synth_ui_mut(app, synth_id); + if ui.octave > 0 { + ui.octave -= 1; + let oct = ui.octave; + app.show_status(format!("{} octave: {}", label, oct)); } } KeyCode::Char(')') => { // Octave up - if app.ui.synth_a.octave < 8 { - app.ui.synth_a.octave += 1; - app.show_status(format!("Synth octave: {}", app.ui.synth_a.octave)); + let ui = synth_ui_mut(app, synth_id); + if ui.octave < 8 { + ui.octave += 1; + let oct = ui.octave; + app.show_status(format!("{} octave: {}", label, oct)); } } _ => {} @@ -872,65 +978,74 @@ fn find_synth_field_pos(field: SynthControlField) -> (usize, usize) { (0, 0) } -fn handle_synth_controls(app: &mut App, key: KeyEvent) { +fn handle_synth_controls(app: &mut App, key: KeyEvent, synth_id: SynthId) { let has_shift = key.modifiers.contains(KeyModifiers::SHIFT); let has_alt = key.modifiers.contains(KeyModifiers::ALT); match key.code { KeyCode::Left => { - let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if c > 0 { - app.ui.synth_a.ctrl_field = SYNTH_CTRL_ROWS[r][c - 1]; + ui.ctrl_field = SYNTH_CTRL_ROWS[r][c - 1]; } - // At leftmost field: do nothing (no cross-box nav) } KeyCode::Right => { - let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if c + 1 < SYNTH_CTRL_ROWS[r].len() { - app.ui.synth_a.ctrl_field = SYNTH_CTRL_ROWS[r][c + 1]; + ui.ctrl_field = SYNTH_CTRL_ROWS[r][c + 1]; } - // At rightmost field: do nothing (no cross-box nav) } KeyCode::Up if has_shift || has_alt => { - adjust_synth_field(app, PARAM_INCREMENT); + adjust_synth_field(app, synth_id, PARAM_INCREMENT); if has_alt { - let note = app.ui.synth_a.octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_a.flash = 6; + let ui = synth_ui_mut(app, synth_id); + let note = ui.octave * 12 + 12; + ui.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Down if has_shift || has_alt => { - adjust_synth_field(app, -PARAM_INCREMENT); + adjust_synth_field(app, synth_id, -PARAM_INCREMENT); if has_alt { - let note = app.ui.synth_a.octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_a.flash = 6; + let ui = synth_ui_mut(app, synth_id); + let note = ui.octave * 12 + 12; + ui.flash = 6; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); } } KeyCode::Up => { - let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if r > 0 { let new_row = &SYNTH_CTRL_ROWS[r - 1]; let new_c = c.min(new_row.len() - 1); - app.ui.synth_a.ctrl_field = new_row[new_c]; + ui.ctrl_field = new_row[new_c]; } } KeyCode::Down => { - let (r, c) = find_synth_field_pos(app.ui.synth_a.ctrl_field); + let ui = synth_ui_mut(app, synth_id); + let (r, c) = find_synth_field_pos(ui.ctrl_field); if r + 1 < SYNTH_CTRL_ROWS.len() { let new_row = &SYNTH_CTRL_ROWS[r + 1]; let new_c = c.min(new_row.len() - 1); - app.ui.synth_a.ctrl_field = new_row[new_c]; + ui.ctrl_field = new_row[new_c]; } } _ => {} } } -fn adjust_synth_field(app: &mut App, delta: f32) { - let field = app.ui.synth_a.ctrl_field; +fn adjust_synth_field(app: &mut App, synth_id: SynthId, delta: f32) { + let ui = synth_ui_mut(app, synth_id); + let field = ui.ctrl_field; + let pattern = match synth_id { + SynthId::A => &mut app.synth_a_pattern, + SynthId::B => &mut app.synth_b_pattern, + }; if field == SynthControlField::Mute { - app.synth_a_pattern.params.mute = !app.synth_a_pattern.params.mute; + pattern.params.mute = !pattern.params.mute; } else if field.is_enum() { let max_val: u8 = match field { SynthControlField::FilterType => 2, @@ -939,19 +1054,19 @@ fn adjust_synth_field(app: &mut App, delta: f32) { SynthControlField::LfoDest => (crate::sequencer::synth_pattern::LFO_DEST_FIELDS.len() - 1) as u8, _ => 3, // Osc1/Osc2 waveforms }; - let cur = field.get(&app.synth_a_pattern.params); + let cur = field.get(&pattern.params); let cur_int = (cur * max_val as f32).round() as u8; let new_int = if delta > 0.0 { (cur_int + 1).min(max_val) } else { cur_int.saturating_sub(1) }; - field.set(&mut app.synth_a_pattern.params, new_int as f32 / max_val as f32); + field.set(&mut pattern.params, new_int as f32 / max_val as f32); } else { - let cur = field.get(&app.synth_a_pattern.params); - field.set(&mut app.synth_a_pattern.params, cur + delta); + let cur = field.get(&pattern.params); + field.set(&mut pattern.params, cur + delta); } - app.send_synth_pattern(); + send_synth(app, synth_id); app.dirty = true; } From 45a382b7a53d4ffd5ea3c400d8aa465fd640c709 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:34:18 +0100 Subject: [PATCH 13/17] feat: wire dual synth playback position to UI state - Route synth_b_step to app.ui.synth_b.playback_step - Add flash animation for synth B triggers - Use actual field names instead of aliases for clarity - Update comment to reflect synth A specific logic Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 22 +++++++++++++--------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/src/app.rs b/src/app.rs index 820d14f..907289c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -715,14 +715,15 @@ impl App { beat, is_bar_start, triggered, - synth_a_triggered: synth_triggered, + synth_a_triggered, drum_step, - synth_a_step: synth_step, - synth_b_step: _, - synth_b_triggered: _, + synth_a_step, + synth_b_step, + synth_b_triggered, } => { self.ui.playback_step = drum_step; - self.ui.synth_a.playback_step = synth_step; + self.ui.synth_a.playback_step = synth_a_step; + self.ui.synth_b.playback_step = synth_b_step; self.ui.current_beat = beat; self.ui.is_bar_start = is_bar_start; @@ -733,8 +734,8 @@ impl App { } } - // Check for queued synth pattern switch at loop wrap (step 0) - if synth_step == 0 && global_step > 0 { + // Check for queued synth A pattern switch at loop wrap (step 0) + if synth_a_step == 0 && global_step > 0 { if let Some(next) = self.ui.synth_a.queued_pattern.take() { self.switch_synth_pattern(next); } @@ -747,10 +748,13 @@ impl App { } } - // Flash synth - if synth_triggered { + // Flash synths + if synth_a_triggered { self.ui.synth_a.flash = FLASH_FRAMES; } + if synth_b_triggered { + self.ui.synth_b.flash = FLASH_FRAMES; + } } } } From 6a1c1bff35d01249e90840e8c8abc54400afbaab Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:39:56 +0100 Subject: [PATCH 14/17] feat: App helper methods for dual synth pattern/kit management Consolidate synth helper methods to support dual synth architecture: - Replace send_synth_pattern() and send_synth_b_pattern() with unified send_synth_pattern(synth_id: SynthId) - Update apply_synth_preset() and apply_synth_pattern_preset() to accept SynthId parameter - Update all call sites in keys.rs, mouse.rs, and app.rs internal methods - Simplify send_synth() helper in keys.rs to use new unified API Old single-synth methods (switch_synth_pattern, queue_synth_pattern, switch_synth_kit) remain for legacy compatibility but updated to use new API internally. New _for() variants with SynthId parameter are preferred for dual synth operations. Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 61 +++++++++++++++++++++++++++++----------------------- src/keys.rs | 21 ++++++------------ src/mouse.rs | 28 ++++++++++-------------- 3 files changed, 51 insertions(+), 59 deletions(-) diff --git a/src/app.rs b/src/app.rs index 907289c..652a554 100644 --- a/src/app.rs +++ b/src/app.rs @@ -826,18 +826,15 @@ impl App { .send(UiToAudio::SetDrumPattern(self.drum_pattern.clone())); } - /// Send the synth pattern to the audio thread. - pub fn send_synth_pattern(&self) { - let _ = self - .tx_to_audio - .send(UiToAudio::SetSynthPattern(SynthId::A, self.synth_a_pattern.clone())); - } - - /// Send the synth B pattern to the audio thread. - pub fn send_synth_b_pattern(&self) { + /// Send the synth pattern to the audio thread for the specified synth. + pub fn send_synth_pattern(&self, synth_id: SynthId) { + let pattern = match synth_id { + SynthId::A => &self.synth_a_pattern, + SynthId::B => &self.synth_b_pattern, + }; let _ = self .tx_to_audio - .send(UiToAudio::SetSynthPattern(SynthId::B, self.synth_b_pattern.clone())); + .send(UiToAudio::SetSynthPattern(synth_id, pattern.clone())); } /// Send effect params to the audio thread. @@ -940,7 +937,7 @@ impl App { self.project.active_synth_pattern = index; self.synth_a_pattern = SynthPattern::default(); self.project.load_synth_pattern(index, &mut self.synth_a_pattern); - self.send_synth_pattern(); + self.send_synth_pattern(SynthId::A); } /// Queue a synth pattern to switch at end of current loop. @@ -963,7 +960,7 @@ impl App { self.ui.synth_a.active_kit = index; self.project.active_synth_kit = index; self.project.load_synth_kit(index, &mut self.synth_a_pattern); - self.send_synth_pattern(); + self.send_synth_pattern(SynthId::A); } // ── Focus-aware synth helpers (dual synth) ───────────────────────── @@ -978,13 +975,13 @@ impl App { self.project.active_synth_pattern = index; self.synth_a_pattern = SynthPattern::default(); self.project.load_synth_pattern(index, &mut self.synth_a_pattern); - self.send_synth_pattern(); + self.send_synth_pattern(SynthId::A); } SynthId::B => { // For synth B, use synth_b_pattern (project B storage is a future task) self.ui.synth_b.active_pattern = index; self.synth_b_pattern = SynthPattern::default(); - self.send_synth_b_pattern(); + self.send_synth_pattern(SynthId::B); } } } @@ -1012,12 +1009,12 @@ impl App { self.ui.synth_a.active_kit = index; self.project.active_synth_kit = index; self.project.load_synth_kit(index, &mut self.synth_a_pattern); - self.send_synth_pattern(); + self.send_synth_pattern(SynthId::A); } SynthId::B => { // For synth B, just update UI state (project B storage is a future task) self.ui.synth_b.active_kit = index; - self.send_synth_b_pattern(); + self.send_synth_pattern(SynthId::B); } } } @@ -1252,11 +1249,17 @@ impl App { pub fn apply_synth_pattern_preset( &mut self, + synth_id: SynthId, preset_steps: &[(u8, u8, u8); crate::sequencer::synth_pattern::MAX_STEPS], merge: crate::presets::PatternMergeMode, ) { use crate::sequencer::synth_pattern::{MAX_STEPS, SynthStep}; + let pattern = match synth_id { + SynthId::A => &mut self.synth_a_pattern, + SynthId::B => &mut self.synth_b_pattern, + }; + let fill_len = if self.transport.loop_config.enabled { self.transport.loop_config.synth_a_length as usize } else { @@ -1273,27 +1276,31 @@ impl App { if vel > 0 { match merge { crate::presets::PatternMergeMode::Replace => { - self.synth_a_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; + pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; } crate::presets::PatternMergeMode::Layer => { - if !self.synth_a_pattern.steps[s].is_active() { - self.synth_a_pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; + if !pattern.steps[s].is_active() { + pattern.steps[s] = SynthStep { note, velocity: vel, length: len }; } } } } else if matches!(merge, crate::presets::PatternMergeMode::Replace) { - self.synth_a_pattern.steps[s] = SynthStep::default(); + pattern.steps[s] = SynthStep::default(); } } - self.send_synth_pattern(); + self.send_synth_pattern(synth_id); self.dirty = true; } - pub fn apply_synth_preset(&mut self, params: crate::sequencer::synth_pattern::SynthParams) { - let mute = self.synth_a_pattern.params.mute; - self.synth_a_pattern.params = params; - self.synth_a_pattern.params.mute = mute; - self.send_synth_pattern(); + pub fn apply_synth_preset(&mut self, synth_id: SynthId, params: crate::sequencer::synth_pattern::SynthParams) { + let pattern = match synth_id { + SynthId::A => &mut self.synth_a_pattern, + SynthId::B => &mut self.synth_b_pattern, + }; + let mute = pattern.params.mute; + pattern.params = params; + pattern.params.mute = mute; + self.send_synth_pattern(synth_id); self.dirty = true; } @@ -1330,7 +1337,7 @@ impl App { self.synth_a_pattern = SynthPattern::default(); self.project.load_synth_pattern(self.ui.synth_a.active_pattern, &mut self.synth_a_pattern); self.project.load_synth_kit(self.ui.synth_a.active_kit, &mut self.synth_a_pattern); - self.send_synth_pattern(); + self.send_synth_pattern(SynthId::A); self.dirty = false; self.show_status(format!("Loaded: {}", name)); diff --git a/src/keys.rs b/src/keys.rs index a57a174..36745a9 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -81,10 +81,7 @@ fn synth_ui_and_pattern(app: &mut App, synth_id: SynthId) -> (&mut crate::app::S /// Send the appropriate synth pattern to the audio thread. fn send_synth(app: &App, synth_id: SynthId) { - match synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), - } + app.send_synth_pattern(synth_id); } /// Main key event handler — dispatches based on modal state first. @@ -539,10 +536,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { let s = ui.cursor_step; pattern.steps[s].note = note; pattern.steps[s].velocity = 100; - match synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), - } + app.send_synth_pattern(synth_id); app.dirty = true; // Advance cursor // Re-borrow to avoid conflict — we already wrote above @@ -565,10 +559,7 @@ pub fn handle_key(app: &mut App, key: KeyEvent) { }; pattern.steps[step].note = note; pattern.steps[step].velocity = 100; - match synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), - } + app.send_synth_pattern(synth_id); app.dirty = true; } } @@ -1170,7 +1161,7 @@ fn handle_preset_browser(app: &mut App, key: KeyEvent) { PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { let name = browser.preset_names.get(browser.preset_idx).copied().unwrap_or("?"); - app.apply_synth_preset(params); + app.apply_synth_preset(SynthId::A, params); app.ui.modal = ModalState::None; app.show_status(format!("Loaded: {}", name)); } @@ -1234,7 +1225,7 @@ fn handle_pattern_browser(app: &mut App, key: KeyEvent) { app.show_status(format!("{}: {}", mode_label, name)); } else if let Some(preset) = pb.browser.selected_synth_pattern() { let name = preset.name; - app.apply_synth_pattern_preset(&preset.steps, merge); + app.apply_synth_pattern_preset(SynthId::A, &preset.steps, merge); app.show_status(format!("{}: {}", mode_label, name)); } } @@ -1265,7 +1256,7 @@ fn preview_preset(app: &mut App) { } PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { - app.apply_synth_preset(params); + app.apply_synth_preset(SynthId::A, params); let note = app.ui.synth_a.octave * 12 + 12; let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); app.ui.synth_a.flash = 6; diff --git a/src/mouse.rs b/src/mouse.rs index 4e5fe9b..6504dc3 100644 --- a/src/mouse.rs +++ b/src/mouse.rs @@ -76,7 +76,7 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) if !field.is_enum() { let current = field.get(&app.synth_a_pattern.params); field.set(&mut app.synth_a_pattern.params, (current + delta).clamp(0.0, 1.0)); - app.send_synth_pattern(); + app.send_synth_pattern(SynthId::A); app.dirty = true; } } else if hit_test_area(col, row, ly.synth_b_knobs) { @@ -85,7 +85,7 @@ fn handle_scroll(app: &mut App, col: u16, row: u16, delta: f32, term_size: Rect) if !field.is_enum() { let current = field.get(&app.synth_b_pattern.params); field.set(&mut app.synth_b_pattern.params, (current + delta).clamp(0.0, 1.0)); - app.send_synth_b_pattern(); + app.send_synth_pattern(SynthId::B); app.dirty = true; } } else if hit_test_compressor_gauge(col, row, ly.transport) { @@ -305,8 +305,8 @@ fn handle_synth_step_click(app: &mut App, synth_id: SynthId, step: usize) { pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; } match synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), + SynthId::A => app.send_synth_pattern(SynthId::A), + SynthId::B => app.send_synth_pattern(SynthId::B), } app.dirty = true; app.ui.mouse.last_click = None; @@ -328,10 +328,7 @@ fn handle_synth_step_click(app: &mut App, synth_id: SynthId, step: usize) { use crate::sequencer::synth_pattern::SynthStep; let note = 60 + (ui_state.octave as u8).wrapping_sub(4) * 12; pattern.steps[step] = SynthStep { note, velocity: 100, length: 1 }; - match synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), - } + app.send_synth_pattern(synth_id); app.dirty = true; app.ui.mouse.synth_note_drag = Some(SynthNoteDrag { synth_id, @@ -582,8 +579,8 @@ fn handle_synth_knobs_click(app: &mut App, synth_id: SynthId, field: SynthContro let new_int = (cur_int + 1) % (max_val + 1); field.set(&mut pattern.params, new_int as f32 / max_val as f32); match synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), + SynthId::A => app.send_synth_pattern(SynthId::A), + SynthId::B => app.send_synth_pattern(SynthId::B), } app.dirty = true; return; @@ -816,10 +813,7 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { }; if pattern.steps[drag.step].length != clamped { pattern.steps[drag.step].length = clamped; - match drag.synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), - } + app.send_synth_pattern(drag.synth_id); app.dirty = true; } return; @@ -848,8 +842,8 @@ fn handle_drag(app: &mut App, col: u16, row: u16, _term_size: Rect) { }; d.field.set(&mut pattern.params, new_value); match d.synth_id { - SynthId::A => app.send_synth_pattern(), - SynthId::B => app.send_synth_b_pattern(), + SynthId::A => app.send_synth_pattern(SynthId::A), + SynthId::B => app.send_synth_pattern(SynthId::B), } app.dirty = true; return; @@ -1033,7 +1027,7 @@ fn handle_fader_drag(app: &mut App, row: u16) { } FaderKind::Synth => { app.synth_a_pattern.params.volume = new_value; - app.send_synth_pattern(); + app.send_synth_pattern(SynthId::A); } } app.dirty = true; From ff319f250eb40ed22d8813030c88b05c114b4d1c Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:44:52 +0100 Subject: [PATCH 15/17] feat: ProjectFile dual synth storage with backward compatibility Added Synth B fields to ProjectFile struct for persisting dual synth state: - synth_b_kits: Vec - active_synth_b_kit: usize - synth_b_patterns: Vec - active_synth_b_pattern: usize All fields use #[serde(default)] for backward compatibility with old project files. Added save/load methods: - save_synth_b_pattern() / load_synth_b_pattern() - save_synth_b_kit() / load_synth_b_kit() Updated normalize() to ensure Synth B arrays are properly initialized. Updated Default impl and demo_project() to initialize Synth B data. Added tests: - test_project_roundtrip_dual_synth: verifies Synth B data survives serialization - test_old_project_loads_with_synth_b_defaults: verifies backward compatibility Co-Authored-By: Claude Opus 4.6 --- src/sequencer/project.rs | 177 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 177 insertions(+) diff --git a/src/sequencer/project.rs b/src/sequencer/project.rs index a9ead3f..001ee1b 100644 --- a/src/sequencer/project.rs +++ b/src/sequencer/project.rs @@ -398,6 +398,14 @@ pub struct ProjectFile { pub synth_patterns: Vec, #[serde(default)] pub active_synth_pattern: usize, + #[serde(default)] + pub synth_b_kits: Vec, + #[serde(default)] + pub active_synth_b_kit: usize, + #[serde(default)] + pub synth_b_patterns: Vec, + #[serde(default)] + pub active_synth_b_pattern: usize, } fn default_bpm() -> f64 { 120.0 } @@ -452,6 +460,20 @@ impl Default for ProjectFile { ..Default::default() }); } + let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS); + for i in 0..NUM_PATTERNS { + synth_b_patterns.push(SynthPatternData { + name: format!("Synth B {}", i + 1), + ..Default::default() + }); + } + let mut synth_b_kits = Vec::with_capacity(NUM_KITS); + for i in 0..NUM_KITS { + synth_b_kits.push(SynthKitData { + name: format!("Synth B Kit {}", i + 1), + ..Default::default() + }); + } Self { textstep: FileHeader::default(), metadata: ProjectMetadata { @@ -471,6 +493,10 @@ impl Default for ProjectFile { active_synth_kit: 0, synth_patterns, active_synth_pattern: 0, + synth_b_kits, + active_synth_b_kit: 0, + synth_b_patterns, + active_synth_b_pattern: 0, } } } @@ -657,6 +683,20 @@ pub fn demo_project() -> ProjectFile { ..Default::default() }); } + let mut synth_b_patterns = Vec::with_capacity(NUM_PATTERNS); + for i in 0..NUM_PATTERNS { + synth_b_patterns.push(SynthPatternData { + name: format!("Synth B {}", i + 1), + ..Default::default() + }); + } + let mut synth_b_kits = Vec::with_capacity(NUM_KITS); + for i in 0..NUM_KITS { + synth_b_kits.push(SynthKitData { + name: format!("Synth B Kit {}", i + 1), + ..Default::default() + }); + } ProjectFile { textstep: FileHeader::default(), @@ -677,6 +717,10 @@ pub fn demo_project() -> ProjectFile { active_synth_kit: 0, synth_patterns, active_synth_pattern: 0, + synth_b_kits, + active_synth_b_kit: 0, + synth_b_patterns, + active_synth_b_pattern: 0, } } @@ -792,6 +836,34 @@ impl ProjectFile { } } + /// Save synth B pattern steps to project. + pub fn save_synth_b_pattern(&mut self, index: usize, pattern: &SynthPattern) { + if index < self.synth_b_patterns.len() { + self.synth_b_patterns[index] = SynthPatternData::from_synth_pattern(pattern); + } + } + + /// Load synth B pattern steps from project. + pub fn load_synth_b_pattern(&self, index: usize, pattern: &mut SynthPattern) { + if let Some(pat_data) = self.synth_b_patterns.get(index) { + pat_data.apply_to(pattern); + } + } + + /// Save synth B kit params to project. + pub fn save_synth_b_kit(&mut self, index: usize, params: &SynthParams) { + if index < self.synth_b_kits.len() { + self.synth_b_kits[index].params = *params; + } + } + + /// Load synth B kit params into pattern. + pub fn load_synth_b_kit(&self, index: usize, pattern: &mut SynthPattern) { + if let Some(kit_data) = self.synth_b_kits.get(index) { + kit_data.apply_to(pattern); + } + } + /// Ensure we always have NUM_PATTERNS patterns and NUM_KITS kits. pub fn normalize(&mut self) { // Migrate old single-kit format: if kits is empty, seed from legacy kit field @@ -841,6 +913,28 @@ impl ProjectFile { if self.active_synth_pattern >= self.synth_patterns.len() { self.active_synth_pattern = 0; } + + while self.synth_b_kits.len() < NUM_KITS { + let idx = self.synth_b_kits.len(); + self.synth_b_kits.push(SynthKitData { + name: format!("Synth B Kit {}", idx + 1), + ..Default::default() + }); + } + if self.active_synth_b_kit >= self.synth_b_kits.len() { + self.active_synth_b_kit = 0; + } + + while self.synth_b_patterns.len() < NUM_PATTERNS { + let idx = self.synth_b_patterns.len(); + self.synth_b_patterns.push(SynthPatternData { + name: format!("Synth B {}", idx + 1), + ..Default::default() + }); + } + if self.active_synth_b_pattern >= self.synth_b_patterns.len() { + self.active_synth_b_pattern = 0; + } } } @@ -1037,6 +1131,89 @@ mod tests { // Remaining kits are defaults assert_eq!(proj.kits[1].name, "Kit 2"); } + + #[test] + fn test_project_roundtrip_dual_synth() { + // Create a project with synth B data + let mut project = ProjectFile::default(); + + // Set synth B pattern data + project.synth_b_patterns[0].name = "Test Synth B Pattern".to_string(); + project.synth_b_patterns[1].name = "Custom Pattern".to_string(); + project.synth_b_patterns[1].steps[5] = SynthStepData { + active: true, + note: 60, + velocity: 0.8, + gate: 0.9, + length: 4, + }; + + // Set synth B kit data + project.synth_b_kits[0].name = "Test Synth B Kit".to_string(); + project.synth_b_kits[0].params.osc1_level = 0.75; + + project.active_synth_b_kit = 2; + project.active_synth_b_pattern = 3; + + // Serialize + let json = serde_json::to_string(&project).unwrap(); + + // Deserialize + let mut loaded: ProjectFile = serde_json::from_str(&json).unwrap(); + loaded.normalize(); + + // Verify synth B data survived + assert_eq!(loaded.synth_b_patterns[0].name, "Test Synth B Pattern"); + assert_eq!(loaded.synth_b_patterns[1].name, "Custom Pattern"); + assert_eq!(loaded.synth_b_patterns[1].steps[5].active, true); + assert_eq!(loaded.synth_b_patterns[1].steps[5].note, 60); + assert_eq!(loaded.synth_b_patterns[1].steps[5].velocity, 0.8); + assert_eq!(loaded.synth_b_kits[0].name, "Test Synth B Kit"); + assert_eq!(loaded.synth_b_kits[0].params.osc1_level, 0.75); + assert_eq!(loaded.active_synth_b_kit, 2); + assert_eq!(loaded.active_synth_b_pattern, 3); + + // Verify arrays are properly sized + assert_eq!(loaded.synth_b_patterns.len(), NUM_PATTERNS); + assert_eq!(loaded.synth_b_kits.len(), NUM_KITS); + } + + #[test] + fn test_old_project_loads_with_synth_b_defaults() { + // Simulate an old project file without synth_b fields + let json = r#"{ + "textstep": {"format_version": 1, "app_version": "0.1.0"}, + "metadata": {"name": "Old Project"}, + "kits": [{"name": "Kit 1", "tracks": []}], + "active_kit": 0, + "patterns": [{"name": "P1", "steps": []}], + "active_pattern": 0, + "bpm": 120.0, + "loop_length": 32, + "swing": 0.5, + "synth_kits": [{"name": "Synth Kit 1", "params": {}}], + "active_synth_kit": 0, + "synth_patterns": [{"name": "Synth 1", "steps": []}], + "active_synth_pattern": 0 + }"#; + + let mut project: ProjectFile = serde_json::from_str(json).unwrap(); + project.normalize(); + + // Verify synth_b fields get defaults + assert_eq!(project.synth_b_patterns.len(), NUM_PATTERNS); + assert_eq!(project.synth_b_kits.len(), NUM_KITS); + assert_eq!(project.active_synth_b_kit, 0); + assert_eq!(project.active_synth_b_pattern, 0); + + // Default names should be present + assert_eq!(project.synth_b_patterns[0].name, "Synth B 1"); + assert_eq!(project.synth_b_kits[0].name, "Synth B Kit 1"); + + // Old data should be intact + assert_eq!(project.metadata.name, "Old Project"); + assert_eq!(project.synth_patterns[0].name, "Synth 1"); + } } #[cfg(test)] From aa798b230823c5a4ff20730f660a9bc88e720f3a Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:48:17 +0100 Subject: [PATCH 16/17] feat: preset browser loads into focused synth (A or B) --- src/app.rs | 22 ++++++++++++++++++---- src/keys.rs | 19 +++++++++++++------ src/presets/mod.rs | 5 +++++ 3 files changed, 36 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index 652a554..593dee1 100644 --- a/src/app.rs +++ b/src/app.rs @@ -1168,12 +1168,19 @@ impl App { // ── Preset Browser ───────────────────────────────────────────────── pub fn open_preset_browser(&mut self) { - let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let browser = if is_synth { + let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls | FocusSection::SynthBGrid | FocusSection::SynthBControls); + let mut browser = if is_synth { crate::presets::PresetBrowserState::for_synth() } else { crate::presets::PresetBrowserState::for_drum_track(self.ui.drum_cursor_track) }; + // Set target synth based on current focus + if is_synth { + browser.target_synth = match self.ui.focus { + FocusSection::SynthBGrid | FocusSection::SynthBControls => SynthId::B, + _ => SynthId::A, + }; + } self.ui.modal = ModalState::PresetBrowser(browser); } @@ -1189,12 +1196,19 @@ impl App { } pub fn open_pattern_browser(&mut self) { - let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls); - let pb = if is_synth { + let is_synth = matches!(self.ui.focus, FocusSection::SynthAGrid | FocusSection::SynthAControls | FocusSection::SynthBGrid | FocusSection::SynthBControls); + let mut pb = if is_synth { crate::presets::PatternBrowserState::new_synth() } else { crate::presets::PatternBrowserState::new() }; + // Set target synth based on current focus + if is_synth { + pb.browser.target_synth = match self.ui.focus { + FocusSection::SynthBGrid | FocusSection::SynthBControls => SynthId::B, + _ => SynthId::A, + }; + } self.ui.modal = ModalState::PatternBrowser(pb); } diff --git a/src/keys.rs b/src/keys.rs index 36745a9..8a22527 100644 --- a/src/keys.rs +++ b/src/keys.rs @@ -1161,7 +1161,8 @@ fn handle_preset_browser(app: &mut App, key: KeyEvent) { PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { let name = browser.preset_names.get(browser.preset_idx).copied().unwrap_or("?"); - app.apply_synth_preset(SynthId::A, params); + let synth_id = browser.target_synth; + app.apply_synth_preset(synth_id, params); app.ui.modal = ModalState::None; app.show_status(format!("Loaded: {}", name)); } @@ -1225,7 +1226,8 @@ fn handle_pattern_browser(app: &mut App, key: KeyEvent) { app.show_status(format!("{}: {}", mode_label, name)); } else if let Some(preset) = pb.browser.selected_synth_pattern() { let name = preset.name; - app.apply_synth_pattern_preset(SynthId::A, &preset.steps, merge); + let synth_id = pb.browser.target_synth; + app.apply_synth_pattern_preset(synth_id, &preset.steps, merge); app.show_status(format!("{}: {}", mode_label, name)); } } @@ -1256,10 +1258,15 @@ fn preview_preset(app: &mut App) { } PresetTarget::SynthSound => { if let Some(params) = browser.selected_synth_params() { - app.apply_synth_preset(SynthId::A, params); - let note = app.ui.synth_a.octave * 12 + 12; - let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(SynthId::A, note)); - app.ui.synth_a.flash = 6; + let synth_id = browser.target_synth; + app.apply_synth_preset(synth_id, params); + let (octave, flash) = match synth_id { + SynthId::A => (app.ui.synth_a.octave, &mut app.ui.synth_a.flash), + SynthId::B => (app.ui.synth_b.octave, &mut app.ui.synth_b.flash), + }; + let note = octave * 12 + 12; + let _ = app.tx_to_audio.send(UiToAudio::TriggerSynth(synth_id, note)); + *flash = 6; } } PresetTarget::Pattern | PresetTarget::SynthPattern => {} // no preview for patterns diff --git a/src/presets/mod.rs b/src/presets/mod.rs index d839a57..6cdf205 100644 --- a/src/presets/mod.rs +++ b/src/presets/mod.rs @@ -46,6 +46,7 @@ pub enum PatternMergeMode { #[derive(Clone, Debug, PartialEq)] pub struct PresetBrowserState { pub target: PresetTarget, + pub target_synth: crate::messages::SynthId, // which synth to apply synth presets to pub categories: Vec<&'static str>, pub category_idx: usize, pub preset_names: Vec<&'static str>, @@ -64,6 +65,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::DrumSound(track), + target_synth: crate::messages::SynthId::A, // default, not used for drum presets categories, category_idx: 0, preset_names: names, @@ -80,6 +82,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::SynthSound, + target_synth: crate::messages::SynthId::A, // default, will be set when browser opens categories, category_idx: 0, preset_names: names, @@ -150,6 +153,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::Pattern, + target_synth: crate::messages::SynthId::A, // default, not used for drum patterns categories, category_idx: 0, preset_names: names, @@ -175,6 +179,7 @@ impl PresetBrowserState { .collect(); Self { target: PresetTarget::SynthPattern, + target_synth: crate::messages::SynthId::A, // default, will be set when browser opens categories, category_idx: 0, preset_names: names, From 227b444631976fdba5511709cd0402bbfb7bb324 Mon Sep 17 00:00:00 2001 From: lobo Date: Wed, 11 Mar 2026 21:51:13 +0100 Subject: [PATCH 17/17] chore: cleanup legacy layout code and dead code from dual synth migration Removed unused legacy components that were replaced by the dual synth panel system: - Removed `ComputedLayout` struct and `compute_layout()` function from layout.rs (replaced by `DualSynthLayout` and `compute_dual_layout()`) - Removed unused constants: `SYNTH_SECTION_HEIGHT`, `SYNTH_COLLAPSED_HEIGHT`, `FADER_WIDTH`, `SEPARATOR_HEIGHT` - Removed `synth_collapsed` field from `UiState` (replaced by `PanelVisibility` panel management) - Removed dead helper functions from ui/mod.rs: `render_separator()`, `render_synth_collapsed()`, `render_volume_fader()` - Fixed unused imports warning in layout.rs All 30 tests pass. Build warnings reduced from 24 to 17 (remaining warnings are for intentionally unused utility functions). Co-Authored-By: Claude Opus 4.6 --- src/app.rs | 2 - src/ui/layout.rs | 133 +---------------------------------------------- src/ui/mod.rs | 85 +----------------------------- 3 files changed, 3 insertions(+), 217 deletions(-) diff --git a/src/app.rs b/src/app.rs index 593dee1..05b01da 100644 --- a/src/app.rs +++ b/src/app.rs @@ -513,7 +513,6 @@ pub struct UiState { pub is_bar_start: bool, pub show_help: bool, pub show_waveform: bool, - pub synth_collapsed: bool, pub panel_vis: PanelVisibility, /// Per-track trigger flash countdown (> 0 means flashing) pub trigger_flash: [u8; NUM_DRUM_TRACKS], @@ -569,7 +568,6 @@ impl Default for UiState { is_bar_start: false, show_help: false, show_waveform: true, - synth_collapsed: false, panel_vis: PanelVisibility::default(), trigger_flash: [0; NUM_DRUM_TRACKS], active_pattern: 0, diff --git a/src/ui/layout.rs b/src/ui/layout.rs index 0a373b4..ebe545b 100644 --- a/src/ui/layout.rs +++ b/src/ui/layout.rs @@ -1,6 +1,6 @@ // src/ui/layout.rs — Single source of truth for all layout dimensions -use ratatui::layout::{Constraint, Direction, Layout, Rect}; +use ratatui::layout::Rect; use crate::app::PanelVisibility; @@ -23,151 +23,22 @@ pub const SYNTH_KNOBS_HEIGHT: u16 = 30; /// Height of the synth step row (2 border + header + spacer + step row + spacer = 6). pub const SYNTH_GRID_HEIGHT: u16 = 6; -/// Combined synth section height (knobs + steps). -pub const SYNTH_SECTION_HEIGHT: u16 = SYNTH_KNOBS_HEIGHT + SYNTH_GRID_HEIGHT; - -/// Synth section when collapsed (border + step row + border) — legacy constant. -pub const SYNTH_COLLAPSED_HEIGHT: u16 = 3; - /// Minimum height for the drum grid (8 tracks + borders + header). pub const DRUM_GRID_MIN_HEIGHT: u16 = 11; /// Height of a collapsed panel (1 top-border + 1 content line showing label). pub const COLLAPSED_PANEL_HEIGHT: u16 = 2; -/// Width of the volume fader column. -pub const FADER_WIDTH: u16 = 3; - /// Height of the waveform/oscilloscope panel (including borders). pub const WAVEFORM_HEIGHT: u16 = 11; /// Activity bar (bottom status line). pub const ACTIVITY_BAR_HEIGHT: u16 = 1; -/// Separator line. -pub const SEPARATOR_HEIGHT: u16 = 1; - /// Help panel height. pub const HELP_HEIGHT: u16 = 22; -// ── Legacy ComputedLayout (used by current render + mouse code) ────────────── - -/// Pre-computed layout rects, shared between render and mouse hit-testing. -pub struct ComputedLayout { - pub transport: Rect, - pub synth_section: Rect, - pub separator: Rect, - pub drum_area: Rect, - pub knobs: Rect, - pub extra: Option, // Help or Waveform panel - pub activity_bar: Rect, - // Synth sub-areas (only valid when expanded) - pub synth_fader: Rect, - pub synth_content: Rect, - pub synth_knobs: Rect, - pub synth_grid: Rect, - // Drum sub-areas - pub drum_fader: Rect, - pub drum_grid: Rect, -} - -/// Compute layout for all sections. Both ui/mod.rs and mouse.rs consume this. -/// (Legacy signature — will be replaced by compute_dual_layout in Tasks 8/10.) -pub fn compute_layout( - size: Rect, - synth_collapsed: bool, - show_help: bool, - show_waveform: bool, -) -> ComputedLayout { - let synth_height = if synth_collapsed { SYNTH_COLLAPSED_HEIGHT } else { SYNTH_SECTION_HEIGHT }; - - let chunks = if show_help { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(TRANSPORT_HEIGHT), - Constraint::Length(synth_height), - Constraint::Length(SEPARATOR_HEIGHT), - Constraint::Min(11), - Constraint::Length(KNOBS_HEIGHT), - Constraint::Length(HELP_HEIGHT), - Constraint::Length(ACTIVITY_BAR_HEIGHT), - ]) - .split(size) - } else if show_waveform { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(TRANSPORT_HEIGHT), - Constraint::Length(synth_height), - Constraint::Length(SEPARATOR_HEIGHT), - Constraint::Min(11), - Constraint::Length(KNOBS_HEIGHT), - Constraint::Length(WAVEFORM_HEIGHT), - Constraint::Length(ACTIVITY_BAR_HEIGHT), - ]) - .split(size) - } else { - Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(TRANSPORT_HEIGHT), - Constraint::Length(synth_height), - Constraint::Length(SEPARATOR_HEIGHT), - Constraint::Min(11), - Constraint::Length(KNOBS_HEIGHT), - Constraint::Length(ACTIVITY_BAR_HEIGHT), - ]) - .split(size) - }; - - let extra = if show_help || show_waveform { Some(chunks[5]) } else { None }; - let activity_idx = if show_help || show_waveform { 6 } else { 5 }; - - let synth_section = chunks[1]; - - // Synth sub-splits - let synth_h = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(FADER_WIDTH), Constraint::Min(20)]) - .split(synth_section); - let synth_fader = synth_h[0]; - let synth_content = synth_h[1]; - - let synth_v = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(SYNTH_KNOBS_HEIGHT), Constraint::Length(SYNTH_GRID_HEIGHT)]) - .split(synth_content); - let synth_knobs = synth_v[0]; - let synth_grid = synth_v[1]; - - // Drum sub-splits - let drum_area = chunks[3]; - let drum_h = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Length(FADER_WIDTH), Constraint::Min(20)]) - .split(drum_area); - let drum_fader = drum_h[0]; - let drum_grid = drum_h[1]; - - ComputedLayout { - transport: chunks[0], - synth_section, - separator: chunks[2], - drum_area, - knobs: chunks[4], - extra, - activity_bar: chunks[activity_idx], - synth_fader, - synth_content, - synth_knobs, - synth_grid, - drum_fader, - drum_grid, - } -} - -// ── Dual-synth ComputedLayout ──────────────────────────────────────────────── +// ── Dual-synth layout ──────────────────────────────────────────────────────── /// Pre-computed layout rects for the dual-synth panel system. /// diff --git a/src/ui/mod.rs b/src/ui/mod.rs index d0e0065..9b2edc6 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -157,90 +157,7 @@ pub fn render(f: &mut Frame, app: &App) { } } -// ── Separator ──────────────────────────────────────────────────────────────── - -#[allow(dead_code)] -fn render_separator(f: &mut Frame, area: Rect) { - let line = "─".repeat(area.width as usize); - f.render_widget( - Paragraph::new(Line::from(Span::styled(line, Style::default().fg(Color::DarkGray)))), - area, - ); -} - -// ── Volume faders ──────────────────────────────────────────────────────────── - -/// Render the synth section in collapsed mode: just a title bar with label. -#[allow(dead_code)] -fn render_synth_collapsed(f: &mut Frame, area: Rect, _app: &App) { - let block = Block::default() - .title(" SYNTH [F2 expand] ") - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::DarkGray)); - f.render_widget(block, area); -} - -/// Render a vertical volume fader (Hi-Fi LED style, same as VU meter). -#[allow(dead_code)] -fn render_volume_fader(f: &mut Frame, area: Rect, volume: f32, _label: &str) { - let block = Block::default() - .borders(Borders::ALL) - .border_style(Style::default().fg(Color::Rgb(40, 40, 40))); - - let inner = block.inner(area); - f.render_widget(block, area); - - if inner.height == 0 || inner.width == 0 { - return; - } - - let total_rows = inner.height as usize; - let filled = (volume * total_rows as f32).round() as usize; - - for row_idx in 0..total_rows { - let bar_level = total_rows - 1 - row_idx; // 0=bottom, max=top - let ratio = bar_level as f32 / total_rows.max(1) as f32; - - // Hi-Fi LED color zones - let (base, dim) = if ratio > 0.78 { - ((255u8, 30, 0), (40u8, 5, 0)) // Red - } else if ratio > 0.56 { - ((255, 140, 0), (35, 20, 0)) // Orange - } else if ratio > 0.33 { - ((220, 220, 0), (30, 30, 0)) // Yellow - } else { - ((0, 220, 0), (0, 30, 0)) // Green - }; - - let is_lit = bar_level < filled; - let color = if is_lit { - Color::Rgb(base.0, base.1, base.2) - } else { - Color::Rgb(dim.0, dim.1, dim.2) - }; - - let ch = if is_lit { "\u{2588}" } else { "\u{2591}" }; // █ or ░ - - // Fill the inner width - let y = inner.y + row_idx as u16; - for col in 0..inner.width { - let span = Span::styled(ch, Style::default().fg(color)); - f.render_widget(Paragraph::new(Line::from(span)), Rect::new(inner.x + col, y, 1, 1)); - } - } - - // Show percentage at bottom of fader - let pct = format!("{:02}", (volume * 99.0).round() as u8); - if area.height >= 3 && area.width >= 3 { - // Overlay percentage on the bottom row of the fader border - let pct_y = area.y + area.height - 1; - let pct_style = Style::default().fg(Color::White).add_modifier(Modifier::BOLD); - f.render_widget( - Paragraph::new(Line::from(Span::styled(pct, pct_style))), - Rect::new(area.x, pct_y, area.width, 1), - ); - } -} +// ── Activity bar ───────────────────────────────────────────────────────────── /// Activity bar: trigger pads + param tweak + status message. fn render_activity_bar(f: &mut Frame, area: Rect, app: &App) {