Allow setting node cv inputs via slider, add preferences window
This commit is contained in:
parent
88dc60f036
commit
caba4305d8
|
|
@ -386,10 +386,19 @@ impl AudioGraph {
|
||||||
let num_audio_cv_inputs = inputs.iter().filter(|p| p.signal_type != SignalType::Midi).count();
|
let num_audio_cv_inputs = inputs.iter().filter(|p| p.signal_type != SignalType::Midi).count();
|
||||||
let num_midi_inputs = inputs.iter().filter(|p| p.signal_type == SignalType::Midi).count();
|
let num_midi_inputs = inputs.iter().filter(|p| p.signal_type == SignalType::Midi).count();
|
||||||
|
|
||||||
// Clear audio/CV input buffers
|
// Clear input buffers
|
||||||
for i in 0..num_audio_cv_inputs {
|
// - Audio inputs: fill with 0.0 (silence) when unconnected
|
||||||
if i < self.input_buffers.len() {
|
// - CV inputs: fill with NaN to indicate "no connection" (allows nodes to use parameter values)
|
||||||
self.input_buffers[i].fill(0.0);
|
let mut audio_cv_idx = 0;
|
||||||
|
for port in inputs.iter().filter(|p| p.signal_type != SignalType::Midi) {
|
||||||
|
if audio_cv_idx < self.input_buffers.len() {
|
||||||
|
let fill_value = match port.signal_type {
|
||||||
|
SignalType::Audio => 0.0, // Silence for audio
|
||||||
|
SignalType::CV => f32::NAN, // Sentinel for CV
|
||||||
|
SignalType::Midi => unreachable!(), // Already filtered out
|
||||||
|
};
|
||||||
|
self.input_buffers[audio_cv_idx].fill(fill_value);
|
||||||
|
audio_cv_idx += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -419,7 +428,12 @@ impl AudioGraph {
|
||||||
let source_buffer = &source_node.output_buffers[conn.from_port];
|
let source_buffer = &source_node.output_buffers[conn.from_port];
|
||||||
if conn.to_port < self.input_buffers.len() {
|
if conn.to_port < self.input_buffers.len() {
|
||||||
for (dst, src) in self.input_buffers[conn.to_port].iter_mut().zip(source_buffer.iter()) {
|
for (dst, src) in self.input_buffers[conn.to_port].iter_mut().zip(source_buffer.iter()) {
|
||||||
*dst += src;
|
// If dst is NaN (unconnected), replace it; otherwise add (for mixing)
|
||||||
|
if dst.is_nan() {
|
||||||
|
*dst = *src;
|
||||||
|
} else {
|
||||||
|
*dst += src;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,6 @@ pub mod nodes;
|
||||||
pub mod preset;
|
pub mod preset;
|
||||||
|
|
||||||
pub use graph::{Connection, GraphNode, AudioGraph};
|
pub use graph::{Connection, GraphNode, AudioGraph};
|
||||||
pub use node_trait::AudioNode;
|
pub use node_trait::{AudioNode, cv_input_or_default};
|
||||||
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode};
|
pub use preset::{GraphPreset, PresetMetadata, SerializedConnection, SerializedNode};
|
||||||
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
pub use types::{ConnectionError, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
||||||
|
|
|
||||||
|
|
@ -77,3 +77,34 @@ pub trait AudioNode: Send {
|
||||||
/// Downcast to `&dyn Any` for type-specific read-only operations
|
/// Downcast to `&dyn Any` for type-specific read-only operations
|
||||||
fn as_any(&self) -> &dyn std::any::Any;
|
fn as_any(&self) -> &dyn std::any::Any;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Helper function for CV inputs with optional connections
|
||||||
|
///
|
||||||
|
/// Returns the input value if connected (not NaN), otherwise returns the default value.
|
||||||
|
/// This implements "Blender-style" input behavior where parameters are replaced by
|
||||||
|
/// connected inputs.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `inputs` - Input buffer array from process()
|
||||||
|
/// * `port` - Input port index
|
||||||
|
/// * `frame` - Current frame index
|
||||||
|
/// * `default` - Default value to use when input is unconnected
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// The input value if connected, otherwise the default value
|
||||||
|
#[inline]
|
||||||
|
pub fn cv_input_or_default(inputs: &[&[f32]], port: usize, frame: usize, default: f32) -> f32 {
|
||||||
|
if port < inputs.len() && frame < inputs[port].len() {
|
||||||
|
let value = inputs[port][frame];
|
||||||
|
if value.is_nan() {
|
||||||
|
// Unconnected: use default parameter value
|
||||||
|
default
|
||||||
|
} else {
|
||||||
|
// Connected: use input signal
|
||||||
|
value
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// No input buffer: use default
|
||||||
|
default
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
|
||||||
const PARAM_ATTACK: u32 = 0;
|
const PARAM_ATTACK: u32 = 0;
|
||||||
|
|
@ -122,12 +122,9 @@ impl AudioNode for ADSRNode {
|
||||||
let frames = output.len();
|
let frames = output.len();
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Read gate input (if available)
|
// Gate input: when unconnected, defaults to 0.0 (off)
|
||||||
let gate_high = if !inputs.is_empty() && frame < inputs[0].len() {
|
let gate_cv = cv_input_or_default(inputs, 0, frame, 0.0);
|
||||||
inputs[0][frame] > 0.5 // Gate is high if CV > 0.5
|
let gate_high = gate_cv > 0.5;
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detect gate transitions
|
// Detect gate transitions
|
||||||
if gate_high && !self.gate_was_high {
|
if gate_high && !self.gate_was_high {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
use crate::dsp::biquad::BiquadFilter;
|
use crate::dsp::biquad::BiquadFilter;
|
||||||
|
|
||||||
|
|
@ -150,10 +150,19 @@ impl AudioNode for FilterNode {
|
||||||
output[..len].copy_from_slice(&input[..len]);
|
output[..len].copy_from_slice(&input[..len]);
|
||||||
|
|
||||||
// Check for CV modulation (modulates cutoff)
|
// Check for CV modulation (modulates cutoff)
|
||||||
if inputs.len() > 1 && !inputs[1].is_empty() {
|
// Sample CV at the start of the buffer - per-sample would be too expensive
|
||||||
// CV input modulates cutoff frequency
|
let cutoff_cv = cv_input_or_default(inputs, 1, 0, self.cutoff);
|
||||||
// For now, just use the base cutoff - per-sample modulation would be expensive
|
if (cutoff_cv - self.cutoff).abs() > 0.01 {
|
||||||
// TODO: Sample CV at frame rate and update filter periodically
|
// CV changed significantly, update filter
|
||||||
|
let new_cutoff = cutoff_cv.clamp(20.0, 20000.0);
|
||||||
|
match self.filter_type {
|
||||||
|
FilterType::Lowpass => {
|
||||||
|
self.filter.set_lowpass(new_cutoff, self.resonance, self.sample_rate as f32);
|
||||||
|
}
|
||||||
|
FilterType::Highpass => {
|
||||||
|
self.filter.set_highpass(new_cutoff, self.resonance, self.sample_rate as f32);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply filter (processes stereo interleaved)
|
// Apply filter (processes stereo interleaved)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
|
@ -256,18 +256,11 @@ impl AudioNode for FMSynthNode {
|
||||||
let frames = output.len() / 2;
|
let frames = output.len() / 2;
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Read CV inputs
|
// Read CV inputs (both are mono signals)
|
||||||
let voct = if inputs.len() > 0 && !inputs[0].is_empty() {
|
// V/Oct: when unconnected, defaults to 0.0 (A4 440 Hz)
|
||||||
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2]
|
let voct = cv_input_or_default(inputs, 0, frame, 0.0);
|
||||||
} else {
|
// Gate: when unconnected, defaults to 0.0 (off)
|
||||||
0.0
|
let gate = cv_input_or_default(inputs, 1, frame, 0.0);
|
||||||
};
|
|
||||||
|
|
||||||
let gate = if inputs.len() > 1 && !inputs[1].is_empty() {
|
|
||||||
inputs[1][frame.min(inputs[1].len() / 2 - 1) * 2]
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Update state
|
// Update state
|
||||||
self.current_frequency = Self::voct_to_freq(voct);
|
self.current_frequency = Self::voct_to_freq(voct);
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
|
|
||||||
const PARAM_GAIN: u32 = 0;
|
const PARAM_GAIN: u32 = 0;
|
||||||
|
|
@ -90,15 +90,11 @@ impl AudioNode for GainNode {
|
||||||
let frames = input.len().min(output.len()) / 2;
|
let frames = input.len().min(output.len()) / 2;
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Calculate final gain
|
|
||||||
let mut final_gain = self.gain;
|
|
||||||
|
|
||||||
// CV input acts as a VCA (voltage-controlled amplifier)
|
// CV input acts as a VCA (voltage-controlled amplifier)
|
||||||
// CV ranges from 0.0 (silence) to 1.0 (full gain parameter value)
|
// CV ranges from 0.0 (silence) to 1.0 (full gain parameter value)
|
||||||
if inputs.len() > 1 && frame < inputs[1].len() {
|
// When unconnected (NaN), defaults to 1.0 (no modulation, use gain parameter as-is)
|
||||||
let cv = inputs[1][frame];
|
let cv = cv_input_or_default(inputs, 1, frame, 1.0);
|
||||||
final_gain *= cv; // Multiply gain by CV (0.0 = silence, 1.0 = full gain)
|
let final_gain = self.gain * cv;
|
||||||
}
|
|
||||||
|
|
||||||
// Apply gain to both channels
|
// Apply gain to both channels
|
||||||
output[frame * 2] = input[frame * 2] * final_gain; // Left
|
output[frame * 2] = input[frame * 2] * final_gain; // Left
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
|
@ -124,26 +124,28 @@ impl AudioNode for OscillatorNode {
|
||||||
let frames = output.len() / 2;
|
let frames = output.len() / 2;
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Start with base frequency
|
|
||||||
let mut frequency = self.frequency;
|
|
||||||
|
|
||||||
// V/Oct input: Standard V/Oct (0V = A4 440Hz, ±1V per octave)
|
// V/Oct input: Standard V/Oct (0V = A4 440Hz, ±1V per octave)
|
||||||
if !inputs.is_empty() && frame < inputs[0].len() {
|
// Port 0: V/Oct CV input
|
||||||
let voct = inputs[0][frame]; // Read V/Oct CV (mono)
|
// If connected, interprets the CV signal as V/Oct (440 * 2^voct)
|
||||||
// Convert V/Oct to frequency: f = 440 * 2^(voct)
|
// If unconnected, uses self.frequency directly as Hz
|
||||||
|
let voct = cv_input_or_default(inputs, 0, frame, f32::NAN);
|
||||||
|
let base_frequency = if voct.is_nan() {
|
||||||
|
// Unconnected: use frequency parameter directly
|
||||||
|
self.frequency
|
||||||
|
} else {
|
||||||
|
// Connected: convert V/Oct to frequency
|
||||||
// voct = 0.0 -> 440 Hz (A4)
|
// voct = 0.0 -> 440 Hz (A4)
|
||||||
// voct = 1.0 -> 880 Hz (A5)
|
// voct = 1.0 -> 880 Hz (A5)
|
||||||
// voct = -0.75 -> 261.6 Hz (C4, middle C)
|
// voct = -0.75 -> 261.6 Hz (C4, middle C)
|
||||||
frequency = 440.0 * 2.0_f32.powf(voct);
|
440.0 * 2.0_f32.powf(voct)
|
||||||
}
|
};
|
||||||
|
|
||||||
// FM input: modulates the frequency
|
// FM input: modulates the frequency
|
||||||
if inputs.len() > 1 && frame < inputs[1].len() {
|
// Port 1: FM CV input
|
||||||
let fm = inputs[1][frame]; // Read FM CV (mono)
|
// If connected, applies FM modulation (multiply by 1 + fm)
|
||||||
frequency *= 1.0 + fm;
|
// If unconnected, no modulation (fm = 0.0)
|
||||||
}
|
let fm = cv_input_or_default(inputs, 1, frame, 0.0);
|
||||||
|
let freq_mod = base_frequency * (1.0 + fm);
|
||||||
let freq_mod = frequency;
|
|
||||||
|
|
||||||
// Generate waveform sample based on waveform type
|
// Generate waveform sample based on waveform type
|
||||||
let sample = match self.waveform {
|
let sample = match self.waveform {
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
|
@ -113,18 +113,12 @@ impl AudioNode for PanNode {
|
||||||
let frames_to_process = frames.min(output_frames);
|
let frames_to_process = frames.min(output_frames);
|
||||||
|
|
||||||
for frame in 0..frames_to_process {
|
for frame in 0..frames_to_process {
|
||||||
// Get base pan position
|
// Pan CV input: when connected, replaces parameter; when unconnected, uses parameter
|
||||||
let mut pan = self.pan;
|
// CV is in 0-1 range, mapped to -1 to +1 pan range
|
||||||
|
let cv_raw = cv_input_or_default(inputs, 1, frame, (self.pan + 1.0) * 0.5);
|
||||||
|
let pan = (cv_raw * 2.0 - 1.0).clamp(-1.0, 1.0);
|
||||||
|
|
||||||
// Add CV modulation if connected
|
// Calculate gains using constant-power panning law
|
||||||
if inputs.len() > 1 && frame < inputs[1].len() {
|
|
||||||
let cv = inputs[1][frame]; // CV is mono
|
|
||||||
// CV is 0-1, map to -1 to +1 range
|
|
||||||
pan += cv * 2.0 - 1.0;
|
|
||||||
pan = pan.clamp(-1.0, 1.0);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update gains if pan changed from CV
|
|
||||||
let angle = (pan + 1.0) * 0.5 * PI / 2.0;
|
let angle = (pan + 1.0) * 0.5 * PI / 2.0;
|
||||||
let left_gain = angle.cos();
|
let left_gain = angle.cos();
|
||||||
let right_gain = angle.sin();
|
let right_gain = angle.sin();
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
use std::sync::{Arc, Mutex};
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
|
@ -202,18 +202,11 @@ impl AudioNode for SimpleSamplerNode {
|
||||||
let frames = output.len() / 2;
|
let frames = output.len() / 2;
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Read CV inputs
|
// Read CV inputs (both are mono signals)
|
||||||
let voct = if !inputs.is_empty() && !inputs[0].is_empty() {
|
// V/Oct: when unconnected, defaults to 0.0 (original pitch)
|
||||||
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2]
|
let voct = cv_input_or_default(inputs, 0, frame, 0.0);
|
||||||
} else {
|
// Gate: when unconnected, defaults to 0.0 (off)
|
||||||
0.0 // Default to original pitch
|
let gate = cv_input_or_default(inputs, 1, frame, 0.0);
|
||||||
};
|
|
||||||
|
|
||||||
let gate = if inputs.len() > 1 && !inputs[1].is_empty() {
|
|
||||||
inputs[1][frame.min(inputs[1].len() / 2 - 1) * 2]
|
|
||||||
} else {
|
|
||||||
0.0
|
|
||||||
};
|
|
||||||
|
|
||||||
// Detect gate trigger (rising edge)
|
// Detect gate trigger (rising edge)
|
||||||
let gate_active = gate > 0.5;
|
let gate_active = gate > 0.5;
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType};
|
use crate::audio::node_graph::{AudioNode, NodeCategory, NodePort, Parameter, ParameterUnit, SignalType, cv_input_or_default};
|
||||||
use crate::audio::midi::MidiEvent;
|
use crate::audio::midi::MidiEvent;
|
||||||
use std::f32::consts::PI;
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
|
@ -243,14 +243,11 @@ impl AudioNode for WavetableOscillatorNode {
|
||||||
let frames = output.len() / 2;
|
let frames = output.len() / 2;
|
||||||
|
|
||||||
for frame in 0..frames {
|
for frame in 0..frames {
|
||||||
// Read V/Oct input
|
// V/Oct input: when unconnected, defaults to 0.0 (A4 440 Hz)
|
||||||
let voct = if !inputs.is_empty() && !inputs[0].is_empty() {
|
// CV signals are mono, so read from frame index directly
|
||||||
inputs[0][frame.min(inputs[0].len() / 2 - 1) * 2]
|
let voct = cv_input_or_default(inputs, 0, frame, 0.0);
|
||||||
} else {
|
|
||||||
0.0 // Default to A4 (440 Hz)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Calculate frequency
|
// Calculate frequency from V/Oct
|
||||||
let freq = self.voct_to_freq(voct);
|
let freq = self.voct_to_freq(voct);
|
||||||
|
|
||||||
// Read from wavetable
|
// Read from wavetable
|
||||||
|
|
|
||||||
|
|
@ -7,12 +7,63 @@ pub struct AppConfig {
|
||||||
/// Recent files list (newest first, max 10 items)
|
/// Recent files list (newest first, max 10 items)
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub recent_files: Vec<PathBuf>,
|
pub recent_files: Vec<PathBuf>,
|
||||||
|
|
||||||
|
// User Preferences
|
||||||
|
/// Default BPM for new projects
|
||||||
|
#[serde(default = "defaults::bpm")]
|
||||||
|
pub bpm: u32,
|
||||||
|
|
||||||
|
/// Default framerate for new projects
|
||||||
|
#[serde(default = "defaults::framerate")]
|
||||||
|
pub framerate: u32,
|
||||||
|
|
||||||
|
/// Default file width in pixels
|
||||||
|
#[serde(default = "defaults::file_width")]
|
||||||
|
pub file_width: u32,
|
||||||
|
|
||||||
|
/// Default file height in pixels
|
||||||
|
#[serde(default = "defaults::file_height")]
|
||||||
|
pub file_height: u32,
|
||||||
|
|
||||||
|
/// Scroll speed multiplier
|
||||||
|
#[serde(default = "defaults::scroll_speed")]
|
||||||
|
pub scroll_speed: f64,
|
||||||
|
|
||||||
|
/// Audio buffer size in samples (128, 256, 512, 1024, 2048, 4096)
|
||||||
|
#[serde(default = "defaults::audio_buffer_size")]
|
||||||
|
pub audio_buffer_size: u32,
|
||||||
|
|
||||||
|
/// Reopen last session on startup
|
||||||
|
#[serde(default = "defaults::reopen_last_session")]
|
||||||
|
pub reopen_last_session: bool,
|
||||||
|
|
||||||
|
/// Restore layout when opening files
|
||||||
|
#[serde(default = "defaults::restore_layout_from_file")]
|
||||||
|
pub restore_layout_from_file: bool,
|
||||||
|
|
||||||
|
/// Enable debug mode
|
||||||
|
#[serde(default = "defaults::debug")]
|
||||||
|
pub debug: bool,
|
||||||
|
|
||||||
|
/// Theme mode ("light", "dark", or "system")
|
||||||
|
#[serde(default = "defaults::theme_mode")]
|
||||||
|
pub theme_mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AppConfig {
|
impl Default for AppConfig {
|
||||||
fn default() -> Self {
|
fn default() -> Self {
|
||||||
Self {
|
Self {
|
||||||
recent_files: Vec::new(),
|
recent_files: Vec::new(),
|
||||||
|
bpm: defaults::bpm(),
|
||||||
|
framerate: defaults::framerate(),
|
||||||
|
file_width: defaults::file_width(),
|
||||||
|
file_height: defaults::file_height(),
|
||||||
|
scroll_speed: defaults::scroll_speed(),
|
||||||
|
audio_buffer_size: defaults::audio_buffer_size(),
|
||||||
|
reopen_last_session: defaults::reopen_last_session(),
|
||||||
|
restore_layout_from_file: defaults::restore_layout_from_file(),
|
||||||
|
debug: defaults::debug(),
|
||||||
|
theme_mode: defaults::theme_mode(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -126,4 +177,91 @@ impl AppConfig {
|
||||||
self.recent_files.clear();
|
self.recent_files.clear();
|
||||||
self.save();
|
self.save();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Validate BPM range (20-300)
|
||||||
|
pub fn validate_bpm(&self) -> Result<(), String> {
|
||||||
|
if self.bpm >= 20 && self.bpm <= 300 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("BPM must be between 20 and 300 (got {})", self.bpm))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate framerate range (1-120)
|
||||||
|
pub fn validate_framerate(&self) -> Result<(), String> {
|
||||||
|
if self.framerate >= 1 && self.framerate <= 120 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Framerate must be between 1 and 120 (got {})", self.framerate))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate file width range (100-10000)
|
||||||
|
pub fn validate_file_width(&self) -> Result<(), String> {
|
||||||
|
if self.file_width >= 100 && self.file_width <= 10000 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("File width must be between 100 and 10000 (got {})", self.file_width))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate file height range (100-10000)
|
||||||
|
pub fn validate_file_height(&self) -> Result<(), String> {
|
||||||
|
if self.file_height >= 100 && self.file_height <= 10000 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("File height must be between 100 and 10000 (got {})", self.file_height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate scroll speed range (0.1-10.0)
|
||||||
|
pub fn validate_scroll_speed(&self) -> Result<(), String> {
|
||||||
|
if self.scroll_speed >= 0.1 && self.scroll_speed <= 10.0 {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(format!("Scroll speed must be between 0.1 and 10.0 (got {})", self.scroll_speed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate audio buffer size (must be 128, 256, 512, 1024, 2048, or 4096)
|
||||||
|
pub fn validate_audio_buffer_size(&self) -> Result<(), String> {
|
||||||
|
match self.audio_buffer_size {
|
||||||
|
128 | 256 | 512 | 1024 | 2048 | 4096 => Ok(()),
|
||||||
|
_ => Err(format!("Audio buffer size must be 128, 256, 512, 1024, 2048, or 4096 (got {})", self.audio_buffer_size))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate theme mode (must be "light", "dark", or "system")
|
||||||
|
pub fn validate_theme_mode(&self) -> Result<(), String> {
|
||||||
|
match self.theme_mode.to_lowercase().as_str() {
|
||||||
|
"light" | "dark" | "system" => Ok(()),
|
||||||
|
_ => Err(format!("Theme mode must be 'light', 'dark', or 'system' (got '{}')", self.theme_mode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validate all preferences
|
||||||
|
pub fn validate(&self) -> Result<(), String> {
|
||||||
|
self.validate_bpm()?;
|
||||||
|
self.validate_framerate()?;
|
||||||
|
self.validate_file_width()?;
|
||||||
|
self.validate_file_height()?;
|
||||||
|
self.validate_scroll_speed()?;
|
||||||
|
self.validate_audio_buffer_size()?;
|
||||||
|
self.validate_theme_mode()?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Default values for preferences (matches JS implementation)
|
||||||
|
mod defaults {
|
||||||
|
pub fn bpm() -> u32 { 120 }
|
||||||
|
pub fn framerate() -> u32 { 24 }
|
||||||
|
pub fn file_width() -> u32 { 800 }
|
||||||
|
pub fn file_height() -> u32 { 600 }
|
||||||
|
pub fn scroll_speed() -> f64 { 1.0 }
|
||||||
|
pub fn audio_buffer_size() -> u32 { 256 }
|
||||||
|
pub fn reopen_last_session() -> bool { false }
|
||||||
|
pub fn restore_layout_from_file() -> bool { true }
|
||||||
|
pub fn debug() -> bool { false }
|
||||||
|
pub fn theme_mode() -> String { "system".to_string() }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,8 @@ mod default_instrument;
|
||||||
|
|
||||||
mod export;
|
mod export;
|
||||||
|
|
||||||
|
mod preferences;
|
||||||
|
|
||||||
mod notifications;
|
mod notifications;
|
||||||
|
|
||||||
mod effect_thumbnails;
|
mod effect_thumbnails;
|
||||||
|
|
@ -55,13 +57,17 @@ fn main() -> eframe::Result {
|
||||||
// Parse command line arguments
|
// Parse command line arguments
|
||||||
let args = Args::parse();
|
let args = Args::parse();
|
||||||
|
|
||||||
// Determine theme mode from arguments
|
// Load config to get theme preference
|
||||||
|
let config = AppConfig::load();
|
||||||
|
|
||||||
|
// Determine theme mode: command-line args override config
|
||||||
let theme_mode = if args.light {
|
let theme_mode = if args.light {
|
||||||
ThemeMode::Light
|
ThemeMode::Light
|
||||||
} else if args.dark {
|
} else if args.dark {
|
||||||
ThemeMode::Dark
|
ThemeMode::Dark
|
||||||
} else {
|
} else {
|
||||||
ThemeMode::System
|
// Use theme from config
|
||||||
|
ThemeMode::from_string(&config.theme_mode)
|
||||||
};
|
};
|
||||||
|
|
||||||
// Load theme
|
// Load theme
|
||||||
|
|
@ -591,6 +597,8 @@ struct EditorApp {
|
||||||
export_dialog: export::dialog::ExportDialog,
|
export_dialog: export::dialog::ExportDialog,
|
||||||
/// Export progress dialog
|
/// Export progress dialog
|
||||||
export_progress_dialog: export::dialog::ExportProgressDialog,
|
export_progress_dialog: export::dialog::ExportProgressDialog,
|
||||||
|
/// Preferences dialog
|
||||||
|
preferences_dialog: preferences::dialog::PreferencesDialog,
|
||||||
/// Export orchestrator for background exports
|
/// Export orchestrator for background exports
|
||||||
export_orchestrator: Option<export::ExportOrchestrator>,
|
export_orchestrator: Option<export::ExportOrchestrator>,
|
||||||
/// GPU-rendered effect thumbnail generator
|
/// GPU-rendered effect thumbnail generator
|
||||||
|
|
@ -656,7 +664,7 @@ impl EditorApp {
|
||||||
|
|
||||||
// Initialize audio system and destructure it for sharing
|
// Initialize audio system and destructure it for sharing
|
||||||
let (audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, file_command_tx) =
|
let (audio_stream, audio_controller, audio_event_rx, audio_sample_rate, audio_channels, file_command_tx) =
|
||||||
match daw_backend::AudioSystem::new(None, 256) {
|
match daw_backend::AudioSystem::new(None, config.audio_buffer_size) {
|
||||||
Ok(audio_system) => {
|
Ok(audio_system) => {
|
||||||
println!("✅ Audio engine initialized successfully");
|
println!("✅ Audio engine initialized successfully");
|
||||||
|
|
||||||
|
|
@ -751,6 +759,7 @@ impl EditorApp {
|
||||||
audio_extraction_rx,
|
audio_extraction_rx,
|
||||||
export_dialog: export::dialog::ExportDialog::default(),
|
export_dialog: export::dialog::ExportDialog::default(),
|
||||||
export_progress_dialog: export::dialog::ExportProgressDialog::default(),
|
export_progress_dialog: export::dialog::ExportProgressDialog::default(),
|
||||||
|
preferences_dialog: preferences::dialog::PreferencesDialog::default(),
|
||||||
export_orchestrator: None,
|
export_orchestrator: None,
|
||||||
effect_thumbnail_generator: None, // Initialized when GPU available
|
effect_thumbnail_generator: None, // Initialized when GPU available
|
||||||
|
|
||||||
|
|
@ -1386,8 +1395,7 @@ impl EditorApp {
|
||||||
// TODO: Implement select none
|
// TODO: Implement select none
|
||||||
}
|
}
|
||||||
MenuAction::Preferences => {
|
MenuAction::Preferences => {
|
||||||
println!("Menu: Preferences");
|
self.preferences_dialog.open(&self.config, &self.theme);
|
||||||
// TODO: Implement preferences dialog
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Modify menu
|
// Modify menu
|
||||||
|
|
@ -2900,6 +2908,13 @@ impl eframe::App for EditorApp {
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render preferences dialog
|
||||||
|
if let Some(result) = self.preferences_dialog.render(ctx, &mut self.config, &mut self.theme) {
|
||||||
|
if result.buffer_size_changed {
|
||||||
|
println!("⚠️ Audio buffer size will be applied on next app restart");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Render video frames incrementally (if video export in progress)
|
// Render video frames incrementally (if video export in progress)
|
||||||
if let Some(orchestrator) = &mut self.export_orchestrator {
|
if let Some(orchestrator) = &mut self.export_orchestrator {
|
||||||
if orchestrator.is_exporting() {
|
if orchestrator.is_exporting() {
|
||||||
|
|
|
||||||
|
|
@ -511,6 +511,9 @@ impl SetParameterAction {
|
||||||
|
|
||||||
let BackendNodeId::Audio(node_idx) = self.backend_node_id;
|
let BackendNodeId::Audio(node_idx) = self.backend_node_id;
|
||||||
|
|
||||||
|
eprintln!("[DEBUG] Setting parameter: track {} node {} param {} = {}",
|
||||||
|
track_id, node_idx.index(), self.param_id, self.new_value);
|
||||||
|
|
||||||
controller.graph_set_parameter(
|
controller.graph_set_parameter(
|
||||||
*track_id,
|
*track_id,
|
||||||
node_idx.index() as u32,
|
node_idx.index() as u32,
|
||||||
|
|
|
||||||
|
|
@ -223,7 +223,7 @@ impl NodeTemplateTrait for NodeTemplate {
|
||||||
"V/Oct".into(),
|
"V/Oct".into(),
|
||||||
DataType::CV,
|
DataType::CV,
|
||||||
ValueType::Float { value: 0.0 },
|
ValueType::Float { value: 0.0 },
|
||||||
InputParamKind::ConnectionOnly,
|
InputParamKind::ConnectionOrConstant,
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
// FM input (frequency modulation)
|
// FM input (frequency modulation)
|
||||||
|
|
|
||||||
|
|
@ -373,6 +373,11 @@ impl NodeGraphPane {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Execute any pending action created during response handling
|
||||||
|
self.execute_pending_action(shared);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_pending_action(&mut self, shared: &mut crate::panes::SharedPaneState) {
|
||||||
// Execute pending action if any
|
// Execute pending action if any
|
||||||
if let Some(action) = self.pending_action.take() {
|
if let Some(action) = self.pending_action.take() {
|
||||||
// Node graph actions need to update the backend, so use execute_with_backend
|
// Node graph actions need to update the backend, so use execute_with_backend
|
||||||
|
|
@ -423,21 +428,41 @@ impl NodeGraphPane {
|
||||||
|
|
||||||
fn check_parameter_changes(&mut self) {
|
fn check_parameter_changes(&mut self) {
|
||||||
// Check all input parameters for value changes
|
// Check all input parameters for value changes
|
||||||
|
let mut checked_count = 0;
|
||||||
|
let mut connection_only_count = 0;
|
||||||
|
let mut non_float_count = 0;
|
||||||
|
|
||||||
for (input_id, input_param) in &self.state.graph.inputs {
|
for (input_id, input_param) in &self.state.graph.inputs {
|
||||||
// Only check parameters that can have constant values (not ConnectionOnly)
|
// Only check parameters that can have constant values (not ConnectionOnly)
|
||||||
if matches!(input_param.kind, InputParamKind::ConnectionOnly) {
|
if matches!(input_param.kind, InputParamKind::ConnectionOnly) {
|
||||||
|
connection_only_count += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get current value
|
// Get current value
|
||||||
let current_value = match &input_param.value {
|
let current_value = match &input_param.value {
|
||||||
ValueType::Float { value } => *value,
|
ValueType::Float { value } => {
|
||||||
_ => continue, // Skip non-float values for now
|
checked_count += 1;
|
||||||
|
*value
|
||||||
|
},
|
||||||
|
other => {
|
||||||
|
non_float_count += 1;
|
||||||
|
eprintln!("[DEBUG] Non-float parameter type: {:?}", std::mem::discriminant(other));
|
||||||
|
continue;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Check if value has changed
|
// Check if value has changed
|
||||||
let previous_value = self.parameter_values.get(&input_id).copied();
|
let previous_value = self.parameter_values.get(&input_id).copied();
|
||||||
if previous_value.is_none() || (previous_value.unwrap() - current_value).abs() > 0.0001 {
|
let has_changed = if let Some(prev) = previous_value {
|
||||||
|
(prev - current_value).abs() > 0.0001
|
||||||
|
} else {
|
||||||
|
// First time seeing this parameter - don't send update, just store it
|
||||||
|
self.parameter_values.insert(input_id, current_value);
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
if has_changed {
|
||||||
// Value has changed, create SetParameterAction
|
// Value has changed, create SetParameterAction
|
||||||
if let Some(track_id) = self.track_id {
|
if let Some(track_id) = self.track_id {
|
||||||
let node_id = input_param.node;
|
let node_id = input_param.node;
|
||||||
|
|
@ -447,6 +472,8 @@ impl NodeGraphPane {
|
||||||
// Get parameter index (position in node's inputs array)
|
// Get parameter index (position in node's inputs array)
|
||||||
if let Some(node) = self.state.graph.nodes.get(node_id) {
|
if let Some(node) = self.state.graph.nodes.get(node_id) {
|
||||||
if let Some(param_index) = node.inputs.iter().position(|(_, id)| *id == input_id) {
|
if let Some(param_index) = node.inputs.iter().position(|(_, id)| *id == input_id) {
|
||||||
|
eprintln!("[DEBUG] Parameter changed: node {:?} param {} from {:?} to {}",
|
||||||
|
backend_id, param_index, previous_value, current_value);
|
||||||
// Create action to update backend
|
// Create action to update backend
|
||||||
let action = Box::new(actions::NodeGraphAction::SetParameter(
|
let action = Box::new(actions::NodeGraphAction::SetParameter(
|
||||||
actions::SetParameterAction::new(
|
actions::SetParameterAction::new(
|
||||||
|
|
@ -466,6 +493,11 @@ impl NodeGraphPane {
|
||||||
self.parameter_values.insert(input_id, current_value);
|
self.parameter_values.insert(input_id, current_value);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if checked_count > 0 || connection_only_count > 0 || non_float_count > 0 {
|
||||||
|
eprintln!("[DEBUG] Parameter check: {} float params checked, {} connection-only, {} non-float",
|
||||||
|
checked_count, connection_only_count, non_float_count);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_dot_grid_background(
|
fn draw_dot_grid_background(
|
||||||
|
|
@ -644,6 +676,9 @@ impl crate::panes::PaneRenderer for NodeGraphPane {
|
||||||
// Check for parameter value changes and send updates to backend
|
// Check for parameter value changes and send updates to backend
|
||||||
self.check_parameter_changes();
|
self.check_parameter_changes();
|
||||||
|
|
||||||
|
// Execute any parameter change actions
|
||||||
|
self.execute_pending_action(shared);
|
||||||
|
|
||||||
// Override library's default scroll behavior:
|
// Override library's default scroll behavior:
|
||||||
// - Library uses scroll for zoom
|
// - Library uses scroll for zoom
|
||||||
// - We want: scroll = pan, ctrl+scroll = zoom
|
// - We want: scroll = pan, ctrl+scroll = zoom
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,398 @@
|
||||||
|
//! Preferences dialog UI
|
||||||
|
//!
|
||||||
|
//! Provides a user interface for configuring application preferences
|
||||||
|
|
||||||
|
use eframe::egui;
|
||||||
|
use crate::config::AppConfig;
|
||||||
|
use crate::theme::{Theme, ThemeMode};
|
||||||
|
|
||||||
|
/// Preferences dialog state
|
||||||
|
pub struct PreferencesDialog {
|
||||||
|
/// Is the dialog open?
|
||||||
|
pub open: bool,
|
||||||
|
|
||||||
|
/// Working copy of preferences (allows cancel to discard changes)
|
||||||
|
working_prefs: PreferencesState,
|
||||||
|
|
||||||
|
/// Original audio buffer size (to detect changes that need restart)
|
||||||
|
original_buffer_size: u32,
|
||||||
|
|
||||||
|
/// Error message (if validation fails)
|
||||||
|
error_message: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Editable preferences state (working copy)
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct PreferencesState {
|
||||||
|
bpm: u32,
|
||||||
|
framerate: u32,
|
||||||
|
file_width: u32,
|
||||||
|
file_height: u32,
|
||||||
|
scroll_speed: f64,
|
||||||
|
audio_buffer_size: u32,
|
||||||
|
reopen_last_session: bool,
|
||||||
|
restore_layout_from_file: bool,
|
||||||
|
debug: bool,
|
||||||
|
theme_mode: ThemeMode,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<(&AppConfig, &Theme)> for PreferencesState {
|
||||||
|
fn from((config, theme): (&AppConfig, &Theme)) -> Self {
|
||||||
|
Self {
|
||||||
|
bpm: config.bpm,
|
||||||
|
framerate: config.framerate,
|
||||||
|
file_width: config.file_width,
|
||||||
|
file_height: config.file_height,
|
||||||
|
scroll_speed: config.scroll_speed,
|
||||||
|
audio_buffer_size: config.audio_buffer_size,
|
||||||
|
reopen_last_session: config.reopen_last_session,
|
||||||
|
restore_layout_from_file: config.restore_layout_from_file,
|
||||||
|
debug: config.debug,
|
||||||
|
theme_mode: theme.mode(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PreferencesState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
bpm: 120,
|
||||||
|
framerate: 24,
|
||||||
|
file_width: 800,
|
||||||
|
file_height: 600,
|
||||||
|
scroll_speed: 1.0,
|
||||||
|
audio_buffer_size: 256,
|
||||||
|
reopen_last_session: false,
|
||||||
|
restore_layout_from_file: true,
|
||||||
|
debug: false,
|
||||||
|
theme_mode: ThemeMode::System,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result returned when preferences are saved
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PreferencesSaveResult {
|
||||||
|
/// Whether audio buffer size changed (requires restart)
|
||||||
|
pub buffer_size_changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PreferencesDialog {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
open: false,
|
||||||
|
working_prefs: PreferencesState::default(),
|
||||||
|
original_buffer_size: 256,
|
||||||
|
error_message: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PreferencesDialog {
|
||||||
|
/// Open the dialog with current config and theme
|
||||||
|
pub fn open(&mut self, config: &AppConfig, theme: &Theme) {
|
||||||
|
self.open = true;
|
||||||
|
self.working_prefs = PreferencesState::from((config, theme));
|
||||||
|
self.original_buffer_size = config.audio_buffer_size;
|
||||||
|
self.error_message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Close the dialog
|
||||||
|
pub fn close(&mut self) {
|
||||||
|
self.open = false;
|
||||||
|
self.error_message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Render the preferences dialog
|
||||||
|
///
|
||||||
|
/// Returns Some(PreferencesSaveResult) if user clicked Save, None otherwise.
|
||||||
|
pub fn render(
|
||||||
|
&mut self,
|
||||||
|
ctx: &egui::Context,
|
||||||
|
config: &mut AppConfig,
|
||||||
|
theme: &mut Theme,
|
||||||
|
) -> Option<PreferencesSaveResult> {
|
||||||
|
if !self.open {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut should_save = false;
|
||||||
|
let mut should_cancel = false;
|
||||||
|
let mut open = self.open;
|
||||||
|
|
||||||
|
egui::Window::new("Preferences")
|
||||||
|
.open(&mut open)
|
||||||
|
.resizable(false)
|
||||||
|
.collapsible(false)
|
||||||
|
.anchor(egui::Align2::CENTER_CENTER, egui::Vec2::ZERO)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
ui.set_width(500.0);
|
||||||
|
|
||||||
|
// Error message
|
||||||
|
if let Some(error) = &self.error_message {
|
||||||
|
ui.colored_label(egui::Color32::from_rgb(255, 100, 100), error);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scrollable area for preferences sections
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(400.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
self.render_general_section(ui);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
self.render_audio_section(ui);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
self.render_appearance_section(ui);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
self.render_startup_section(ui);
|
||||||
|
ui.add_space(8.0);
|
||||||
|
self.render_advanced_section(ui);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(16.0);
|
||||||
|
|
||||||
|
// Buttons
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
if ui.button("Cancel").clicked() {
|
||||||
|
should_cancel = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ui.button("Reset to Defaults").clicked() {
|
||||||
|
self.reset_to_defaults();
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
if ui.button("Save").clicked() {
|
||||||
|
should_save = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update open state
|
||||||
|
self.open = open;
|
||||||
|
|
||||||
|
if should_cancel {
|
||||||
|
self.close();
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_save {
|
||||||
|
return self.handle_save(config, theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_general_section(&mut self, ui: &mut egui::Ui) {
|
||||||
|
egui::CollapsingHeader::new("General")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Default BPM:");
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(&mut self.working_prefs.bpm)
|
||||||
|
.clamp_range(20..=300)
|
||||||
|
.speed(1.0),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Default Framerate:");
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(&mut self.working_prefs.framerate)
|
||||||
|
.clamp_range(1..=120)
|
||||||
|
.speed(1.0)
|
||||||
|
.suffix(" fps"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Default File Width:");
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(&mut self.working_prefs.file_width)
|
||||||
|
.clamp_range(100..=10000)
|
||||||
|
.speed(10.0)
|
||||||
|
.suffix(" px"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Default File Height:");
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(&mut self.working_prefs.file_height)
|
||||||
|
.clamp_range(100..=10000)
|
||||||
|
.speed(10.0)
|
||||||
|
.suffix(" px"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Scroll Speed:");
|
||||||
|
ui.add(
|
||||||
|
egui::DragValue::new(&mut self.working_prefs.scroll_speed)
|
||||||
|
.clamp_range(0.1..=10.0)
|
||||||
|
.speed(0.1),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_audio_section(&mut self, ui: &mut egui::Ui) {
|
||||||
|
egui::CollapsingHeader::new("Audio")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Audio Buffer Size:");
|
||||||
|
|
||||||
|
egui::ComboBox::from_id_source("audio_buffer_size")
|
||||||
|
.selected_text(format!("{} samples", self.working_prefs.audio_buffer_size))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.audio_buffer_size,
|
||||||
|
128,
|
||||||
|
"128 samples (~3ms - Low latency)",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.audio_buffer_size,
|
||||||
|
256,
|
||||||
|
"256 samples (~6ms - Balanced)",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.audio_buffer_size,
|
||||||
|
512,
|
||||||
|
"512 samples (~12ms - Stable)",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.audio_buffer_size,
|
||||||
|
1024,
|
||||||
|
"1024 samples (~23ms - Very stable)",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.audio_buffer_size,
|
||||||
|
2048,
|
||||||
|
"2048 samples (~46ms - Low-end systems)",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.audio_buffer_size,
|
||||||
|
4096,
|
||||||
|
"4096 samples (~93ms - Very low-end systems)",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.label("⚠ Requires app restart to take effect");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_appearance_section(&mut self, ui: &mut egui::Ui) {
|
||||||
|
egui::CollapsingHeader::new("Appearance")
|
||||||
|
.default_open(true)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label("Theme:");
|
||||||
|
|
||||||
|
egui::ComboBox::from_id_source("theme_mode")
|
||||||
|
.selected_text(format!("{:?}", self.working_prefs.theme_mode))
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.theme_mode,
|
||||||
|
ThemeMode::Light,
|
||||||
|
"Light",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.theme_mode,
|
||||||
|
ThemeMode::Dark,
|
||||||
|
"Dark",
|
||||||
|
);
|
||||||
|
ui.selectable_value(
|
||||||
|
&mut self.working_prefs.theme_mode,
|
||||||
|
ThemeMode::System,
|
||||||
|
"System",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_startup_section(&mut self, ui: &mut egui::Ui) {
|
||||||
|
egui::CollapsingHeader::new("Startup")
|
||||||
|
.default_open(false)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.working_prefs.reopen_last_session,
|
||||||
|
"Reopen last session on startup",
|
||||||
|
);
|
||||||
|
ui.checkbox(
|
||||||
|
&mut self.working_prefs.restore_layout_from_file,
|
||||||
|
"Restore layout when opening files",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_advanced_section(&mut self, ui: &mut egui::Ui) {
|
||||||
|
egui::CollapsingHeader::new("Advanced")
|
||||||
|
.default_open(false)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.checkbox(&mut self.working_prefs.debug, "Enable debug mode");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reset_to_defaults(&mut self) {
|
||||||
|
self.working_prefs = PreferencesState::default();
|
||||||
|
self.error_message = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_save(
|
||||||
|
&mut self,
|
||||||
|
config: &mut AppConfig,
|
||||||
|
theme: &mut Theme,
|
||||||
|
) -> Option<PreferencesSaveResult> {
|
||||||
|
// Create temp config for validation
|
||||||
|
let mut temp_config = config.clone();
|
||||||
|
temp_config.bpm = self.working_prefs.bpm;
|
||||||
|
temp_config.framerate = self.working_prefs.framerate;
|
||||||
|
temp_config.file_width = self.working_prefs.file_width;
|
||||||
|
temp_config.file_height = self.working_prefs.file_height;
|
||||||
|
temp_config.scroll_speed = self.working_prefs.scroll_speed;
|
||||||
|
temp_config.audio_buffer_size = self.working_prefs.audio_buffer_size;
|
||||||
|
temp_config.reopen_last_session = self.working_prefs.reopen_last_session;
|
||||||
|
temp_config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
|
||||||
|
temp_config.debug = self.working_prefs.debug;
|
||||||
|
temp_config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
|
||||||
|
|
||||||
|
// Validate
|
||||||
|
if let Err(err) = temp_config.validate() {
|
||||||
|
self.error_message = Some(err);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if buffer size changed
|
||||||
|
let buffer_size_changed = self.working_prefs.audio_buffer_size != self.original_buffer_size;
|
||||||
|
|
||||||
|
// Apply changes to config
|
||||||
|
config.bpm = self.working_prefs.bpm;
|
||||||
|
config.framerate = self.working_prefs.framerate;
|
||||||
|
config.file_width = self.working_prefs.file_width;
|
||||||
|
config.file_height = self.working_prefs.file_height;
|
||||||
|
config.scroll_speed = self.working_prefs.scroll_speed;
|
||||||
|
config.audio_buffer_size = self.working_prefs.audio_buffer_size;
|
||||||
|
config.reopen_last_session = self.working_prefs.reopen_last_session;
|
||||||
|
config.restore_layout_from_file = self.working_prefs.restore_layout_from_file;
|
||||||
|
config.debug = self.working_prefs.debug;
|
||||||
|
config.theme_mode = self.working_prefs.theme_mode.to_string_lower();
|
||||||
|
|
||||||
|
// Apply theme immediately
|
||||||
|
theme.set_mode(self.working_prefs.theme_mode);
|
||||||
|
|
||||||
|
// Save to disk
|
||||||
|
config.save();
|
||||||
|
|
||||||
|
// Close dialog
|
||||||
|
self.close();
|
||||||
|
|
||||||
|
Some(PreferencesSaveResult {
|
||||||
|
buffer_size_changed,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
//! Preferences module
|
||||||
|
//!
|
||||||
|
//! User preferences dialog and settings management
|
||||||
|
|
||||||
|
pub mod dialog;
|
||||||
|
|
||||||
|
pub use dialog::{PreferencesDialog, PreferencesSaveResult};
|
||||||
|
|
@ -15,6 +15,26 @@ pub enum ThemeMode {
|
||||||
System, // Follow system preference
|
System, // Follow system preference
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl ThemeMode {
|
||||||
|
/// Convert from string ("light", "dark", or "system")
|
||||||
|
pub fn from_string(s: &str) -> Self {
|
||||||
|
match s.to_lowercase().as_str() {
|
||||||
|
"light" => Self::Light,
|
||||||
|
"dark" => Self::Dark,
|
||||||
|
_ => Self::System,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert to lowercase string
|
||||||
|
pub fn to_string_lower(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::Light => "light".to_string(),
|
||||||
|
Self::Dark => "dark".to_string(),
|
||||||
|
Self::System => "system".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Style properties that can be applied to UI elements
|
/// Style properties that can be applied to UI elements
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct Style {
|
pub struct Style {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue