From 36cfd961da036eff4ef6acb86b982fd69ba62d81 Mon Sep 17 00:00:00 2001 From: Simon Gellis Date: Sat, 31 Oct 2026 01:10:03 -0400 Subject: [PATCH] Config synchronizes across app --- src/app.rs | 9 +++-- src/config.rs | 37 +++++++----------- src/filesystem.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++- src/main.rs | 5 +-- src/window/game.rs | 31 ++++++---------- 5 files changed, 124 insertions(+), 51 deletions(-) diff --git a/src/app.rs b/src/app.rs index a83f84e..b276224 100644 --- a/src/app.rs +++ b/src/app.rs @@ -23,7 +23,7 @@ use winit::{ }; use crate::{ - config::{AppConfig, CliArgs}, + config::{AppConfig, CliArgs, Config}, controller::ControllerManager, emulator::{EmulatorClient, EmulatorCommand, SimId}, filesystem::Persistence, @@ -94,7 +94,7 @@ impl Application { handle: OwnedDisplayHandle, persistence: Persistence, args: CliArgs, - config: AppConfig, + config: Config, ) -> Self { let wgpu = WgpuState::new(handle, config); let icon = load_icon().ok().map(Arc::new); @@ -504,11 +504,11 @@ impl ApplicationHandler for Application { struct WgpuState { instance: wgpu::Instance, device_descriptor: Arc wgpu::DeviceDescriptor<'static> + Send + Sync>, - config: AppConfig, + config: Config, } impl WgpuState { - fn new(handle: OwnedDisplayHandle, config: AppConfig) -> Self { + fn new(handle: OwnedDisplayHandle, config: Config) -> Self { #[allow(unused_variables)] let egui_wgpu::WgpuSetupCreateNew { instance_descriptor: wgpu::InstanceDescriptor { backends, .. }, @@ -531,6 +531,7 @@ impl WgpuState { } fn init(&mut self, window: &Window) -> egui_wgpu::WgpuSetupExisting { + self.config.refresh(); let power_preference = wgpu::PowerPreference::from_env().unwrap_or(self.config.power_preference); debug!("power preference: {power_preference:?}"); diff --git a/src/config.rs b/src/config.rs index 0706c3b..8006480 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,9 +1,9 @@ -use anyhow::Result; use bytemuck::NoUninit; use clap::{Parser, ValueEnum}; use egui::{Color32, Pos2, Vec2}; use serde::{Deserialize, Serialize}; +pub use crate::filesystem::Config; use crate::{emulator::SimId, filesystem::Persistence, window::DisplayMode}; use std::path::PathBuf; @@ -116,38 +116,31 @@ pub struct AppConfig { #[serde(default = "default_save_data_location")] pub save_data_location: SaveDataLocation, } - -impl AppConfig { - pub fn load(persistence: &Persistence) -> Self { - if let Ok(config) = persistence.load_config(APP_CONFIG_FILENAME) { - return config; - } +impl Default for AppConfig { + fn default() -> Self { Self { power_preference: default_power_preference(), force_fallback: false, save_data_location: default_save_data_location(), } } +} - pub fn save(&self, persistence: &Persistence) -> Result<()> { - persistence.save_config(APP_CONFIG_FILENAME, &self) +impl AppConfig { + pub fn load(persistence: &Persistence) -> Config { + persistence.config(APP_CONFIG_FILENAME) } - pub fn update(&mut self, args: &CliArgs) -> bool { - let mut updated = false; + pub fn apply_updates(&mut self, args: &CliArgs) { if let Some(pref) = args.wgpu_power_preference { self.power_preference = pref.into(); - updated = true; } if let Some(force_fallback) = args.wgpu_force_fallback { self.force_fallback = force_fallback; - updated = true; } if let Some(save_data_location) = args.save_data_location { self.save_data_location = save_data_location; - updated = true; } - updated } } @@ -171,12 +164,8 @@ pub struct SimConfig { #[serde(default)] pub position: Option, } - -impl SimConfig { - pub fn load(persistence: &Persistence, sim_id: SimId) -> Self { - if let Ok(config) = persistence.load_config(sim_config_filename(sim_id)) { - return config; - } +impl Default for SimConfig { + fn default() -> Self { Self { display_mode: DisplayMode::Anaglyph, colors: COLOR_PRESETS[0], @@ -187,9 +176,11 @@ impl SimConfig { position: None, } } +} - pub fn save(&self, persistence: &Persistence, sim_id: SimId) -> Result<()> { - persistence.save_config(sim_config_filename(sim_id), self) +impl SimConfig { + pub fn load(persistence: &Persistence, sim_id: SimId) -> Config { + persistence.config(sim_config_filename(sim_id)) } } diff --git a/src/filesystem.rs b/src/filesystem.rs index fbf093d..82b9b5b 100644 --- a/src/filesystem.rs +++ b/src/filesystem.rs @@ -1,4 +1,13 @@ -use std::{fs, path::PathBuf}; +use std::{ + collections::HashMap, + fs, + ops::Deref, + path::PathBuf, + sync::{ + Arc, Mutex, + atomic::{AtomicU64, Ordering}, + }, +}; use anyhow::{Result, bail}; use directories::ProjectDirs; @@ -7,12 +16,42 @@ use serde::{Deserialize, Serialize}; #[derive(Clone)] pub struct Persistence { config_dir: Option, + versions: Arc>>>, } impl Persistence { pub fn new() -> Self { Self { config_dir: init_config_dir(), + versions: Arc::new(Mutex::new(HashMap::new())), + } + } + + pub fn config(&self, file: &str) -> Config { + let latest_version = self + .versions + .lock() + .unwrap() + .entry(file.to_string()) + .or_default() + .clone(); + let path = self + .config_dir + .as_ref() + .map(|p| p.join(file).with_extension("json")); + let value = if let Some(p) = path.as_ref() + && let Ok(bytes) = fs::read(p) + && let Ok(v) = serde_json::from_slice(&bytes) + { + v + } else { + T::default() + }; + Config { + value, + version: latest_version.load(Ordering::Relaxed), + latest_version, + path, } } @@ -50,3 +89,55 @@ pub fn init_data_dir() -> Option { fn project_dirs() -> Option { ProjectDirs::from("com", "virtual-boy", "Lemur") } + +pub trait ConfigCompatible: Clone + Default + Eq + Serialize + for<'a> Deserialize<'a> {} +impl Deserialize<'a>> ConfigCompatible for T {} + +// A wrapper for a T (probably) stored on the filesystem. +// Cheap to dereference, easy to sync changes to all copies. +pub struct Config { + value: T, + version: u64, + latest_version: Arc, + path: Option, +} + +impl Deref for Config { + type Target = T; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl Config { + pub fn refresh(&mut self) { + let version = self.latest_version.load(Ordering::Relaxed); + if self.version == version { + return; + } + if let Some(path) = &self.path + && let Ok(bytes) = fs::read(path) + && let Ok(latest) = serde_json::from_slice(&bytes) + { + self.value = latest; + } + self.version = version; + } + + pub fn update(&mut self, update: impl FnOnce(&mut T)) { + let mut new_value = self.value.clone(); + update(&mut new_value); + if self.value == new_value { + return; + } + if let Some(path) = &self.path + && let Ok(bytes) = serde_json::to_vec(&new_value) + { + let _ = fs::write(path, bytes); + } + let version = self.latest_version.fetch_add(1, Ordering::Relaxed) + 1; + self.value = new_value.clone(); + self.version = version; + } +} diff --git a/src/main.rs b/src/main.rs index 0b568cb..564a17f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -94,9 +94,8 @@ fn main() -> Result<()> { let persistence = Persistence::new(); let mut config = AppConfig::load(&persistence); - if config.update(&args) { - let _ = config.save(&persistence); - } + config.update(|c| c.apply_updates(&args)); + let p1 = SimConfig::load(&persistence, SimId::Player1); let p2 = SimConfig::load(&persistence, SimId::Player2); diff --git a/src/window/game.rs b/src/window/game.rs index adba4d4..2e4823b 100644 --- a/src/window/game.rs +++ b/src/window/game.rs @@ -7,7 +7,7 @@ use std::{ use crate::{ app::UserEvent, - config::{AppConfig, COLOR_PRESETS, SaveDataLocation, SimConfig}, + config::{AppConfig, COLOR_PRESETS, Config, SaveDataLocation, SimConfig}, emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState}, filepicker::FilePicker, filesystem::Persistence, @@ -42,7 +42,7 @@ pub struct GameWindow { persistence: Persistence, shortcuts: ShortcutProvider, sim_id: SimId, - config: SimConfig, + config: Config, toasts: Toasts, screen: Option, messages: mpsc::Receiver, @@ -334,7 +334,7 @@ impl GameWindow { } let watch_rom = self.client.is_rom_watched(self.sim_id); if ui.selectable_button(watch_rom, "Watch ROM").clicked() { - self.update_config(|c| c.watch_rom = !watch_rom); + self.config.update(|c| c.watch_rom = !watch_rom); self.client .send_command(EmulatorCommand::WatchRom(self.sim_id, !watch_rom)); } @@ -395,7 +395,7 @@ impl GameWindow { ui.separator(); let low_battery = self.config.low_battery; if ui.selectable_button(low_battery, "Low Battery").clicked() { - self.update_config(|c| c.low_battery = !low_battery); + self.config.update(|c| c.low_battery = !low_battery); let _ = self .proxy .send_event(UserEvent::LowBattery(self.sim_id, !low_battery)); @@ -551,7 +551,7 @@ impl GameWindow { ui.send_viewport_cmd(ViewportCommand::InnerSize(current_dims * scale)); } - self.update_config(|c| { + self.config.update(|c| { c.display_mode = display_mode; c.dimensions = current_dims * scale; }); @@ -559,7 +559,7 @@ impl GameWindow { ui.menu_button("Colors", |ui| { for preset in COLOR_PRESETS { if ui.color_pair_button(preset[0], preset[1]).clicked() { - self.update_config(|c| c.colors = preset); + self.config.update(|c| c.colors = preset); } } ui.with_layout(ui.layout().with_cross_align(egui::Align::Center), |ui| { @@ -589,7 +589,7 @@ impl GameWindow { .selectable_button(self.config.audio_enabled, "Enabled") .clicked() { - self.update_config(|c| c.audio_enabled = !c.audio_enabled); + self.config.update(|c| c.audio_enabled = !c.audio_enabled); self.client.send_command(EmulatorCommand::SetAudioEnabled( self.sim_id, self.config.audio_enabled, @@ -611,8 +611,7 @@ impl GameWindow { return; }; let mut app_config = AppConfig::load(&self.persistence); - app_config.save_data_location = new_location; - let _ = app_config.save(&self.persistence); + app_config.update(|c| c.save_data_location = new_location); self.client .send_command(EmulatorCommand::SetSaveDataLocation(new_location)); }; @@ -662,19 +661,10 @@ impl GameWindow { self.color_picker = None; } if updated { - self.update_config(|c| c.colors = colors); + self.config.update(|c| c.colors = colors); } } - fn update_config(&mut self, update: impl FnOnce(&mut SimConfig)) { - let mut new_config = self.config.clone(); - update(&mut new_config); - if self.config != new_config { - let _ = new_config.save(&self.persistence, self.sim_id); - } - self.config = new_config; - } - fn button_for(&self, ctx: &Context, text: &str, command: Command) -> Button<'_> { let button = Button::new(text); match self.shortcuts.shortcut_for(command) { @@ -701,6 +691,7 @@ impl AppWindow for GameWindow { } fn show(&mut self, ui: &mut Ui) { + self.config.refresh(); let has_player_2 = self.client.has_player_2(); if !self.show_player_name && has_player_2 { ui.send_viewport_cmd(ViewportCommand::Title(format!("Lemur ({})", self.sim_id))); @@ -731,7 +722,7 @@ impl AppWindow for GameWindow { bounds.max - bounds.min }; let position = ui.input(|i| i.viewport().outer_rect.map(|r| r.min)); - self.update_config(|c| { + self.config.update(|c| { c.dimensions = dimensions; c.position = position; });