Config synchronizes across app

This commit is contained in:
2026-10-31 01:10:16 -04:00
parent 9bcd99e437
commit 36cfd961da
5 changed files with 124 additions and 51 deletions
+5 -4
View File
@@ -23,7 +23,7 @@ use winit::{
}; };
use crate::{ use crate::{
config::{AppConfig, CliArgs}, config::{AppConfig, CliArgs, Config},
controller::ControllerManager, controller::ControllerManager,
emulator::{EmulatorClient, EmulatorCommand, SimId}, emulator::{EmulatorClient, EmulatorCommand, SimId},
filesystem::Persistence, filesystem::Persistence,
@@ -94,7 +94,7 @@ impl Application {
handle: OwnedDisplayHandle, handle: OwnedDisplayHandle,
persistence: Persistence, persistence: Persistence,
args: CliArgs, args: CliArgs,
config: AppConfig, config: Config<AppConfig>,
) -> Self { ) -> Self {
let wgpu = WgpuState::new(handle, config); let wgpu = WgpuState::new(handle, config);
let icon = load_icon().ok().map(Arc::new); let icon = load_icon().ok().map(Arc::new);
@@ -504,11 +504,11 @@ impl ApplicationHandler<UserEvent> for Application {
struct WgpuState { struct WgpuState {
instance: wgpu::Instance, instance: wgpu::Instance,
device_descriptor: Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>, device_descriptor: Arc<dyn Fn(&wgpu::Adapter) -> wgpu::DeviceDescriptor<'static> + Send + Sync>,
config: AppConfig, config: Config<AppConfig>,
} }
impl WgpuState { impl WgpuState {
fn new(handle: OwnedDisplayHandle, config: AppConfig) -> Self { fn new(handle: OwnedDisplayHandle, config: Config<AppConfig>) -> Self {
#[allow(unused_variables)] #[allow(unused_variables)]
let egui_wgpu::WgpuSetupCreateNew { let egui_wgpu::WgpuSetupCreateNew {
instance_descriptor: wgpu::InstanceDescriptor { backends, .. }, instance_descriptor: wgpu::InstanceDescriptor { backends, .. },
@@ -531,6 +531,7 @@ impl WgpuState {
} }
fn init(&mut self, window: &Window) -> egui_wgpu::WgpuSetupExisting { fn init(&mut self, window: &Window) -> egui_wgpu::WgpuSetupExisting {
self.config.refresh();
let power_preference = let power_preference =
wgpu::PowerPreference::from_env().unwrap_or(self.config.power_preference); wgpu::PowerPreference::from_env().unwrap_or(self.config.power_preference);
debug!("power preference: {power_preference:?}"); debug!("power preference: {power_preference:?}");
+14 -23
View File
@@ -1,9 +1,9 @@
use anyhow::Result;
use bytemuck::NoUninit; use bytemuck::NoUninit;
use clap::{Parser, ValueEnum}; use clap::{Parser, ValueEnum};
use egui::{Color32, Pos2, Vec2}; use egui::{Color32, Pos2, Vec2};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub use crate::filesystem::Config;
use crate::{emulator::SimId, filesystem::Persistence, window::DisplayMode}; use crate::{emulator::SimId, filesystem::Persistence, window::DisplayMode};
use std::path::PathBuf; use std::path::PathBuf;
@@ -116,38 +116,31 @@ pub struct AppConfig {
#[serde(default = "default_save_data_location")] #[serde(default = "default_save_data_location")]
pub save_data_location: SaveDataLocation, pub save_data_location: SaveDataLocation,
} }
impl Default for AppConfig {
impl AppConfig { fn default() -> Self {
pub fn load(persistence: &Persistence) -> Self {
if let Ok(config) = persistence.load_config(APP_CONFIG_FILENAME) {
return config;
}
Self { Self {
power_preference: default_power_preference(), power_preference: default_power_preference(),
force_fallback: false, force_fallback: false,
save_data_location: default_save_data_location(), save_data_location: default_save_data_location(),
} }
} }
}
pub fn save(&self, persistence: &Persistence) -> Result<()> { impl AppConfig {
persistence.save_config(APP_CONFIG_FILENAME, &self) pub fn load(persistence: &Persistence) -> Config<Self> {
persistence.config(APP_CONFIG_FILENAME)
} }
pub fn update(&mut self, args: &CliArgs) -> bool { pub fn apply_updates(&mut self, args: &CliArgs) {
let mut updated = false;
if let Some(pref) = args.wgpu_power_preference { if let Some(pref) = args.wgpu_power_preference {
self.power_preference = pref.into(); self.power_preference = pref.into();
updated = true;
} }
if let Some(force_fallback) = args.wgpu_force_fallback { if let Some(force_fallback) = args.wgpu_force_fallback {
self.force_fallback = force_fallback; self.force_fallback = force_fallback;
updated = true;
} }
if let Some(save_data_location) = args.save_data_location { if let Some(save_data_location) = args.save_data_location {
self.save_data_location = save_data_location; self.save_data_location = save_data_location;
updated = true;
} }
updated
} }
} }
@@ -171,12 +164,8 @@ pub struct SimConfig {
#[serde(default)] #[serde(default)]
pub position: Option<Pos2>, pub position: Option<Pos2>,
} }
impl Default for SimConfig {
impl SimConfig { fn default() -> Self {
pub fn load(persistence: &Persistence, sim_id: SimId) -> Self {
if let Ok(config) = persistence.load_config(sim_config_filename(sim_id)) {
return config;
}
Self { Self {
display_mode: DisplayMode::Anaglyph, display_mode: DisplayMode::Anaglyph,
colors: COLOR_PRESETS[0], colors: COLOR_PRESETS[0],
@@ -187,9 +176,11 @@ impl SimConfig {
position: None, position: None,
} }
} }
}
pub fn save(&self, persistence: &Persistence, sim_id: SimId) -> Result<()> { impl SimConfig {
persistence.save_config(sim_config_filename(sim_id), self) pub fn load(persistence: &Persistence, sim_id: SimId) -> Config<Self> {
persistence.config(sim_config_filename(sim_id))
} }
} }
+92 -1
View File
@@ -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 anyhow::{Result, bail};
use directories::ProjectDirs; use directories::ProjectDirs;
@@ -7,12 +16,42 @@ use serde::{Deserialize, Serialize};
#[derive(Clone)] #[derive(Clone)]
pub struct Persistence { pub struct Persistence {
config_dir: Option<PathBuf>, config_dir: Option<PathBuf>,
versions: Arc<Mutex<HashMap<String, Arc<AtomicU64>>>>,
} }
impl Persistence { impl Persistence {
pub fn new() -> Self { pub fn new() -> Self {
Self { Self {
config_dir: init_config_dir(), config_dir: init_config_dir(),
versions: Arc::new(Mutex::new(HashMap::new())),
}
}
pub fn config<T: ConfigCompatible>(&self, file: &str) -> Config<T> {
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<PathBuf> {
fn project_dirs() -> Option<ProjectDirs> { fn project_dirs() -> Option<ProjectDirs> {
ProjectDirs::from("com", "virtual-boy", "Lemur") ProjectDirs::from("com", "virtual-boy", "Lemur")
} }
pub trait ConfigCompatible: Clone + Default + Eq + Serialize + for<'a> Deserialize<'a> {}
impl<T: Clone + Default + Eq + Serialize + for<'a> 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<T> {
value: T,
version: u64,
latest_version: Arc<AtomicU64>,
path: Option<PathBuf>,
}
impl<T> Deref for Config<T> {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.value
}
}
impl<T: ConfigCompatible> Config<T> {
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;
}
}
+2 -3
View File
@@ -94,9 +94,8 @@ fn main() -> Result<()> {
let persistence = Persistence::new(); let persistence = Persistence::new();
let mut config = AppConfig::load(&persistence); let mut config = AppConfig::load(&persistence);
if config.update(&args) { config.update(|c| c.apply_updates(&args));
let _ = config.save(&persistence);
}
let p1 = SimConfig::load(&persistence, SimId::Player1); let p1 = SimConfig::load(&persistence, SimId::Player1);
let p2 = SimConfig::load(&persistence, SimId::Player2); let p2 = SimConfig::load(&persistence, SimId::Player2);
+11 -20
View File
@@ -7,7 +7,7 @@ use std::{
use crate::{ use crate::{
app::UserEvent, app::UserEvent,
config::{AppConfig, COLOR_PRESETS, SaveDataLocation, SimConfig}, config::{AppConfig, COLOR_PRESETS, Config, SaveDataLocation, SimConfig},
emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState}, emulator::{EmulatorClient, EmulatorCommand, EmulatorState, SimId, SimState},
filepicker::FilePicker, filepicker::FilePicker,
filesystem::Persistence, filesystem::Persistence,
@@ -42,7 +42,7 @@ pub struct GameWindow {
persistence: Persistence, persistence: Persistence,
shortcuts: ShortcutProvider, shortcuts: ShortcutProvider,
sim_id: SimId, sim_id: SimId,
config: SimConfig, config: Config<SimConfig>,
toasts: Toasts, toasts: Toasts,
screen: Option<GameScreen>, screen: Option<GameScreen>,
messages: mpsc::Receiver<Toast>, messages: mpsc::Receiver<Toast>,
@@ -334,7 +334,7 @@ impl GameWindow {
} }
let watch_rom = self.client.is_rom_watched(self.sim_id); let watch_rom = self.client.is_rom_watched(self.sim_id);
if ui.selectable_button(watch_rom, "Watch ROM").clicked() { 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 self.client
.send_command(EmulatorCommand::WatchRom(self.sim_id, !watch_rom)); .send_command(EmulatorCommand::WatchRom(self.sim_id, !watch_rom));
} }
@@ -395,7 +395,7 @@ impl GameWindow {
ui.separator(); ui.separator();
let low_battery = self.config.low_battery; let low_battery = self.config.low_battery;
if ui.selectable_button(low_battery, "Low Battery").clicked() { 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 let _ = self
.proxy .proxy
.send_event(UserEvent::LowBattery(self.sim_id, !low_battery)); .send_event(UserEvent::LowBattery(self.sim_id, !low_battery));
@@ -551,7 +551,7 @@ impl GameWindow {
ui.send_viewport_cmd(ViewportCommand::InnerSize(current_dims * scale)); ui.send_viewport_cmd(ViewportCommand::InnerSize(current_dims * scale));
} }
self.update_config(|c| { self.config.update(|c| {
c.display_mode = display_mode; c.display_mode = display_mode;
c.dimensions = current_dims * scale; c.dimensions = current_dims * scale;
}); });
@@ -559,7 +559,7 @@ impl GameWindow {
ui.menu_button("Colors", |ui| { ui.menu_button("Colors", |ui| {
for preset in COLOR_PRESETS { for preset in COLOR_PRESETS {
if ui.color_pair_button(preset[0], preset[1]).clicked() { 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| { 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") .selectable_button(self.config.audio_enabled, "Enabled")
.clicked() .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.client.send_command(EmulatorCommand::SetAudioEnabled(
self.sim_id, self.sim_id,
self.config.audio_enabled, self.config.audio_enabled,
@@ -611,8 +611,7 @@ impl GameWindow {
return; return;
}; };
let mut app_config = AppConfig::load(&self.persistence); let mut app_config = AppConfig::load(&self.persistence);
app_config.save_data_location = new_location; app_config.update(|c| c.save_data_location = new_location);
let _ = app_config.save(&self.persistence);
self.client self.client
.send_command(EmulatorCommand::SetSaveDataLocation(new_location)); .send_command(EmulatorCommand::SetSaveDataLocation(new_location));
}; };
@@ -662,19 +661,10 @@ impl GameWindow {
self.color_picker = None; self.color_picker = None;
} }
if updated { 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<'_> { fn button_for(&self, ctx: &Context, text: &str, command: Command) -> Button<'_> {
let button = Button::new(text); let button = Button::new(text);
match self.shortcuts.shortcut_for(command) { match self.shortcuts.shortcut_for(command) {
@@ -701,6 +691,7 @@ impl AppWindow for GameWindow {
} }
fn show(&mut self, ui: &mut Ui) { fn show(&mut self, ui: &mut Ui) {
self.config.refresh();
let has_player_2 = self.client.has_player_2(); let has_player_2 = self.client.has_player_2();
if !self.show_player_name && has_player_2 { if !self.show_player_name && has_player_2 {
ui.send_viewport_cmd(ViewportCommand::Title(format!("Lemur ({})", self.sim_id))); ui.send_viewport_cmd(ViewportCommand::Title(format!("Lemur ({})", self.sim_id)));
@@ -731,7 +722,7 @@ impl AppWindow for GameWindow {
bounds.max - bounds.min bounds.max - bounds.min
}; };
let position = ui.input(|i| i.viewport().outer_rect.map(|r| r.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.dimensions = dimensions;
c.position = position; c.position = position;
}); });