diff --git a/Cargo.toml b/Cargo.toml index 27470ce17b2a8..319b61dfc76c8 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_settings = ["bevy_internal/bevy_settings"] + # Feathers widget collection. experimental_bevy_feathers = ["bevy_internal/bevy_feathers", "bevy_ui_widgets"] @@ -5147,6 +5150,30 @@ 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_settings"] + +[package.metadata.example.prefs_counter] +name = "User Preferences" +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_settings"] + +[package.metadata.example.prefs_window] +name = "Save Window Position" +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/crates/bevy_ecs/macros/src/lib.rs b/crates/bevy_ecs/macros/src/lib.rs index 6c830203ffce8..34476f625da3c 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}; @@ -567,6 +569,62 @@ pub fn derive_resource(input: TokenStream) -> TokenStream { component::derive_resource(input) } +/// Implement the `SettingsGroup` trait. +#[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 (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 + } + } + }; + + TokenStream::from(expanded) +} + /// Cheat sheet for derive syntax, /// see full explanation and examples on the `Component` trait doc. /// diff --git a/crates/bevy_internal/Cargo.toml b/crates/bevy_internal/Cargo.toml index 3074556138c0d..9840056b62ecb 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_settings = { path = "../bevy_settings", 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..859ebec55307c 100644 --- a/crates/bevy_internal/src/lib.rs +++ b/crates/bevy_internal/src/lib.rs @@ -78,6 +78,8 @@ pub use bevy_remote as remote; pub use bevy_render as render; #[cfg(feature = "bevy_scene")] pub use bevy_scene as scene; +#[cfg(feature = "bevy_settings")] +pub use bevy_settings as settings; #[cfg(feature = "bevy_shader")] pub use bevy_shader as shader; #[cfg(feature = "bevy_solari")] 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"); + } +} diff --git a/crates/bevy_settings/Cargo.toml b/crates/bevy_settings/Cargo.toml new file mode 100644 index 0000000000000..b95d09430c8bc --- /dev/null +++ b/crates/bevy_settings/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "bevy_settings" +version = "0.19.0-dev" +edition = "2024" +description = "User settings 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_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" } +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" +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_settings/src/lib.rs b/crates/bevy_settings/src/lib.rs new file mode 100644 index 0000000000000..9919902a4a480 --- /dev/null +++ b/crates/bevy_settings/src/lib.rs @@ -0,0 +1,716 @@ +//! Framework for saving and loading user settings files (e.g. user preferences) in Bevy +//! applications. +//! +//! For purposes of this crate, the term "preferences" and "settings" are defined as: +//! * **Preferences** are configuration files that store persistent choices made by the end user +//! while the app is running. Examples are audio volume, window position, or "show the tutorial". +//! A key distinction is that these configuration files are consumed and produced by the same app. +//! * **Settings** is a more general term, which also includes configuration files produced by a +//! different application, such as a text editor or external settings app. +use core::any::TypeId; +use core::time::Duration; +use std::collections::HashMap; + +use bevy_app::{App, Plugin, PostUpdate}; +use bevy_ecs::{ + change_detection::Tick, + reflect::{AppTypeRegistry, ReflectComponent, ReflectResource}, + resource::Resource, + system::{Command, Commands, Res, ResMut}, + world::World, +}; +pub use bevy_ecs_macros::SettingsGroup; +use bevy_log::warn; +use bevy_reflect::{ + prelude::ReflectDefault, + serde::{TypedReflectDeserializer, TypedReflectSerializer}, + FromReflect, FromType, PartialReflect, ReflectMut, TypeInfo, TypePath, TypeRegistration, + TypeRegistry, +}; + +#[cfg(not(target_arch = "wasm32"))] +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; + +#[cfg(target_arch = "wasm32")] +use store_wasm::PreferencesStore; + +/// Plugin to orchestrate loading and saving of user preferences. +/// +/// You are required to provide a unique application name, so that your preferences don't overwrite +/// those of other apps. To ensure global uniqueness, it is recommended to use a reverse domain +/// name, e.g. "com.example.myapp". The plugin will create a directory with that name in the +/// appropriate filesystem location (depending on platform) for app preferences. For platforms +/// without filesystems, other storage mechanisms will be used. +/// +/// If you are in the unfortunate position where you do not have a domain name and cannot +/// afford one, use a reverse domain based on the URL of your repo (GitHub, GitLab, Codeberg +/// and so on). +/// +/// Adding this plugin causes an immediate load of preferences (from either the filesystem or +/// browser local storage, depending on platform). +/// +/// When using this plugin, care must be taken to ensure that plugins execute in the proper order. +/// Loading preferences causes registered settings to be inserted into the world as bevy resources. +/// You cannot access these values before they are loaded, but you may want to use the loaded values +/// when configuring other plugins. For this reason, it's generally a good idea to initialize and +/// load preferences before other plugins. The preferences plugin does not depend on any other +/// plugins. +/// +/// In many cases, you may want to introduce additional "glue" plugins that copy preference +/// properties after they are loaded. For example, the +/// [`WindowPlugin`](https://docs.rs/bevy/latest/bevy/prelude/struct.WindowPlugin.html) plugin knows +/// nothing about preferences, but if you want the window size and position to persist between runs +/// you can add an additional plugin which copies the window settings from the resource to the +/// actual window entity. +/// +/// Saving of preferences is not automatic; the recommended practice is to issue a +/// [`SavePreferencesDeferred`] command after modifying a settings resource. This will wait for +/// a short interval and then spawn an i/o task to write out the changed settings file. You can +/// also issue a [`SavePreferencesSync::IfChanged`] command immediately before exiting the app. +/// Note that on some platforms, depending on how the user exits (such as invoking Command-Q on +/// ``MacOS``) there may be no opportunity to intercept the app exit event, so the most reliable +/// approach is to use both techniques: deferred save and save-on-exit. +/// +/// Saving is crash-resistant: if the app crashes in the middle of a save, the preferences file +/// will not be corrupted (it writes to a temporary file first, then uses atomic operations to +/// replace the previous file). +pub struct PreferencesPlugin { + /// The unique name of the application. + pub app_name: String, +} + +impl PreferencesPlugin { + /// Construct a new `PreferencesPlugin` for the given application name. + pub fn new(app_name: &str) -> Self { + Self { + app_name: app_name.to_string(), + } + } +} + +impl Plugin for PreferencesPlugin { + fn build(&self, app: &mut App) { + let app_name = self.app_name.clone(); + let world = app.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; + }; + let app_types = app_types.clone(); + let types = app_types.read(); + + let world = app.world_mut(); + let file_index = build_preferences_registry(&app_name, &types, last_save); + + // Now load each of the toml files we discovered, and apply their properties to + // the resources in the world. + for (filename, manifest) in file_index.files.iter() { + load_settings_file(world, &app_name, filename, manifest, &types); + } + + // 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); + + app.add_systems(PostUpdate, handle_delayed_save); + } +} + +/// 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 = "")`. +/// The name should be in ``snake_case`` to be consistent with TOML style. +/// If there is a collision between names (multiple resources have the same name) then +/// the resulting properties will be merged into a single section. +/// +/// You can also control which file the type gets saved to via +/// `settings_group(file = "")`. This should be the base name of the file without the +/// extension. The default name is `settings`, which will cause the preferences to be written out +/// to `settings.toml` in the app's preferences directory. +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`]. +#[derive(Clone)] +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(), + } + } + + 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. +#[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 (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 +/// is complete. +#[derive(Default, PartialEq)] +pub enum SavePreferencesSync { + /// Save preferences only if they have changed since the most recent load or save. + #[default] + IfChanged, + /// Save preferences unconditionally. + Always, +} + +impl Command for SavePreferencesSync { + fn apply(self, world: &mut World) { + save_preferences(world, false, self == SavePreferencesSync::Always); + } +} + +/// 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 since the most recent load or save. + #[default] + IfChanged, + /// Save preferences unconditionally. + Always, +} + +impl Command for SavePreferences { + fn apply(self, world: &mut World) { + save_preferences(world, true, self == SavePreferences::Always); + } +} + +/// 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 { + warn!("Preferences registry not found - did you forget to install the PreferencesPlugin?"); + 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() { + if force || has_preferences_changed(world, manifest) { + 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; + } +} + +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 + }) +} + +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 +} + +/// Builds the preferences file registry by scanning the type registry for settings resources. +/// This is separated from loading to enable testing without file I/O. +/// +/// Returns the `PreferencesFileRegistry` that tracks which resources are associated with +/// which settings files. +fn build_preferences_registry( + app_name: &str, + types: &TypeRegistry, + last_save: Tick, +) -> PreferencesFileRegistry { + // 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: app_name.to_string(), + 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. + for ty in types.iter() { + if !ty.contains::() { + continue; + }; + + 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()); + } + + file_index +} + +/// Loads a single settings file and applies its values to the world's resources. +fn load_settings_file( + world: &mut World, + app_name: &str, + filename: &str, + manifest: &PreferenceFileManifest, + types: &TypeRegistry, +) { + // 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"); + } + + apply_settings_to_world(world, toml.as_ref(), manifest, types); +} + +/// Applies settings from a TOML table to the world's resources. +/// This is separated from file loading to enable testing without filesystem access. +/// +/// For each resource type in the manifest, this function either: +/// - Updates an existing resource with values from the TOML, or +/// - Creates a new resource with default values merged with TOML values +fn apply_settings_to_world( + world: &mut World, + toml: Option<&toml::Table>, + manifest: &PreferenceFileManifest, + types: &TypeRegistry, +) { + for tid in manifest.resource_types.iter() { + let ty = types.get(*tid).unwrap(); + 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)); + + 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(toml) = toml + && let Some(value) = toml.get(group) + { + load_properties(value, &mut *reflect, types); + } + } else { + // The resource does not exist, so create a default. + let reflect_default = ty.data::().unwrap(); + let mut default_value = reflect_default.default(); + let mut res_entity = world.spawn_empty(); + + if let Some(toml) = toml + && let Some(value) = toml.get(group) + { + 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(), types); + } + } +} + +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); + } + } + } + } +} + +fn handle_delayed_save( + mut preferences: ResMut, + time: Res