Lightningbeam/lightningbeam-ui/lightningbeam-editor/src/config.rs

274 lines
8.9 KiB
Rust

use serde::{Deserialize, Serialize};
use std::path::PathBuf;
/// Application configuration (persistent)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppConfig {
/// Recent files list (newest first, max 10 items)
#[serde(default)]
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,
/// Show waveforms as stacked stereo instead of combined mono
#[serde(default = "defaults::waveform_stereo")]
pub waveform_stereo: bool,
/// Theme mode ("light", "dark", or "system")
#[serde(default = "defaults::theme_mode")]
pub theme_mode: String,
}
impl Default for AppConfig {
fn default() -> Self {
Self {
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(),
waveform_stereo: defaults::waveform_stereo(),
theme_mode: defaults::theme_mode(),
}
}
}
impl AppConfig {
/// Load config from standard location
/// Returns default config if file doesn't exist or is malformed
pub fn load() -> Self {
match Self::try_load() {
Ok(config) => config,
Err(e) => {
eprintln!("⚠️ Failed to load config: {}", e);
eprintln!(" Using default configuration");
Self::default()
}
}
}
/// Try to load config, returning error if something goes wrong
fn try_load() -> Result<Self, Box<dyn std::error::Error>> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Self::default());
}
let contents = std::fs::read_to_string(&config_path)?;
let config: AppConfig = serde_json::from_str(&contents)?;
Ok(config)
}
/// Save config to standard location
/// Logs error but doesn't block if save fails
pub fn save(&self) {
if let Err(e) = self.try_save() {
eprintln!("⚠️ Failed to save config: {}", e);
}
}
/// Try to save config atomically (write to temp, then rename)
fn try_save(&self) -> Result<(), Box<dyn std::error::Error>> {
let config_path = Self::config_path()?;
// Ensure parent directory exists
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
// Serialize to JSON with pretty formatting
let json = serde_json::to_string_pretty(self)?;
// Atomic write: write to temp file, then rename
let temp_path = config_path.with_extension("json.tmp");
std::fs::write(&temp_path, json)?;
std::fs::rename(temp_path, config_path)?;
Ok(())
}
/// Get cross-platform config file path
fn config_path() -> Result<PathBuf, Box<dyn std::error::Error>> {
use directories::ProjectDirs;
let proj_dirs = ProjectDirs::from("", "", "lightningbeam")
.ok_or("Failed to determine config directory")?;
Ok(proj_dirs.config_dir().join("config.json"))
}
/// Add a file to recent files list
/// - Canonicalize path (resolve relative paths and symlinks)
/// - Move to front if already in list (remove duplicates)
/// - Enforce 10-item limit (LRU eviction)
/// - Auto-save config
pub fn add_recent_file(&mut self, path: PathBuf) {
// Try to canonicalize path (absolute, resolve symlinks)
let canonical = match path.canonicalize() {
Ok(p) => p,
Err(e) => {
// Canonicalize can fail for unsaved files or deleted files
eprintln!("⚠️ Could not canonicalize path {:?}: {}", path, e);
return; // Don't add non-existent paths
}
};
// Remove if already present (we'll add to front)
self.recent_files.retain(|p| p != &canonical);
// Add to front
self.recent_files.insert(0, canonical);
// Enforce 10-item limit
self.recent_files.truncate(10);
// Auto-save
self.save();
}
/// Get recent files list, filtering out files that no longer exist
/// Returns newest first
pub fn get_recent_files(&self) -> Vec<PathBuf> {
self.recent_files
.iter()
.filter(|p| p.exists())
.cloned()
.collect()
}
/// Clear all recent files
pub fn clear_recent_files(&mut self) {
self.recent_files.clear();
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 waveform_stereo() -> bool { false }
pub fn theme_mode() -> String { "system".to_string() }
}