From c7500504bc3d6b1eccf0630c59d71f6a148b4964 Mon Sep 17 00:00:00 2001 From: Talin Date: Tue, 17 Feb 2026 21:07:04 -0800 Subject: [PATCH 01/23] New `bevy_preferences` crate --- Cargo.toml | 15 + crates/bevy_internal/Cargo.toml | 1 + crates/bevy_internal/src/lib.rs | 2 + crates/bevy_preferences/Cargo.toml | 38 +++ crates/bevy_preferences/src/lib.rs | 323 ++++++++++++++++++++++ crates/bevy_preferences/src/store_fs.rs | 140 ++++++++++ crates/bevy_preferences/src/store_wasm.rs | 95 +++++++ examples/app/prefs_counter.rs | 136 +++++++++ 8 files changed, 750 insertions(+) create mode 100644 crates/bevy_preferences/Cargo.toml create mode 100644 crates/bevy_preferences/src/lib.rs create mode 100644 crates/bevy_preferences/src/store_fs.rs create mode 100644 crates/bevy_preferences/src/store_wasm.rs create mode 100644 examples/app/prefs_counter.rs diff --git a/Cargo.toml b/Cargo.toml index 27470ce17b2a8..c71ffed3a2013 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -410,6 +410,9 @@ bevy_input_focus = ["bevy_internal/bevy_input_focus"] # Headless widget collection for Bevy UI. bevy_ui_widgets = ["bevy_internal/bevy_ui_widgets"] +# Load and save user preferences +bevy_preferences = ["bevy_internal/bevy_preferences"] + # Feathers widget collection. experimental_bevy_feathers = ["bevy_internal/bevy_feathers", "bevy_ui_widgets"] @@ -5147,6 +5150,18 @@ description = "Demonstrates use of core scrollbar in Bevy UI" category = "UI (User Interface)" wasm = true +[[example]] +name = "prefs_counter" +path = "examples/app/prefs_counter.rs" +doc-scrape-examples = true +required-features = ["bevy_preferences"] + +[package.metadata.example.prefs] +name = "User Preferences" +description = "Demonstrates persistence of user preferences" +category = "Application" +wasm = true + [[example]] name = "system_fonts" path = "examples/ui/text/system_fonts.rs" diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 3074556138c0d..bc794de46cb36 100644 --- a/crates/bevy_internal/Cargo.toml +++ b/crates/bevy_internal/Cargo.toml @@ -528,6 +528,7 @@ bevy_input_focus = { path = "../bevy_input_focus", optional = true, version = "0 ] } bevy_pbr = { path = "../bevy_pbr", optional = true, version = "0.19.0-dev" } bevy_picking = { path = "../bevy_picking", optional = true, version = "0.19.0-dev" } +bevy_preferences = { path = "../bevy_preferences", optional = true, version = "0.19.0-dev" } bevy_remote = { path = "../bevy_remote", optional = true, version = "0.19.0-dev" } bevy_render = { path = "../bevy_render", optional = true, version = "0.19.0-dev" } bevy_scene = { path = "../bevy_scene", optional = true, version = "0.19.0-dev" } diff --git a/crates/bevy_internal/src/lib.rs b/crates/bevy_internal/src/lib.rs index 794cf3afd95a1..ba0658c00fefe 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -70,6 +70,8 @@ pub use bevy_picking as picking; pub use bevy_platform as platform; #[cfg(feature = "bevy_post_process")] pub use bevy_post_process as post_process; +#[cfg(feature = "bevy_preferences")] +pub use bevy_preferences as preferences; pub use bevy_ptr as ptr; pub use bevy_reflect as reflect; #[cfg(feature = "bevy_remote")] diff --git a/crates/bevy_preferences/Cargo.toml b/crates/bevy_preferences/Cargo.toml new file mode 100644 index 0000000000000..a354cade0e6b2 --- /dev/null +++ b/crates/bevy_preferences/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "bevy_preferences" +version = "0.19.0-dev" +edition = "2024" +description = "User preferences framework for Bevy Engine" +homepage = "https://bevy.org" +repository = "https://github.com/bevyengine/bevy" +license = "MIT OR Apache-2.0" +keywords = ["bevy"] + +[dependencies] +# bevy +bevy_app = { path = "../bevy_app", version = "0.19.0-dev" } +bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } +bevy_log = { path = "../bevy_log", version = "0.19.0-dev" } +bevy_platform = { path = "../bevy_platform", version = "0.19.0-dev" } +bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } +bevy_tasks = { path = "../bevy_tasks", version = "0.19.0-dev" } + +serde = "1.0.217" +thiserror = "2.0.18" +toml = { version = "0.8.19" } + +[target.'cfg(target_arch = "wasm32")'.dependencies] +web-sys = { version = "0.3", default-features = false, features = [ + "Window", + "Storage", +] } + +[features] +default = [] + +[lints] +workspace = true + +[package.metadata.docs.rs] +rustdoc-args = ["-Zunstable-options", "--generate-link-to-definition"] +all-features = true diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs new file mode 100644 index 0000000000000..edfdf0dbbb835 --- /dev/null +++ b/crates/bevy_preferences/src/lib.rs @@ -0,0 +1,323 @@ +//! Framework for saving and loading user preferences in Bevy applications. +use core::any::TypeId; +use std::collections::HashMap; + +use bevy_app::{App, Plugin}; +use bevy_ecs::{ + change_detection::Tick, + reflect::{AppTypeRegistry, ReflectComponent, ReflectResource}, + resource::Resource, + system::Command, + world::World, +}; +use bevy_log::warn; +use bevy_reflect::{ + prelude::ReflectDefault, serde::TypedReflectDeserializer, Reflect, ReflectDeserialize, + ReflectSerialize, TypeInfo, +}; + +#[cfg(not(target_arch = "wasm32"))] +mod store_fs; + +#[cfg(target_arch = "wasm32")] +mod store_wasm; + +use serde::de::DeserializeSeed; +#[cfg(not(target_arch = "wasm32"))] +use store_fs::PreferencesStore; + +#[cfg(target_arch = "wasm32")] +use store_wasm::PreferencesStore; + +/// Plugin to orchestrate loading and saving of preferences. +pub struct PreferencesPlugin { + /// The name of the application. This is used to uniquely identify the preferences directory + /// so as not to confuse it with other applications' preferences. To ensure global uniqueness, + /// it is recommended to use a reverse domain name, e.g. "com.example.myapp". + pub app_name: String, +} + +impl PreferencesPlugin { + /// Construct a new `PreferencesPlugin` for the givn application name. To ensure global + /// uniqueness and avoid overwriting settings for other apps, it is recommended to use a + /// reverse domain name, e.g. "com.example.myapp". + pub fn new(app_name: &str) -> Self { + Self { + app_name: app_name.to_string(), + } + } +} + +impl Plugin for PreferencesPlugin { + fn build(&self, _app: &mut App) {} +} + +/// Annotation for a type which overrides which preferences file the type's contents will be +/// written to. By default, all preferences are written to a file named "settings". +#[derive(Debug, Clone, Reflect)] +pub struct PreferencesFile(pub &'static str); + +/// Annotation for a type which causes the type's contents to be placed in a named section +/// in the preferences file. +#[derive(Debug, Clone, Reflect)] +pub struct PreferencesGroup(pub &'static str); + +/// List of resource types that will be associated with a specific preferences file. +/// Also tracks when that file was last written or read. +#[derive(Default)] +struct PreferenceFileManifest { + last_save: Tick, + resource_types: Vec, +} + +/// Records the game tick when preferences were last loaded or saved. This is used to determine +/// which preferences files have changed and need to be saved. Also tracks which settings files +/// are associated with which resource types. +#[derive(Resource)] +struct PreferencesFileRegistry { + app_name: String, + files: HashMap<&'static str, PreferenceFileManifest>, +} + +/// A Command which saves preferences to disk. This blocks the command queue until saving +/// is complete. +#[derive(Default, PartialEq)] +pub enum SavePreferencesSync { + /// Save preferences only if they have changed (based on [`PreferencesChanged` resource]). + #[default] + IfChanged, + /// Save preferences unconditionally. + Always, +} + +impl Command for SavePreferencesSync { + fn apply(self, world: &mut World) { + // TODO: if self is `IfChanged` then only save if file.last_save is >= the change time + // of all resources. + let Some(registry) = world.get_resource::() else { + warn!("Preferences registry not found - did you forget to call load_preferences()?"); + return; + }; + let Some(app_types) = world.get_resource::() else { + return; + }; + let app_types = app_types.clone(); + let types = app_types.read(); + + for (filename, manifest) in registry.files.iter() { + // TODO: See if changed + let table = resources_to_toml(world, &types, manifest); + let store = PreferencesStore::new(®istry.app_name); + store.save(filename, table); + } + } +} + +fn resources_to_toml( + world: &World, + types: &bevy_reflect::TypeRegistry, + manifest: &PreferenceFileManifest, +) -> toml::map::Map { + let mut table = toml::Table::new(); + for tid in manifest.resource_types.iter() { + let ty = types.get(*tid).unwrap(); + let type_info = ty.type_info(); + let Some(cmp) = ty.data::() else { + continue; + }; + let Some(ser) = ty.data::() else { + continue; + }; + + if let TypeInfo::Struct(stinfo) = type_info + && let Some(group) = stinfo + .custom_attributes() + .get::() + .map(|g| g.0) + { + let Some(component_id) = world.components().get_id(*tid) else { + continue; + }; + + // let Some(resource_tick) = world.get_resource_change_ticks_by_id(component_id) else { + // continue; + // }; + + let Some(res_entity) = world.resource_entities().get(component_id) else { + continue; + }; + let res_entity_ref = world.entity(*res_entity); + + let Some(reflect) = cmp.reflect(res_entity_ref) else { + continue; + }; + let ser_value = ser.get_serializable(reflect); + + let toml_value = toml::Value::try_from(&*ser_value).unwrap(); + table.insert(group.to_string(), toml_value); + } + } + table +} + +/// A Command which saves preferences to disk. Actual FS operations happen in another thread. +#[derive(Default, PartialEq)] +pub enum SavePreferences { + /// Save preferences only if they have changed (based on [`PreferencesChanged` resource]). + #[default] + IfChanged, + /// Save preferences unconditionally. + Always, +} + +impl Command for SavePreferences { + fn apply(self, _world: &mut World) { + // let prefs = world.get_resource::().unwrap(); + // prefs.save_async(self == SavePreferences::Always); + } +} + +/// Extension trait that implements loading of preferences into the application. +/// +/// This needs to be called before `app.build()` so that preference values will be available +/// when the app is starting up. +pub trait LoadPreferences { + /// Reads the preferences file and inserts or updates resources that are marked as preferences. + fn load_preferences(&mut self) -> &mut Self; +} + +impl LoadPreferences for App { + fn load_preferences(&mut self) -> &mut Self { + // Find the plugin so we can get the app name. + let plugins = self.get_added_plugins::(); + let Some(plugin) = plugins.first() else { + warn!("Preference cannot be loaded; plugin not found."); + return self; + }; + let app_name = plugin.app_name.clone(); + let world = self.world(); + let last_save = world.read_change_tick(); + + // Get the type registry and clone the Arc so we don't have to worry about borrowing. + let Some(app_types) = world.get_resource::() else { + return self; + }; + let app_types = app_types.clone(); + let types = app_types.read(); + + // Build an index that remembers all of the resource types that are to be saved to + // each individual settings file. + let mut file_index = PreferencesFileRegistry { + app_name: plugin.app_name.clone(), + files: HashMap::new(), + }; + + // Scan through types looking for resources that have the neccessary traits and + // annotations. + for ty in types.iter() { + if !(ty.contains::() + && ty.contains::() + && ty.contains::() + && ty.contains::()) + { + continue; + }; + + let type_info = ty.type_info(); + if let TypeInfo::Struct(stinfo) = type_info + && let Some(_group) = stinfo + .custom_attributes() + .get::() + .map(|g| g.0) + { + // If no filename is specified, use "settings" + let filename = stinfo + .custom_attributes() + .get::() + .map_or("settings", |f| f.0); + + let pending_file = + file_index + .files + .entry(filename) + .or_insert(PreferenceFileManifest { + last_save, + resource_types: Vec::new(), + }); + pending_file.last_save = last_save; + pending_file.resource_types.push(ty.type_id()); + } + } + + // Now load each of the toml files we discovered, and apply their properties to + // the resources in the world. + let world = self.world_mut(); + let types = app_types.read(); + for (filename, manifest) in file_index.files.iter() { + // Load the TOML file + let store = PreferencesStore::new(&app_name); + let toml = store.load(filename); + if toml.is_none() { + warn!("Filename {filename}.toml not found"); + } + + for tid in manifest.resource_types.iter() { + let ty = types.get(*tid).unwrap(); + let type_info = ty.type_info(); + + if let TypeInfo::Struct(stinfo) = type_info + && let Some(group) = stinfo + .custom_attributes() + .get::() + .map(|g| g.0) + { + let reflect_component = ty.data::().unwrap(); + let component_id = world.components().get_id(*tid); + let res_entity = + component_id.and_then(|cid| world.resource_entities().get(cid)); + + let deserializer = TypedReflectDeserializer::new(ty, &types); + if let Some(res_entity) = res_entity { + // Resource already exists, so apply toml properties to it. + let res_entity_mut = world.entity_mut(*res_entity); + let Some(mut reflect) = reflect_component.reflect_mut(res_entity_mut) + else { + continue; + }; + + if let Some(ref toml) = toml + && let Some(value) = toml.get(group) + { + let new_value = deserializer.deserialize(value.clone()).unwrap(); + reflect.apply(new_value.as_ref()); + } + } else { + // The resource does not exist, so create a default. + let reflect_default = ty.data::().unwrap(); + let mut default_value = reflect_default.default(); + let types = app_types.read(); + let mut res_entity = world.spawn_empty(); + + if let Some(ref toml) = toml + && let Some(value) = toml.get(group) + { + let new_value = deserializer.deserialize(value.clone()).unwrap(); + default_value.apply(new_value.as_ref()); + } + + reflect_component.insert( + &mut res_entity, + default_value.as_partial_reflect(), + &types, + ); + } + } + } + } + + drop(types); + world.insert_resource::(file_index); + + self + } +} diff --git a/crates/bevy_preferences/src/store_fs.rs b/crates/bevy_preferences/src/store_fs.rs new file mode 100644 index 0000000000000..1bbe11d2e1866 --- /dev/null +++ b/crates/bevy_preferences/src/store_fs.rs @@ -0,0 +1,140 @@ +use bevy_log::{debug, error, warn}; +use bevy_platform::dirs::preferences_dir; +use bevy_tasks::IoTaskPool; +use std::{fs, path::PathBuf}; + +/// Persistent storage which uses the local filesystem. Preferences will be located in the +/// OS-specific directory for user preferences. +pub(crate) struct PreferencesStore { + base_path: Option, +} + +impl PreferencesStore { + /// Construct a new filesystem preferences store. + /// + /// # Arguments + /// * `app_name` - The name of the application. This is used to uniquely identify the + /// preferences directory so as not to confuse it with other applications' preferences. + /// To ensure global uniqueness, it is recommended to use a reverse domain name, e.g. + /// "com.example.myapp". + pub(crate) fn new(app_name: &str) -> Self { + Self { + base_path: if let Some(base_dir) = preferences_dir() { + let prefs_path = base_dir.join(app_name); + debug!("Preferences path: {:?}", prefs_path); + Some(prefs_path) + } else { + warn!("Could not find user configuration directories"); + None + }, + } + } + + /// Save a [`PreferencesFile`] to disk. + /// + /// # Arguments + /// * `filename` - the name of the file to be saved + /// * `contents` - the contents of the file + pub(crate) fn save(&self, filename: &str, contents: toml::Table) { + if let Some(base_path) = &self.base_path { + // Recursively create the preferences directory if it doesn't exist. + let mut dir_builder = fs::DirBuilder::new(); + dir_builder.recursive(true); + if let Err(e) = dir_builder.create(base_path.clone()) { + warn!("Could not create preferences directory: {:?}", e); + return; + } + + // Save preferences to temp file + let temp_path = base_path.join(format!("{filename}.toml.new")); + if let Err(e) = fs::write(&temp_path, contents.to_string()) { + error!("Error saving preferences file: {}", e); + } + + // Replace old prefs file with new one. + let file_path = base_path.join(format!("{filename}.toml")); + if let Err(e) = fs::rename(&temp_path, file_path) { + warn!("Could not save preferences file: {:?}", e); + } + } + } + + /// Save the contents of a [`PreferencesFile`] to disk in another thread. + /// + /// # Arguments + /// * `filename` - the name of the file to be saved + /// * `contents` - the contents of the file + pub(crate) fn save_async(&self, filename: &str, contents: toml::Table) { + if let Some(base_path) = &self.base_path { + IoTaskPool::get().scope(|scope| { + scope.spawn(async { + // Recursively create the preferences directory if it doesn't exist. + let mut dir_builder = fs::DirBuilder::new(); + dir_builder.recursive(true); + if let Err(e) = dir_builder.create(base_path.clone()) { + warn!("Could not create preferences directory: {:?}", e); + return; + } + + // Save preferences to temp file + let temp_path = base_path.join(format!("{filename}.toml.new")); + if let Err(e) = fs::write(&temp_path, contents.to_string()) { + error!("Error saving preferences file: {}", e); + } + + // Replace old prefs file with new one. + let file_path = base_path.join(format!("{filename}.toml")); + if let Err(e) = fs::rename(&temp_path, file_path) { + warn!("Could not save preferences file: {:?}", e); + } + }); + }); + } + } + + /// Deserialize a preferences file from disk. If the file does not exist, `None` will + /// be returned. + /// + /// # Arguments + /// * `filename` - The name of the preferences file, without the file extension. + pub(crate) fn load(&self, filename: &str) -> Option { + let Some(base_path) = &self.base_path else { + return None; + }; + + let file_path = base_path.join(format!("{filename}.toml")); + decode_toml_file(&file_path) + } +} + +/// Load a preferences file from disk in TOML format. +pub(crate) fn decode_toml_file(file: &PathBuf) -> Option { + if file.exists() && file.is_file() { + let prefs_str = match fs::read_to_string(file) { + Ok(prefs_str) => prefs_str, + Err(e) => { + error!("Error reading preferences file: {}", e); + return None; + } + }; + + let table_value = match toml::from_str::(&prefs_str) { + Ok(table_value) => table_value, + Err(e) => { + error!("Error parsing preferences file: {}", e); + return None; + } + }; + + match table_value { + toml::Value::Table(table) => Some(table), + _ => { + error!("Preferences file must be a table"); + None + } + } + } else { + // Preferences file does not exist yet. + None + } +} diff --git a/crates/bevy_preferences/src/store_wasm.rs b/crates/bevy_preferences/src/store_wasm.rs new file mode 100644 index 0000000000000..a430e2901ff6a --- /dev/null +++ b/crates/bevy_preferences/src/store_wasm.rs @@ -0,0 +1,95 @@ +use crate::prefs_file::serialize_table; +use crate::{prefs::PreferencesStore, PreferencesFile, PreferencesFileContent}; +use bevy_log::error; +use bevy_tasks::IoTaskPool; +use web_sys::window; + +/// Persistent storage which uses browser local storage. +pub(crate) struct PreferencesStore { + app_name: String, +} + +impl PreferencesStore { + /// Construct a new `StoreWasm` instance. + /// + /// # Arguments + /// * `app_name` - The name of the application. This is used to uniquely identify the + /// preferences directory so as not to confuse it with other applications' preferences. + /// To ensure global uniqueness, it is recommended to use a reverse domain name, e.g. + /// "com.example.myapp". + pub fn new(app_name: &str) -> Self { + Self { + app_name: app_name.to_owned(), + } + } + + /// Returns the storage key for a given filename. This consists of the app name combined + /// with the filename. + fn storage_key(&self, filename: &str) -> String { + format!("{}-{}", self.app_name, filename) + } + + /// Save a [`PreferencesFile`] to browser storage, synchronously. + /// + /// # Arguments + /// * `filename` - the name of the file to be saved + /// * `contents` - the contents of the file + pub(crate) fn save(&self, filename: &str, contents: &PreferencesFile) { + if let Ok(Some(storage)) = window().unwrap().local_storage() { + let toml_str = serialize_table(&contents.table); + storage + .set_item(&self.storage_key(filename).as_str(), &toml_str) + .unwrap(); + } + } + + /// Save the content of a [`PreferencesFile`] to disk, in another thread. + /// + /// # Arguments + /// * `filename` - the name of the file to be saved + /// * `contents` - the contents of the file + pub(crate) fn save_async(&self, filename: &str, contents: PreferencesFileContent) { + IoTaskPool::get().scope(|scope| { + scope.spawn(async { + if let Ok(Some(storage)) = window().unwrap().local_storage() { + let toml_str = serialize_table(&contents.0); + storage + .set_item(&self.storage_key(filename).as_str(), &toml_str) + .unwrap(); + } + }); + }); + } + + /// Deserialize a preferences file. If the file does not exist, `None` will + /// be returned. + /// + /// # Arguments + /// * `filename` - The name of the preferences file, without the file extension. + pub(crate) fn load(&mut self, filename: &str) -> Option { + if let Ok(Some(storage)) = window().unwrap().local_storage() { + let storage_key = self.storage_key(filename); + let Ok(Some(toml_str)) = storage.get_item(&storage_key) else { + return None; + }; + + let table_value = match toml::from_str::(&toml_str) { + Ok(table_value) => table_value, + Err(e) => { + error!("Error parsing preferences file: {}", e); + return None; + } + }; + + match table_value { + toml::Value::Table(table) => Some(PreferencesFile::from_table(table)), + _ => { + error!("Preferences file must be a table"); + None + } + } + } else { + None + } + } +} diff --git a/examples/app/prefs_counter.rs b/examples/app/prefs_counter.rs new file mode 100644 index 0000000000000..775cf03983099 --- /dev/null +++ b/examples/app/prefs_counter.rs @@ -0,0 +1,136 @@ +//! Demonstrates persistence of user preferences. +use bevy::{ + // user_prefs::{Preferences, StartAutosaveTimer}, + preferences::{LoadPreferences as _, PreferencesGroup, PreferencesPlugin, SavePreferencesSync}, + prelude::*, + window::{ExitCondition, WindowCloseRequested}, +}; +use serde::{Deserialize, Serialize}; +// use bevy_state::reflect; + +fn main() { + // Configure preferences store + // let mut preferences = Preferences::new("org.bevy.example.prefs"); + // let count: i32 = preferences + // .get("prefs") + // .map(|file| { + // file.get_group("counter") + // .map(|group| group.get::("count").unwrap_or(0)) + // .unwrap_or(0) + // }) + // .unwrap_or(0); + + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + // We want to intercept the exit so that we can save prefs. + exit_condition: ExitCondition::DontExit, + primary_window: Some(Window { + title: "Prefs Counter".into(), + ..default() + }), + ..default() + })) + .add_plugins(PreferencesPlugin::new("org.bevy.examples.prefs_counter")) + // .add_plugins(AutosavePrefsPlugin) + // .insert_resource(preferences) + // .insert_resource(Counter { count: 0 }) + .add_systems(Startup, setup) + .add_systems(Update, (show_count, change_count, on_window_close)) + .load_preferences() + .run(); +} + +#[derive(Resource, Reflect, Default, Serialize, Deserialize)] +#[reflect(Resource, Serialize, Deserialize, Default, @PreferencesGroup("counter"))] +struct Counter { + count: i32, +} + +#[derive(Component)] +struct CounterDisplay; + +fn setup(mut commands: Commands) { + commands.spawn((Camera::default(), Camera2d)); + commands + .spawn(Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }) + .with_children(|parent| { + parent.spawn(( + Text::new("---"), + TextFont { + font_size: FontSize::Px(33.0), + ..default() + }, + CounterDisplay, + TextColor(Color::srgb(0.9, 0.9, 0.9)), + )); + parent.spawn(( + Text::new("Press SPACE to increment"), + TextFont { + font_size: FontSize::Px(20.0), + ..default() + }, + )); + }); +} + +fn show_count(mut query: Query<&mut Text, With>, counter: Res) { + if counter.is_changed() { + for mut text in query.iter_mut() { + text.0 = format!("Count: {}", counter.count); + } + } +} + +fn change_count( + mut counter: ResMut, + keyboard: Res>, + // mut prefs: ResMut, + mut commands: Commands, +) { + let mut changed = false; + if keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::Period) { + counter.count += 1; + changed = true; + } + if keyboard.just_pressed(KeyCode::Backspace) + || keyboard.just_pressed(KeyCode::Delete) + || keyboard.just_pressed(KeyCode::Comma) + { + counter.count -= 1; + changed = true; + } + + if changed { + commands.queue(SavePreferencesSync::Always); + } + + // if changed && let Some(app_prefs) = prefs.get_mut("prefs") { + // // let mut counter_prefs = app_prefs.get_group_mut("counter").unwrap(); + // // counter_prefs.set("count", counter.count); + // // commands.queue(StartAutosaveTimer); + // } +} + +fn on_window_close(mut close: MessageReader, mut commands: Commands) { + // Save preferences immediately, then quit. + if let Some(_close_event) = close.read().next() { + commands.queue(SavePreferencesSync::IfChanged); + commands.queue(ExitAfterSave); + } +} + +struct ExitAfterSave; + +impl Command for ExitAfterSave { + fn apply(self, world: &mut World) { + world.write_message(AppExit::Success); + } +} From d3fa50172da5dda2f179f79e9ac3dd50544d249a Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 18 Feb 2026 09:27:06 -0800 Subject: [PATCH 02/23] CI --- Cargo.toml | 2 +- crates/bevy_preferences/src/lib.rs | 55 ++++++++++++++++++------------ docs/cargo_features.md | 1 + examples/README.md | 1 + examples/app/prefs_counter.rs | 23 +------------ 5 files changed, 38 insertions(+), 44 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index c71ffed3a2013..a95d438bf0e12 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5156,7 +5156,7 @@ path = "examples/app/prefs_counter.rs" doc-scrape-examples = true required-features = ["bevy_preferences"] -[package.metadata.example.prefs] +[package.metadata.example.prefs_counter] name = "User Preferences" description = "Demonstrates persistence of user preferences" category = "Application" diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index edfdf0dbbb835..e22ba669b9fd5 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -92,24 +92,7 @@ pub enum SavePreferencesSync { impl Command for SavePreferencesSync { fn apply(self, world: &mut World) { - // TODO: if self is `IfChanged` then only save if file.last_save is >= the change time - // of all resources. - let Some(registry) = world.get_resource::() else { - warn!("Preferences registry not found - did you forget to call load_preferences()?"); - return; - }; - let Some(app_types) = world.get_resource::() else { - return; - }; - let app_types = app_types.clone(); - let types = app_types.read(); - - for (filename, manifest) in registry.files.iter() { - // TODO: See if changed - let table = resources_to_toml(world, &types, manifest); - let store = PreferencesStore::new(®istry.app_name); - store.save(filename, table); - } + save_preferences(world, false, self == SavePreferencesSync::Always); } } @@ -171,9 +154,39 @@ pub enum SavePreferences { } impl Command for SavePreferences { - fn apply(self, _world: &mut World) { - // let prefs = world.get_resource::().unwrap(); - // prefs.save_async(self == SavePreferences::Always); + fn apply(self, world: &mut World) { + save_preferences(world, true, self == SavePreferences::Always); + } +} + +fn save_preferences(world: &mut World, use_async: bool, _force: bool) { + let this_run = world.change_tick(); + let Some(registry) = world.get_resource::() else { + warn!("Preferences registry not found - did you forget to call load_preferences()?"); + return; + }; + let Some(app_types) = world.get_resource::() else { + return; + }; + let app_types = app_types.clone(); + let types = app_types.read(); + + for (filename, manifest) in registry.files.iter() { + // TODO: See if changed unless _force is true + // only save if file.last_save is >= the change time of all resources. + let table = resources_to_toml(world, &types, manifest); + let store = PreferencesStore::new(®istry.app_name); + if use_async { + store.save_async(filename, table); + } else { + store.save(filename, table); + } + } + + // Update timestamps + let mut registry = world.get_resource_mut::().unwrap(); + for (_, manifest) in registry.files.iter_mut() { + manifest.last_save = this_run; } } diff --git a/docs/cargo_features.md b/docs/cargo_features.md index 429988906d5d6..a58cbdcbac5e4 100644 --- a/docs/cargo_features.md +++ b/docs/cargo_features.md @@ -90,6 +90,7 @@ This is the complete `bevy` cargo feature list, without "profiles" or "collectio |bevy_pbr|Adds PBR rendering| |bevy_picking|Provides picking functionality without any backend| |bevy_post_process|Provides post process effects such as depth of field, bloom, chromatic aberration.| +|bevy_preferences|Load and save user preferences| |bevy_remote|Enable the Bevy Remote Protocol| |bevy_render|Provides rendering functionality| |bevy_scene|Provides scene functionality| diff --git a/examples/README.md b/examples/README.md index 782adf8919f05..44f5fcb26cf8a 100644 --- a/examples/README.md +++ b/examples/README.md @@ -245,6 +245,7 @@ Example | Description [Render Recovery](../examples/app/render_recovery.rs) | Demonstrates how bevy can recover from rendering failures. [Return after Run](../examples/app/return_after_run.rs) | Show how to return to main after the Bevy app has exited [Thread Pool Resources](../examples/app/thread_pool_resources.rs) | Creates and customizes the internal thread pool +[User Preferences](../examples/app/prefs_counter.rs) | Demonstrates persistence of user preferences [Without Winit](../examples/app/without_winit.rs) | Create an application without winit (runs single time, no event loop) ### Assets diff --git a/examples/app/prefs_counter.rs b/examples/app/prefs_counter.rs index 775cf03983099..76bd8fb662bfb 100644 --- a/examples/app/prefs_counter.rs +++ b/examples/app/prefs_counter.rs @@ -6,20 +6,8 @@ use bevy::{ window::{ExitCondition, WindowCloseRequested}, }; use serde::{Deserialize, Serialize}; -// use bevy_state::reflect; fn main() { - // Configure preferences store - // let mut preferences = Preferences::new("org.bevy.example.prefs"); - // let count: i32 = preferences - // .get("prefs") - // .map(|file| { - // file.get_group("counter") - // .map(|group| group.get::("count").unwrap_or(0)) - // .unwrap_or(0) - // }) - // .unwrap_or(0); - App::new() .add_plugins(DefaultPlugins.set(WindowPlugin { // We want to intercept the exit so that we can save prefs. @@ -32,8 +20,6 @@ fn main() { })) .add_plugins(PreferencesPlugin::new("org.bevy.examples.prefs_counter")) // .add_plugins(AutosavePrefsPlugin) - // .insert_resource(preferences) - // .insert_resource(Counter { count: 0 }) .add_systems(Startup, setup) .add_systems(Update, (show_count, change_count, on_window_close)) .load_preferences() @@ -92,7 +78,6 @@ fn show_count(mut query: Query<&mut Text, With>, counter: Res, keyboard: Res>, - // mut prefs: ResMut, mut commands: Commands, ) { let mut changed = false; @@ -109,14 +94,8 @@ fn change_count( } if changed { - commands.queue(SavePreferencesSync::Always); + commands.queue(SavePreferencesSync::IfChanged); } - - // if changed && let Some(app_prefs) = prefs.get_mut("prefs") { - // // let mut counter_prefs = app_prefs.get_group_mut("counter").unwrap(); - // // counter_prefs.set("count", counter.count); - // // commands.queue(StartAutosaveTimer); - // } } fn on_window_close(mut close: MessageReader, mut commands: Commands) { From 02b7b12e3b5079d2325e0cad5abfbf31eeaa01c5 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 18 Feb 2026 10:17:14 -0800 Subject: [PATCH 03/23] Doc fixes. --- crates/bevy_preferences/src/lib.rs | 2 +- crates/bevy_preferences/src/store_fs.rs | 6 +++--- crates/bevy_preferences/src/store_wasm.rs | 8 ++++---- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index e22ba669b9fd5..3f9a85666b7ed 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -225,7 +225,7 @@ impl LoadPreferences for App { files: HashMap::new(), }; - // Scan through types looking for resources that have the neccessary traits and + // Scan through types looking for resources that have the necessary traits and // annotations. for ty in types.iter() { if !(ty.contains::() diff --git a/crates/bevy_preferences/src/store_fs.rs b/crates/bevy_preferences/src/store_fs.rs index 1bbe11d2e1866..a182bfbfe3abc 100644 --- a/crates/bevy_preferences/src/store_fs.rs +++ b/crates/bevy_preferences/src/store_fs.rs @@ -30,7 +30,7 @@ impl PreferencesStore { } } - /// Save a [`PreferencesFile`] to disk. + /// Save a [`toml::Table`] to disk. /// /// # Arguments /// * `filename` - the name of the file to be saved @@ -59,7 +59,7 @@ impl PreferencesStore { } } - /// Save the contents of a [`PreferencesFile`] to disk in another thread. + /// Save the contents of a [`toml::Table`] to disk in another thread. /// /// # Arguments /// * `filename` - the name of the file to be saved @@ -92,7 +92,7 @@ impl PreferencesStore { } } - /// Deserialize a preferences file from disk. If the file does not exist, `None` will + /// Deserialize a [`toml::Table`] from disk. If the file does not exist, `None` will /// be returned. /// /// # Arguments diff --git a/crates/bevy_preferences/src/store_wasm.rs b/crates/bevy_preferences/src/store_wasm.rs index a430e2901ff6a..eaa935ffdc4c3 100644 --- a/crates/bevy_preferences/src/store_wasm.rs +++ b/crates/bevy_preferences/src/store_wasm.rs @@ -10,7 +10,7 @@ pub(crate) struct PreferencesStore { } impl PreferencesStore { - /// Construct a new `StoreWasm` instance. + /// Construct a new preferecnes store for browser local storage. /// /// # Arguments /// * `app_name` - The name of the application. This is used to uniquely identify the @@ -29,7 +29,7 @@ impl PreferencesStore { format!("{}-{}", self.app_name, filename) } - /// Save a [`PreferencesFile`] to browser storage, synchronously. + /// Save a [`toml::Table`] to browser storage, synchronously. /// /// # Arguments /// * `filename` - the name of the file to be saved @@ -43,7 +43,7 @@ impl PreferencesStore { } } - /// Save the content of a [`PreferencesFile`] to disk, in another thread. + /// Save the content of a [`toml::Table`] to disk, in another thread. /// /// # Arguments /// * `filename` - the name of the file to be saved @@ -61,7 +61,7 @@ impl PreferencesStore { }); } - /// Deserialize a preferences file. If the file does not exist, `None` will + /// Deserialize a [`toml::Table`]. If the file does not exist, `None` will /// be returned. /// /// # Arguments From a7c3bf9f90c3ddedc84f480537544795c28243a2 Mon Sep 17 00:00:00 2001 From: Talin Date: Wed, 18 Feb 2026 10:51:26 -0800 Subject: [PATCH 04/23] Typo --- crates/bevy_preferences/src/store_wasm.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/bevy_preferences/src/store_wasm.rs b/crates/bevy_preferences/src/store_wasm.rs index eaa935ffdc4c3..d10229db8b9b1 100644 --- a/crates/bevy_preferences/src/store_wasm.rs +++ b/crates/bevy_preferences/src/store_wasm.rs @@ -10,7 +10,7 @@ pub(crate) struct PreferencesStore { } impl PreferencesStore { - /// Construct a new preferecnes store for browser local storage. + /// Construct a new preferences store for browser local storage. /// /// # Arguments /// * `app_name` - The name of the application. This is used to uniquely identify the From ce2fbd4fcb234c46ecf1ff5e6b59ea31af2962a3 Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 20 Feb 2026 08:44:45 -0800 Subject: [PATCH 05/23] Changed PreferencesGroup annotation to derive macro. --- crates/bevy_ecs/macros/src/lib.rs | 40 +++++++ crates/bevy_preferences/Cargo.toml | 1 + crates/bevy_preferences/src/lib.rs | 177 +++++++++++++++-------------- examples/app/prefs_counter.rs | 9 +- 4 files changed, 140 insertions(+), 87 deletions(-) diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 6c830203ffce8..651a9259e8dc7 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -567,6 +567,46 @@ pub fn derive_resource(input: TokenStream) -> TokenStream { component::derive_resource(input) } +/// Implement the `SettingsGroup` trait. +#[proc_macro_derive(SettingsGroup)] +pub fn derive_settings_group(input: TokenStream) -> TokenStream { + let input = parse_macro_input!(input as DeriveInput); + + let name = &input.ident; + let snake = to_snake_case(&name.to_string()); + + let expanded = quote! { + impl SettingsGroup for #name { + fn settings_group_name() -> &'static str { + #snake + } + } + }; + + TokenStream::from(expanded) +} + +fn to_snake_case(s: &str) -> String { + let mut out = String::new(); + let chars: Vec = s.chars().collect(); + + for (i, &ch) in chars.iter().enumerate() { + if ch.is_uppercase() { + let prev_is_lower = i > 0 && chars[i - 1].is_lowercase(); + let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + + if i > 0 && (prev_is_lower || next_is_lower) { + out.push('_'); + } + out.push(ch.to_lowercase().next().unwrap()); + } else { + out.push(ch); + } + } + + out +} + /// Cheat sheet for derive syntax, /// see full explanation and examples on the `Component` trait doc. /// diff --git a/crates/bevy_preferences/Cargo.toml b/crates/bevy_preferences/Cargo.toml index a354cade0e6b2..df4815e9c55d2 100644 --- a/crates/bevy_preferences/Cargo.toml +++ b/crates/bevy_preferences/Cargo.toml @@ -12,6 +12,7 @@ keywords = ["bevy"] # bevy bevy_app = { path = "../bevy_app", version = "0.19.0-dev" } bevy_ecs = { path = "../bevy_ecs", version = "0.19.0-dev" } +bevy_ecs_macros = { path = "../bevy_ecs/macros", version = "0.19.0-dev" } bevy_log = { path = "../bevy_log", version = "0.19.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.19.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index 3f9a85666b7ed..6399940208872 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -10,10 +10,11 @@ use bevy_ecs::{ system::Command, world::World, }; +pub use bevy_ecs_macros::SettingsGroup; use bevy_log::warn; use bevy_reflect::{ - prelude::ReflectDefault, serde::TypedReflectDeserializer, Reflect, ReflectDeserialize, - ReflectSerialize, TypeInfo, + prelude::ReflectDefault, serde::TypedReflectDeserializer, FromReflect, FromType, Reflect, + ReflectDeserialize, ReflectSerialize, TypeInfo, TypePath, TypeRegistration, }; #[cfg(not(target_arch = "wasm32"))] @@ -54,13 +55,34 @@ impl Plugin for PreferencesPlugin { /// Annotation for a type which overrides which preferences file the type's contents will be /// written to. By default, all preferences are written to a file named "settings". +/// TODO: Change this to an option on the derive macro once I figure out how to do that. #[derive(Debug, Clone, Reflect)] pub struct PreferencesFile(pub &'static str); -/// Annotation for a type which causes the type's contents to be placed in a named section -/// in the preferences file. -#[derive(Debug, Clone, Reflect)] -pub struct PreferencesGroup(pub &'static str); +/// Trait which identifies a type as corresponding to a section with a settings file. +pub trait SettingsGroup: Resource { + /// The name of the logical section within the settings file. + fn settings_group_name() -> &'static str; +} + +/// Reflected data from a [`SettingsGroup`]. +#[derive(Clone)] +pub struct ReflectSettingsGroup { + /// The name of the logical section within the settings file. + settings_group_name: &'static str, +} + +impl FromType for ReflectSettingsGroup { + fn from_type() -> Self { + ReflectSettingsGroup { + settings_group_name: T::settings_group_name(), + } + } + + fn insert_dependencies(type_registration: &mut TypeRegistration) { + type_registration.register_type_data::(); + } +} /// List of resource types that will be associated with a specific preferences file. /// Also tracks when that file was last written or read. @@ -104,41 +126,38 @@ fn resources_to_toml( let mut table = toml::Table::new(); for tid in manifest.resource_types.iter() { let ty = types.get(*tid).unwrap(); - let type_info = ty.type_info(); let Some(cmp) = ty.data::() else { continue; }; let Some(ser) = ty.data::() else { continue; }; + let Some(reflect_settings_group) = ty.data::() else { + continue; + }; - if let TypeInfo::Struct(stinfo) = type_info - && let Some(group) = stinfo - .custom_attributes() - .get::() - .map(|g| g.0) - { - let Some(component_id) = world.components().get_id(*tid) else { - continue; - }; + let group = reflect_settings_group.settings_group_name; + + let Some(component_id) = world.components().get_id(*tid) else { + continue; + }; - // let Some(resource_tick) = world.get_resource_change_ticks_by_id(component_id) else { - // continue; - // }; + // let Some(resource_tick) = world.get_resource_change_ticks_by_id(component_id) else { + // continue; + // }; - let Some(res_entity) = world.resource_entities().get(component_id) else { - continue; - }; - let res_entity_ref = world.entity(*res_entity); + let Some(res_entity) = world.resource_entities().get(component_id) else { + continue; + }; + let res_entity_ref = world.entity(*res_entity); - let Some(reflect) = cmp.reflect(res_entity_ref) else { - continue; - }; - let ser_value = ser.get_serializable(reflect); + let Some(reflect) = cmp.reflect(res_entity_ref) else { + continue; + }; + let ser_value = ser.get_serializable(reflect); - let toml_value = toml::Value::try_from(&*ser_value).unwrap(); - table.insert(group.to_string(), toml_value); - } + let toml_value = toml::Value::try_from(&*ser_value).unwrap(); + table.insert(group.to_string(), toml_value); } table } @@ -228,7 +247,7 @@ impl LoadPreferences for App { // Scan through types looking for resources that have the necessary traits and // annotations. for ty in types.iter() { - if !(ty.contains::() + if !(ty.contains::() && ty.contains::() && ty.contains::() && ty.contains::()) @@ -237,12 +256,7 @@ impl LoadPreferences for App { }; let type_info = ty.type_info(); - if let TypeInfo::Struct(stinfo) = type_info - && let Some(_group) = stinfo - .custom_attributes() - .get::() - .map(|g| g.0) - { + if let TypeInfo::Struct(stinfo) = type_info { // If no filename is specified, use "settings" let filename = stinfo .custom_attributes() @@ -276,54 +290,49 @@ impl LoadPreferences for App { for tid in manifest.resource_types.iter() { let ty = types.get(*tid).unwrap(); - let type_info = ty.type_info(); - - if let TypeInfo::Struct(stinfo) = type_info - && let Some(group) = stinfo - .custom_attributes() - .get::() - .map(|g| g.0) - { - let reflect_component = ty.data::().unwrap(); - let component_id = world.components().get_id(*tid); - let res_entity = - component_id.and_then(|cid| world.resource_entities().get(cid)); - - let deserializer = TypedReflectDeserializer::new(ty, &types); - if let Some(res_entity) = res_entity { - // Resource already exists, so apply toml properties to it. - let res_entity_mut = world.entity_mut(*res_entity); - let Some(mut reflect) = reflect_component.reflect_mut(res_entity_mut) - else { - continue; - }; - - if let Some(ref toml) = toml - && let Some(value) = toml.get(group) - { - let new_value = deserializer.deserialize(value.clone()).unwrap(); - reflect.apply(new_value.as_ref()); - } - } else { - // The resource does not exist, so create a default. - let reflect_default = ty.data::().unwrap(); - let mut default_value = reflect_default.default(); - let types = app_types.read(); - let mut res_entity = world.spawn_empty(); - - if let Some(ref toml) = toml - && let Some(value) = toml.get(group) - { - let new_value = deserializer.deserialize(value.clone()).unwrap(); - default_value.apply(new_value.as_ref()); - } - - reflect_component.insert( - &mut res_entity, - default_value.as_partial_reflect(), - &types, - ); + let Some(reflect_settings_group) = ty.data::() else { + continue; + }; + + let group = reflect_settings_group.settings_group_name; + + let reflect_component = ty.data::().unwrap(); + let component_id = world.components().get_id(*tid); + let res_entity = component_id.and_then(|cid| world.resource_entities().get(cid)); + + let deserializer = TypedReflectDeserializer::new(ty, &types); + if let Some(res_entity) = res_entity { + // Resource already exists, so apply toml properties to it. + let res_entity_mut = world.entity_mut(*res_entity); + let Some(mut reflect) = reflect_component.reflect_mut(res_entity_mut) else { + continue; + }; + + if let Some(ref toml) = toml + && let Some(value) = toml.get(group) + { + let new_value = deserializer.deserialize(value.clone()).unwrap(); + reflect.apply(new_value.as_ref()); + } + } else { + // The resource does not exist, so create a default. + let reflect_default = ty.data::().unwrap(); + let mut default_value = reflect_default.default(); + let types = app_types.read(); + let mut res_entity = world.spawn_empty(); + + if let Some(ref toml) = toml + && let Some(value) = toml.get(group) + { + let new_value = deserializer.deserialize(value.clone()).unwrap(); + default_value.apply(new_value.as_ref()); } + + reflect_component.insert( + &mut res_entity, + default_value.as_partial_reflect(), + &types, + ); } } } diff --git a/examples/app/prefs_counter.rs b/examples/app/prefs_counter.rs index 76bd8fb662bfb..40b95032ed5f0 100644 --- a/examples/app/prefs_counter.rs +++ b/examples/app/prefs_counter.rs @@ -1,7 +1,10 @@ //! Demonstrates persistence of user preferences. use bevy::{ // user_prefs::{Preferences, StartAutosaveTimer}, - preferences::{LoadPreferences as _, PreferencesGroup, PreferencesPlugin, SavePreferencesSync}, + preferences::{ + LoadPreferences as _, PreferencesPlugin, ReflectSettingsGroup, SavePreferencesSync, + SettingsGroup, + }, prelude::*, window::{ExitCondition, WindowCloseRequested}, }; @@ -26,8 +29,8 @@ fn main() { .run(); } -#[derive(Resource, Reflect, Default, Serialize, Deserialize)] -#[reflect(Resource, Serialize, Deserialize, Default, @PreferencesGroup("counter"))] +#[derive(Resource, SettingsGroup, Reflect, Default, Serialize, Deserialize)] +#[reflect(Resource, SettingsGroup, Serialize, Deserialize, Default)] struct Counter { count: i32, } From 004bf2e7ce7df719e080bb6142ec5e3b3d3b1332 Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 20 Feb 2026 08:49:25 -0800 Subject: [PATCH 06/23] Moved case conversion to a better place. --- crates/bevy_ecs/macros/src/lib.rs | 27 ++++------------------- crates/bevy_macro_utils/src/label.rs | 33 ++++++++++++++++++++++++++++ 2 files changed, 37 insertions(+), 23 deletions(-) diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 651a9259e8dc7..df486ebce38ce 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -15,7 +15,9 @@ use crate::{ component::map_entities, query_data::derive_query_data_impl, query_filter::derive_query_filter_impl, }; -use bevy_macro_utils::{derive_label, ensure_no_collision, get_struct_fields, BevyManifest}; +use bevy_macro_utils::{ + derive_label, ensure_no_collision, get_struct_fields, pascal_to_snake_case, BevyManifest, +}; use proc_macro::TokenStream; use proc_macro2::{Ident, Span}; use quote::{format_ident, quote, ToTokens}; @@ -573,7 +575,7 @@ pub fn derive_settings_group(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let name = &input.ident; - let snake = to_snake_case(&name.to_string()); + let snake = pascal_to_snake_case(&name.to_string()); let expanded = quote! { impl SettingsGroup for #name { @@ -586,27 +588,6 @@ pub fn derive_settings_group(input: TokenStream) -> TokenStream { TokenStream::from(expanded) } -fn to_snake_case(s: &str) -> String { - let mut out = String::new(); - let chars: Vec = s.chars().collect(); - - for (i, &ch) in chars.iter().enumerate() { - if ch.is_uppercase() { - let prev_is_lower = i > 0 && chars[i - 1].is_lowercase(); - let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); - - if i > 0 && (prev_is_lower || next_is_lower) { - out.push('_'); - } - out.push(ch.to_lowercase().next().unwrap()); - } else { - out.push(ch); - } - } - - out -} - /// Cheat sheet for derive syntax, /// see full explanation and examples on the `Component` trait doc. /// diff --git a/crates/bevy_macro_utils/src/label.rs b/crates/bevy_macro_utils/src/label.rs index 7669f85f1a385..6e660989dab48 100644 --- a/crates/bevy_macro_utils/src/label.rs +++ b/crates/bevy_macro_utils/src/label.rs @@ -93,3 +93,36 @@ pub fn derive_label( } .into() } + +/// Convert a string from ``PascalCase`` to ``snake_case``. +pub fn pascal_to_snake_case(s: &str) -> String { + let mut out = String::new(); + let chars: Vec = s.chars().collect(); + + for (i, &ch) in chars.iter().enumerate() { + if ch.is_uppercase() { + let prev_is_lower = i > 0 && chars[i - 1].is_lowercase(); + let next_is_lower = chars.get(i + 1).is_some_and(|c| c.is_lowercase()); + + if i > 0 && (prev_is_lower || next_is_lower) { + out.push('_'); + } + out.push(ch.to_lowercase().next().unwrap()); + } else { + out.push(ch); + } + } + + out +} +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_pascal_to_snake_case() { + assert_eq!(pascal_to_snake_case("PascalCase"), "pascal_case"); + assert_eq!(pascal_to_snake_case("lowercase"), "lowercase"); + assert_eq!(pascal_to_snake_case("HTTPServer"), "http_server"); + } +} From 2f1cf3a59659542aa8f73b0265b0b354782b4cd6 Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 20 Feb 2026 13:19:12 -0800 Subject: [PATCH 07/23] Implement override of group name. --- crates/bevy_ecs/macros/src/lib.rs | 27 ++++++++++++++++++++++++--- examples/app/prefs_counter.rs | 1 + 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index df486ebce38ce..190e6dbe9c280 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -570,17 +570,38 @@ pub fn derive_resource(input: TokenStream) -> TokenStream { } /// Implement the `SettingsGroup` trait. -#[proc_macro_derive(SettingsGroup)] +#[proc_macro_derive(SettingsGroup, attributes(settings_group))] pub fn derive_settings_group(input: TokenStream) -> TokenStream { let input = parse_macro_input!(input as DeriveInput); let name = &input.ident; - let snake = pascal_to_snake_case(&name.to_string()); + + let override_name = input + .attrs + .iter() + .find(|attr| attr.path().is_ident("settings_group")) + .and_then(|attr| { + let mut override_val: Option = None; + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("group") { + let value = meta.value()?; + let s: syn::LitStr = value.parse()?; + override_val = Some(s.value()); + Ok(()) + } else { + Err(meta.error("unsupported attribute")) + } + }) + .ok()?; + override_val + }); + + let group_name = override_name.unwrap_or(pascal_to_snake_case(&name.to_string())); let expanded = quote! { impl SettingsGroup for #name { fn settings_group_name() -> &'static str { - #snake + #group_name } } }; diff --git a/examples/app/prefs_counter.rs b/examples/app/prefs_counter.rs index 40b95032ed5f0..4ad4ae476c16c 100644 --- a/examples/app/prefs_counter.rs +++ b/examples/app/prefs_counter.rs @@ -31,6 +31,7 @@ fn main() { #[derive(Resource, SettingsGroup, Reflect, Default, Serialize, Deserialize)] #[reflect(Resource, SettingsGroup, Serialize, Deserialize, Default)] +// #[settings_group(group = "counter_test")] struct Counter { count: i32, } From d70e03d90343792b27ac0cbaaeaf3086c158b025 Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 20 Feb 2026 18:01:06 -0800 Subject: [PATCH 08/23] Section merging. --- crates/bevy_preferences/src/lib.rs | 59 +++++++++++++++++++++++++----- examples/app/prefs_counter.rs | 10 ++++- 2 files changed, 58 insertions(+), 11 deletions(-) diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index 6399940208872..e7acdd08d7796 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -13,8 +13,9 @@ use bevy_ecs::{ pub use bevy_ecs_macros::SettingsGroup; use bevy_log::warn; use bevy_reflect::{ - prelude::ReflectDefault, serde::TypedReflectDeserializer, FromReflect, FromType, Reflect, - ReflectDeserialize, ReflectSerialize, TypeInfo, TypePath, TypeRegistration, + prelude::ReflectDefault, serde::TypedReflectDeserializer, FromReflect, FromType, + PartialReflect, Reflect, ReflectDeserialize, ReflectMut, ReflectSerialize, TypeInfo, TypePath, + TypeRegistration, TypeRegistry, }; #[cfg(not(target_arch = "wasm32"))] @@ -55,11 +56,14 @@ impl Plugin for PreferencesPlugin { /// Annotation for a type which overrides which preferences file the type's contents will be /// written to. By default, all preferences are written to a file named "settings". -/// TODO: Change this to an option on the derive macro once I figure out how to do that. +/// TODO: Change this to an option on the derive macro. #[derive(Debug, Clone, Reflect)] pub struct PreferencesFile(pub &'static str); /// Trait which identifies a type as corresponding to a section with a settings file. +/// You can override the name of the section with `settings_group(group = "")`. +/// If there is a collision between names (multiple resources have the same name) then +/// the resulting properties will be merged into a single section. pub trait SettingsGroup: Resource { /// The name of the logical section within the settings file. fn settings_group_name() -> &'static str; @@ -120,7 +124,7 @@ impl Command for SavePreferencesSync { fn resources_to_toml( world: &World, - types: &bevy_reflect::TypeRegistry, + types: &TypeRegistry, manifest: &PreferenceFileManifest, ) -> toml::map::Map { let mut table = toml::Table::new(); @@ -157,7 +161,20 @@ fn resources_to_toml( let ser_value = ser.get_serializable(reflect); let toml_value = toml::Value::try_from(&*ser_value).unwrap(); - table.insert(group.to_string(), toml_value); + match ( + toml_value.as_table(), + table.get_mut(group).and_then(|value| value.as_table_mut()), + ) { + (Some(from), Some(to)) => { + // Merge the tables + for (key, value) in from.iter() { + to.insert(key.to_string(), value.clone()); + } + } + _ => { + table.insert(group.to_string(), toml_value); + } + }; } table } @@ -300,7 +317,7 @@ impl LoadPreferences for App { let component_id = world.components().get_id(*tid); let res_entity = component_id.and_then(|cid| world.resource_entities().get(cid)); - let deserializer = TypedReflectDeserializer::new(ty, &types); + // let deserializer = TypedReflectDeserializer::new(ty, &types); if let Some(res_entity) = res_entity { // Resource already exists, so apply toml properties to it. let res_entity_mut = world.entity_mut(*res_entity); @@ -311,8 +328,7 @@ impl LoadPreferences for App { if let Some(ref toml) = toml && let Some(value) = toml.get(group) { - let new_value = deserializer.deserialize(value.clone()).unwrap(); - reflect.apply(new_value.as_ref()); + load_properties(value, &mut *reflect, &types); } } else { // The resource does not exist, so create a default. @@ -324,8 +340,7 @@ impl LoadPreferences for App { if let Some(ref toml) = toml && let Some(value) = toml.get(group) { - let new_value = deserializer.deserialize(value.clone()).unwrap(); - default_value.apply(new_value.as_ref()); + load_properties(value, &mut *default_value, &types); } reflect_component.insert( @@ -343,3 +358,27 @@ impl LoadPreferences for App { self } } + +fn load_properties(value: &toml::Value, resource: &mut dyn PartialReflect, types: &TypeRegistry) { + let Some(tinfo) = resource.get_represented_type_info() else { + return; + }; + if let TypeInfo::Struct(stinfo) = tinfo + && let Some(table) = value.as_table() + && let ReflectMut::Struct(st_reflect) = resource.reflect_mut() + { + // Deserialize matching field names, ignore ones that don't match. + for (idx, field) in stinfo.field_names().iter().enumerate() { + if let Some(toml_field_value) = table.get(*field) + && let Some(field_info) = stinfo.field_at(idx) + && let Some(field_type) = types.get(field_info.type_id()) + { + let deserializer = TypedReflectDeserializer::new(field_type, types); + if let Ok(field_value) = deserializer.deserialize(toml_field_value.clone()) { + // Should be safe to unwrap here since we know the field exists (above). + st_reflect.field_at_mut(idx).unwrap().apply(&*field_value); + } + } + } + } +} diff --git a/examples/app/prefs_counter.rs b/examples/app/prefs_counter.rs index 4ad4ae476c16c..8c077a45a2af5 100644 --- a/examples/app/prefs_counter.rs +++ b/examples/app/prefs_counter.rs @@ -31,11 +31,19 @@ fn main() { #[derive(Resource, SettingsGroup, Reflect, Default, Serialize, Deserialize)] #[reflect(Resource, SettingsGroup, Serialize, Deserialize, Default)] -// #[settings_group(group = "counter_test")] struct Counter { count: i32, } +/// A different settings group which has the name group name as the previous. The two groups will be +/// merged into a single section in the config file. +#[derive(Resource, SettingsGroup, Reflect, Default, Serialize, Deserialize)] +#[reflect(Resource, SettingsGroup, Serialize, Deserialize, Default)] +#[settings_group(group = "counter")] +struct OtherSettings { + enabled: bool, +} + #[derive(Component)] struct CounterDisplay; From f8e42e023d16c878265ec4a6e9ce87e0ce362e9e Mon Sep 17 00:00:00 2001 From: Talin Date: Fri, 20 Feb 2026 19:43:00 -0800 Subject: [PATCH 09/23] Remove dependency on Serialize/Deserialize. --- crates/bevy_preferences/src/lib.rs | 23 +++++++++-------------- examples/app/prefs_counter.rs | 9 ++++----- 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index e7acdd08d7796..918a5729c7fbc 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -13,8 +13,9 @@ use bevy_ecs::{ pub use bevy_ecs_macros::SettingsGroup; use bevy_log::warn; use bevy_reflect::{ - prelude::ReflectDefault, serde::TypedReflectDeserializer, FromReflect, FromType, - PartialReflect, Reflect, ReflectDeserialize, ReflectMut, ReflectSerialize, TypeInfo, TypePath, + prelude::ReflectDefault, + serde::{TypedReflectDeserializer, TypedReflectSerializer}, + FromReflect, FromType, PartialReflect, Reflect, ReflectMut, TypeInfo, TypePath, TypeRegistration, TypeRegistry, }; @@ -133,9 +134,6 @@ fn resources_to_toml( let Some(cmp) = ty.data::() else { continue; }; - let Some(ser) = ty.data::() else { - continue; - }; let Some(reflect_settings_group) = ty.data::() else { continue; }; @@ -154,13 +152,12 @@ fn resources_to_toml( continue; }; let res_entity_ref = world.entity(*res_entity); - let Some(reflect) = cmp.reflect(res_entity_ref) else { continue; }; - let ser_value = ser.get_serializable(reflect); - let toml_value = toml::Value::try_from(&*ser_value).unwrap(); + let serializer = TypedReflectSerializer::new(reflect.as_partial_reflect(), types); + let toml_value = toml::Value::try_from(serializer).unwrap(); match ( toml_value.as_table(), table.get_mut(group).and_then(|value| value.as_table_mut()), @@ -264,11 +261,7 @@ impl LoadPreferences for App { // Scan through types looking for resources that have the necessary traits and // annotations. for ty in types.iter() { - if !(ty.contains::() - && ty.contains::() - && ty.contains::() - && ty.contains::()) - { + if !(ty.contains::() && ty.contains::()) { continue; }; @@ -343,6 +336,7 @@ impl LoadPreferences for App { load_properties(value, &mut *default_value, &types); } + // Now add the new resource to the world. reflect_component.insert( &mut res_entity, default_value.as_partial_reflect(), @@ -352,9 +346,10 @@ impl LoadPreferences for App { } } + // Cache the index so that we don't have to do it again when saving (and also makes + // saving more deterministic). drop(types); world.insert_resource::(file_index); - self } } diff --git a/examples/app/prefs_counter.rs b/examples/app/prefs_counter.rs index 8c077a45a2af5..0f51100f93b71 100644 --- a/examples/app/prefs_counter.rs +++ b/examples/app/prefs_counter.rs @@ -8,7 +8,6 @@ use bevy::{ prelude::*, window::{ExitCondition, WindowCloseRequested}, }; -use serde::{Deserialize, Serialize}; fn main() { App::new() @@ -29,16 +28,16 @@ fn main() { .run(); } -#[derive(Resource, SettingsGroup, Reflect, Default, Serialize, Deserialize)] -#[reflect(Resource, SettingsGroup, Serialize, Deserialize, Default)] +#[derive(Resource, SettingsGroup, Reflect, Default)] +#[reflect(Resource, SettingsGroup, Default)] struct Counter { count: i32, } /// A different settings group which has the name group name as the previous. The two groups will be /// merged into a single section in the config file. -#[derive(Resource, SettingsGroup, Reflect, Default, Serialize, Deserialize)] -#[reflect(Resource, SettingsGroup, Serialize, Deserialize, Default)] +#[derive(Resource, SettingsGroup, Reflect, Default)] +#[reflect(Resource, SettingsGroup, Default)] #[settings_group(group = "counter")] struct OtherSettings { enabled: bool, From 71cdf5b5b60f28dfd8202816461ffb616fcc539c Mon Sep 17 00:00:00 2001 From: Talin Date: Sat, 21 Feb 2026 22:23:41 -0800 Subject: [PATCH 10/23] Window position saving. --- Cargo.toml | 12 ++ examples/app/prefs_window.rs | 207 +++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 examples/app/prefs_window.rs diff --git a/Cargo.toml b/Cargo.toml index a95d438bf0e12..828f2c7b4deea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -5162,6 +5162,18 @@ description = "Demonstrates persistence of user preferences" category = "Application" wasm = true +[[example]] +name = "prefs_window" +path = "examples/app/prefs_window.rs" +doc-scrape-examples = true +required-features = ["bevy_preferences"] + +[package.metadata.example.prefs_window] +name = "User Preferences" +description = "Demonstrates saving window position in preferences" +category = "Application" +wasm = false + [[example]] name = "system_fonts" path = "examples/ui/text/system_fonts.rs" diff --git a/examples/app/prefs_window.rs b/examples/app/prefs_window.rs new file mode 100644 index 0000000000000..610fc22f3b6fe --- /dev/null +++ b/examples/app/prefs_window.rs @@ -0,0 +1,207 @@ +//! Demonstrates persistence of user preferences for saving window position. +use bevy::{ + // user_prefs::{StartAutosaveTimer}, + preferences::{ + LoadPreferences as _, PreferencesPlugin, ReflectSettingsGroup, SavePreferencesSync, + SettingsGroup, + }, + prelude::*, + window::{ExitCondition, WindowCloseRequested, WindowMode, WindowResized, WindowResolution}, +}; + +fn main() { + App::new() + .add_plugins(DefaultPlugins.set(WindowPlugin { + // We want to intercept the exit so that we can save prefs. + exit_condition: ExitCondition::DontExit, + primary_window: Some(Window { + title: "Prefs Counter".into(), + ..default() + }), + ..default() + })) + .add_plugins(PreferencesPlugin::new("org.bevy.examples.prefs_window")) + // .add_plugins(AutosavePrefsPlugin) + .add_systems(Startup, setup) + .add_systems( + Update, + ( + show_count, + change_count, + on_window_close, + update_window_settings, + ), + ) + .load_preferences() + .add_plugins(init_window_pos) + .run(); +} + +#[derive(Resource, SettingsGroup, Reflect, Default)] +#[reflect(Resource, SettingsGroup, Default)] +struct Counter { + count: i32, +} + +/// Settings group which remembers the current window position and size +#[derive(Resource, SettingsGroup, Reflect, Default, Clone, PartialEq)] +#[reflect(Resource, SettingsGroup, Default)] +#[settings_group(group = "window")] +struct WindowSettings { + position: Option, + size: Option, + fullscreen: bool, +} + +#[derive(Component)] +struct CounterDisplay; + +fn init_window_pos(app: &mut App) { + let world = app.world_mut(); + let Some(window_settings) = world.get_resource::() else { + return; + }; + let window_settings = window_settings.clone(); + + let Ok(mut window) = world.query::<&mut Window>().single_mut(world) else { + warn!("window not found"); + return; + }; + + if let Some(position) = window_settings.position { + window.position = WindowPosition::new(position); + } + + if let Some(size) = window_settings.size { + window.resolution = WindowResolution::new(size.x, size.y); + } + + window.mode = if window_settings.fullscreen { + WindowMode::BorderlessFullscreen(MonitorSelection::Current) + } else { + WindowMode::Windowed + }; +} + +fn setup(mut commands: Commands) { + commands.spawn((Camera::default(), Camera2d)); + commands + .spawn(Node { + width: Val::Percent(100.0), + height: Val::Percent(100.0), + display: Display::Flex, + flex_direction: FlexDirection::Column, + align_items: AlignItems::Center, + justify_content: JustifyContent::Center, + ..default() + }) + .with_children(|parent| { + parent.spawn(( + Text::new("---"), + TextFont { + font_size: FontSize::Px(33.0), + ..default() + }, + CounterDisplay, + TextColor(Color::srgb(0.9, 0.9, 0.9)), + )); + parent.spawn(( + Text::new("Press SPACE to increment"), + TextFont { + font_size: FontSize::Px(20.0), + ..default() + }, + )); + }); +} + +fn show_count(mut query: Query<&mut Text, With>, counter: Res) { + if counter.is_changed() { + for mut text in query.iter_mut() { + text.0 = format!("Count: {}", counter.count); + } + } +} + +fn change_count( + mut counter: ResMut, + keyboard: Res>, + mut commands: Commands, +) { + let mut changed = false; + if keyboard.just_pressed(KeyCode::Space) || keyboard.just_pressed(KeyCode::Period) { + counter.count += 1; + changed = true; + } + if keyboard.just_pressed(KeyCode::Backspace) + || keyboard.just_pressed(KeyCode::Delete) + || keyboard.just_pressed(KeyCode::Comma) + { + counter.count -= 1; + changed = true; + } + + if changed { + commands.queue(SavePreferencesSync::IfChanged); + } +} + +/// System which keeps the window settings up to date when the user resizes or moves the window. +pub fn update_window_settings( + mut move_events: MessageReader, + mut resize_events: MessageReader, + windows: Query<&mut Window>, + mut window_settings: ResMut, + mut commands: Commands, +) { + let Ok(window) = windows.single() else { + return; + }; + + let mut window_changed = false; + for _ in move_events.read() { + window_changed = true; + } + + for _ in resize_events.read() { + window_changed = true; + } + + if window_changed { + store_window_settings(window_settings, window); + // TODO: Replace with timer + commands.queue(SavePreferencesSync::IfChanged); + } +} + +fn store_window_settings(mut window_settings: ResMut, window: &Window) { + window_settings.set_if_neq(WindowSettings { + position: match window.position { + WindowPosition::At(pos) => Some(pos), + _ => None, + }, + size: Some(UVec2::new( + window.resolution.width() as u32, + window.resolution.height() as u32, + )), + fullscreen: window.mode != WindowMode::Windowed, + }); + + // commands.queue(StartAutosaveTimer); +} + +fn on_window_close(mut close: MessageReader, mut commands: Commands) { + // Save preferences immediately, then quit. + if let Some(_close_event) = close.read().next() { + commands.queue(SavePreferencesSync::IfChanged); + commands.queue(ExitAfterSave); + } +} + +struct ExitAfterSave; + +impl Command for ExitAfterSave { + fn apply(self, world: &mut World) { + world.write_message(AppExit::Success); + } +} From 34f6649644ba46a4c6e94f19641f16ff4fa30f27 Mon Sep 17 00:00:00 2001 From: Talin Date: Sun, 22 Feb 2026 12:31:48 -0800 Subject: [PATCH 11/23] Changed `PreferencesFile` annotation to derive attribute. --- crates/bevy_ecs/macros/src/lib.rs | 54 +++++++++++------- crates/bevy_preferences/src/lib.rs | 91 ++++++++++++++++-------------- examples/app/prefs_window.rs | 12 ++-- 3 files changed, 90 insertions(+), 67 deletions(-) diff --git a/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 190e6dbe9c280..34476f625da3c 100644 --- a/crates/bevy_ecs/macros/src/lib.rs +++ b/crates/bevy_ecs/macros/src/lib.rs @@ -576,33 +576,49 @@ pub fn derive_settings_group(input: TokenStream) -> TokenStream { let name = &input.ident; - let override_name = input - .attrs - .iter() - .find(|attr| attr.path().is_ident("settings_group")) - .and_then(|attr| { - let mut override_val: Option = None; - attr.parse_nested_meta(|meta| { - if meta.path.is_ident("group") { - let value = meta.value()?; - let s: syn::LitStr = value.parse()?; - override_val = Some(s.value()); - Ok(()) - } else { - Err(meta.error("unsupported attribute")) - } - }) - .ok()?; - override_val - }); + let (override_name, override_file) = { + let mut override_name: Option = None; + let mut override_file: Option = None; + + input + .attrs + .iter() + .find(|attr| attr.path().is_ident("settings_group")) + .and_then(|attr| { + attr.parse_nested_meta(|meta| { + if meta.path.is_ident("group") { + let value = meta.value()?; + let s: syn::LitStr = value.parse()?; + override_name = Some(s.value()); + Ok(()) + } else if meta.path.is_ident("file") { + let value = meta.value()?; + let s: syn::LitStr = value.parse()?; + override_file = Some(s.value()); + Ok(()) + } else { + Err(meta.error("unsupported attribute")) + } + }) + .ok() + }); + + (override_name, override_file) + }; let group_name = override_name.unwrap_or(pascal_to_snake_case(&name.to_string())); + let file_name = override_file + .map(|f| quote! { Some(#f) }) + .unwrap_or(quote! { None }); let expanded = quote! { impl SettingsGroup for #name { fn settings_group_name() -> &'static str { #group_name } + fn settings_source() -> Option<&'static str> { + #file_name + } } }; diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index 918a5729c7fbc..f6550712fc70b 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -11,12 +11,12 @@ use bevy_ecs::{ world::World, }; pub use bevy_ecs_macros::SettingsGroup; -use bevy_log::warn; +use bevy_log::{info, warn}; use bevy_reflect::{ prelude::ReflectDefault, serde::{TypedReflectDeserializer, TypedReflectSerializer}, - FromReflect, FromType, PartialReflect, Reflect, ReflectMut, TypeInfo, TypePath, - TypeRegistration, TypeRegistry, + FromReflect, FromType, PartialReflect, ReflectMut, TypeInfo, TypePath, TypeRegistration, + TypeRegistry, }; #[cfg(not(target_arch = "wasm32"))] @@ -55,12 +55,6 @@ impl Plugin for PreferencesPlugin { fn build(&self, _app: &mut App) {} } -/// Annotation for a type which overrides which preferences file the type's contents will be -/// written to. By default, all preferences are written to a file named "settings". -/// TODO: Change this to an option on the derive macro. -#[derive(Debug, Clone, Reflect)] -pub struct PreferencesFile(pub &'static str); - /// Trait which identifies a type as corresponding to a section with a settings file. /// You can override the name of the section with `settings_group(group = "")`. /// If there is a collision between names (multiple resources have the same name) then @@ -68,6 +62,10 @@ pub struct PreferencesFile(pub &'static str); pub trait SettingsGroup: Resource { /// The name of the logical section within the settings file. fn settings_group_name() -> &'static str; + + /// The name of the configuration file that contains this settings group. + /// TODO: Eventually convert this into an enum which represents various configuration sources. + fn settings_source() -> Option<&'static str>; } /// Reflected data from a [`SettingsGroup`]. @@ -75,12 +73,15 @@ pub trait SettingsGroup: Resource { pub struct ReflectSettingsGroup { /// The name of the logical section within the settings file. settings_group_name: &'static str, + /// The name of the settings file, defaults to "settings". + settings_source: Option<&'static str>, } impl FromType for ReflectSettingsGroup { fn from_type() -> Self { ReflectSettingsGroup { settings_group_name: T::settings_group_name(), + settings_source: T::settings_source(), } } @@ -144,10 +145,6 @@ fn resources_to_toml( continue; }; - // let Some(resource_tick) = world.get_resource_change_ticks_by_id(component_id) else { - // continue; - // }; - let Some(res_entity) = world.resource_entities().get(component_id) else { continue; }; @@ -192,7 +189,7 @@ impl Command for SavePreferences { } } -fn save_preferences(world: &mut World, use_async: bool, _force: bool) { +fn save_preferences(world: &mut World, use_async: bool, force: bool) { let this_run = world.change_tick(); let Some(registry) = world.get_resource::() else { warn!("Preferences registry not found - did you forget to call load_preferences()?"); @@ -205,14 +202,15 @@ fn save_preferences(world: &mut World, use_async: bool, _force: bool) { let types = app_types.read(); for (filename, manifest) in registry.files.iter() { - // TODO: See if changed unless _force is true - // only save if file.last_save is >= the change time of all resources. - let table = resources_to_toml(world, &types, manifest); - let store = PreferencesStore::new(®istry.app_name); - if use_async { - store.save_async(filename, table); - } else { - store.save(filename, table); + if force || has_preferences_changed(world, manifest) { + let table = resources_to_toml(world, &types, manifest); + let store = PreferencesStore::new(®istry.app_name); + info!("Saving settings {filename}"); + if use_async { + store.save_async(filename, table); + } else { + store.save(filename, table); + } } } @@ -223,6 +221,19 @@ fn save_preferences(world: &mut World, use_async: bool, _force: bool) { } } +fn has_preferences_changed(world: &World, manifest: &PreferenceFileManifest) -> bool { + let this_run = world.read_change_tick(); + manifest.resource_types.iter().any(|r| { + let Some(component_id) = world.components().get_id(*r) else { + return false; + }; + if let Some(resource_change) = world.get_resource_change_ticks_by_id(component_id) { + return resource_change.is_changed(manifest.last_save, this_run); + } + false + }) +} + /// Extension trait that implements loading of preferences into the application. /// /// This needs to be called before `app.build()` so that preference values will be available @@ -261,29 +272,25 @@ impl LoadPreferences for App { // Scan through types looking for resources that have the necessary traits and // annotations. for ty in types.iter() { - if !(ty.contains::() && ty.contains::()) { + if !ty.contains::() { continue; }; - let type_info = ty.type_info(); - if let TypeInfo::Struct(stinfo) = type_info { - // If no filename is specified, use "settings" - let filename = stinfo - .custom_attributes() - .get::() - .map_or("settings", |f| f.0); - - let pending_file = - file_index - .files - .entry(filename) - .or_insert(PreferenceFileManifest { - last_save, - resource_types: Vec::new(), - }); - pending_file.last_save = last_save; - pending_file.resource_types.push(ty.type_id()); - } + let Some(reflect_group) = ty.data::() else { + continue; + }; + + // If no filename is specified, use "settings" + let filename = reflect_group.settings_source.unwrap_or("settings"); + let pending_file = file_index + .files + .entry(filename) + .or_insert(PreferenceFileManifest { + last_save, + resource_types: Vec::new(), + }); + pending_file.last_save = last_save; + pending_file.resource_types.push(ty.type_id()); } // Now load each of the toml files we discovered, and apply their properties to diff --git a/examples/app/prefs_window.rs b/examples/app/prefs_window.rs index 610fc22f3b6fe..adfd0315f6791 100644 --- a/examples/app/prefs_window.rs +++ b/examples/app/prefs_window.rs @@ -2,8 +2,8 @@ use bevy::{ // user_prefs::{StartAutosaveTimer}, preferences::{ - LoadPreferences as _, PreferencesPlugin, ReflectSettingsGroup, SavePreferencesSync, - SettingsGroup, + LoadPreferences as _, PreferencesPlugin, ReflectSettingsGroup, SavePreferences, + SavePreferencesSync, SettingsGroup, }, prelude::*, window::{ExitCondition, WindowCloseRequested, WindowMode, WindowResized, WindowResolution}, @@ -142,16 +142,16 @@ fn change_count( } if changed { - commands.queue(SavePreferencesSync::IfChanged); + commands.queue(SavePreferences::IfChanged); } } /// System which keeps the window settings up to date when the user resizes or moves the window. -pub fn update_window_settings( +fn update_window_settings( mut move_events: MessageReader, mut resize_events: MessageReader, windows: Query<&mut Window>, - mut window_settings: ResMut, + window_settings: ResMut, mut commands: Commands, ) { let Ok(window) = windows.single() else { @@ -170,7 +170,7 @@ pub fn update_window_settings( if window_changed { store_window_settings(window_settings, window); // TODO: Replace with timer - commands.queue(SavePreferencesSync::IfChanged); + commands.queue(SavePreferences::IfChanged); } } From 203b6a571c68244e3d29943766e523e51505acd6 Mon Sep 17 00:00:00 2001 From: Talin Date: Sun, 22 Feb 2026 13:01:26 -0800 Subject: [PATCH 12/23] Added preferences save timer. --- crates/bevy_preferences/Cargo.toml | 1 + crates/bevy_preferences/src/lib.rs | 154 +++++++++++++++++++---------- examples/app/prefs_counter.rs | 8 +- examples/app/prefs_window.rs | 18 ++-- 4 files changed, 115 insertions(+), 66 deletions(-) diff --git a/crates/bevy_preferences/Cargo.toml b/crates/bevy_preferences/Cargo.toml index df4815e9c55d2..7ee83bc89e021 100644 --- a/crates/bevy_preferences/Cargo.toml +++ b/crates/bevy_preferences/Cargo.toml @@ -17,6 +17,7 @@ bevy_log = { path = "../bevy_log", version = "0.19.0-dev" } bevy_platform = { path = "../bevy_platform", version = "0.19.0-dev" } bevy_reflect = { path = "../bevy_reflect", version = "0.19.0-dev" } bevy_tasks = { path = "../bevy_tasks", version = "0.19.0-dev" } +bevy_time = { path = "../bevy_time", version = "0.19.0-dev" } serde = "1.0.217" thiserror = "2.0.18" diff --git a/crates/bevy_preferences/src/lib.rs b/crates/bevy_preferences/src/lib.rs index f6550712fc70b..d28f4a03a10bf 100644 --- a/crates/bevy_preferences/src/lib.rs +++ b/crates/bevy_preferences/src/lib.rs @@ -1,17 +1,18 @@ //! Framework for saving and loading user preferences in Bevy applications. use core::any::TypeId; +use core::time::Duration; use std::collections::HashMap; -use bevy_app::{App, Plugin}; +use bevy_app::{App, Plugin, PostUpdate}; use bevy_ecs::{ change_detection::Tick, reflect::{AppTypeRegistry, ReflectComponent, ReflectResource}, resource::Resource, - system::Command, + system::{Command, Commands, Res, ResMut}, world::World, }; pub use bevy_ecs_macros::SettingsGroup; -use bevy_log::{info, warn}; +use bevy_log::warn; use bevy_reflect::{ prelude::ReflectDefault, serde::{TypedReflectDeserializer, TypedReflectSerializer}, @@ -25,6 +26,7 @@ mod store_fs; #[cfg(target_arch = "wasm32")] mod store_wasm; +use bevy_time::{Time, Timer, TimerMode}; use serde::de::DeserializeSeed; #[cfg(not(target_arch = "wasm32"))] use store_fs::PreferencesStore; @@ -52,7 +54,9 @@ impl PreferencesPlugin { } impl Plugin for PreferencesPlugin { - fn build(&self, _app: &mut App) {} + fn build(&self, app: &mut App) { + app.add_systems(PostUpdate, handle_delayed_save); + } } /// Trait which identifies a type as corresponding to a section with a settings file. @@ -103,8 +107,14 @@ struct PreferenceFileManifest { /// are associated with which resource types. #[derive(Resource)] struct PreferencesFileRegistry { + /// App name (from plugin) app_name: String, + + /// List of known preferences files, determined by scanning reflection registry. files: HashMap<&'static str, PreferenceFileManifest>, + + /// Timer used for batched saving. + save_timer: Timer, } /// A Command which saves preferences to disk. This blocks the command queue until saving @@ -124,55 +134,6 @@ impl Command for SavePreferencesSync { } } -fn resources_to_toml( - world: &World, - types: &TypeRegistry, - manifest: &PreferenceFileManifest, -) -> toml::map::Map { - let mut table = toml::Table::new(); - for tid in manifest.resource_types.iter() { - let ty = types.get(*tid).unwrap(); - let Some(cmp) = ty.data::() else { - continue; - }; - let Some(reflect_settings_group) = ty.data::() else { - continue; - }; - - let group = reflect_settings_group.settings_group_name; - - let Some(component_id) = world.components().get_id(*tid) else { - continue; - }; - - let Some(res_entity) = world.resource_entities().get(component_id) else { - continue; - }; - let res_entity_ref = world.entity(*res_entity); - let Some(reflect) = cmp.reflect(res_entity_ref) else { - continue; - }; - - let serializer = TypedReflectSerializer::new(reflect.as_partial_reflect(), types); - let toml_value = toml::Value::try_from(serializer).unwrap(); - match ( - toml_value.as_table(), - table.get_mut(group).and_then(|value| value.as_table_mut()), - ) { - (Some(from), Some(to)) => { - // Merge the tables - for (key, value) in from.iter() { - to.insert(key.to_string(), value.clone()); - } - } - _ => { - table.insert(group.to_string(), toml_value); - } - }; - } - table -} - /// A Command which saves preferences to disk. Actual FS operations happen in another thread. #[derive(Default, PartialEq)] pub enum SavePreferences { @@ -189,6 +150,30 @@ impl Command for SavePreferences { } } +/// A Command which saves changed preferences after a delay. Issuing this command multiple times +/// resets the delay timer each time. This is meant to be used for settings which change at +/// a high frequency, such as dragging a slider which controls the game's audio volume. The default +/// delay is 1.0 seconds. +pub struct SavePreferencesDeferred(pub Duration); + +impl Default for SavePreferencesDeferred { + fn default() -> Self { + Self(Duration::from_secs(1)) + } +} + +impl Command for SavePreferencesDeferred { + fn apply(self, world: &mut World) { + let Some(mut registry) = world.get_resource_mut::() else { + return; + }; + + registry.save_timer.set_duration(self.0); + registry.save_timer.reset(); + registry.save_timer.unpause(); + } +} + fn save_preferences(world: &mut World, use_async: bool, force: bool) { let this_run = world.change_tick(); let Some(registry) = world.get_resource::() else { @@ -205,7 +190,6 @@ fn save_preferences(world: &mut World, use_async: bool, force: bool) { if force || has_preferences_changed(world, manifest) { let table = resources_to_toml(world, &types, manifest); let store = PreferencesStore::new(®istry.app_name); - info!("Saving settings {filename}"); if use_async { store.save_async(filename, table); } else { @@ -234,6 +218,55 @@ fn has_preferences_changed(world: &World, manifest: &PreferenceFileManifest) -> }) } +fn resources_to_toml( + world: &World, + types: &TypeRegistry, + manifest: &PreferenceFileManifest, +) -> toml::map::Map { + let mut table = toml::Table::new(); + for tid in manifest.resource_types.iter() { + let ty = types.get(*tid).unwrap(); + let Some(cmp) = ty.data::() else { + continue; + }; + let Some(reflect_settings_group) = ty.data::() else { + continue; + }; + + let group = reflect_settings_group.settings_group_name; + + let Some(component_id) = world.components().get_id(*tid) else { + continue; + }; + + let Some(res_entity) = world.resource_entities().get(component_id) else { + continue; + }; + let res_entity_ref = world.entity(*res_entity); + let Some(reflect) = cmp.reflect(res_entity_ref) else { + continue; + }; + + let serializer = TypedReflectSerializer::new(reflect.as_partial_reflect(), types); + let toml_value = toml::Value::try_from(serializer).unwrap(); + match ( + toml_value.as_table(), + table.get_mut(group).and_then(|value| value.as_table_mut()), + ) { + (Some(from), Some(to)) => { + // Merge the tables + for (key, value) in from.iter() { + to.insert(key.to_string(), value.clone()); + } + } + _ => { + table.insert(group.to_string(), toml_value); + } + }; + } + table +} + /// Extension trait that implements loading of preferences into the application. /// /// This needs to be called before `app.build()` so that preference values will be available @@ -267,7 +300,9 @@ impl LoadPreferences for App { let mut file_index = PreferencesFileRegistry { app_name: plugin.app_name.clone(), files: HashMap::new(), + save_timer: Timer::new(Duration::from_secs(1), TimerMode::Once), }; + file_index.save_timer.pause(); // Ensure timer is initially paused // Scan through types looking for resources that have the necessary traits and // annotations. @@ -384,3 +419,14 @@ fn load_properties(value: &toml::Value, resource: &mut dyn PartialReflect, types } } } + +fn handle_delayed_save( + mut preferences: ResMut, + time: Res