From 020c681f8d8d8e0874d688a0240eff35385b60f0 Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Thu, 9 May 2024 21:44:46 -0700 Subject: [PATCH 1/6] Add simple preferences API --- crates/bevy_app/src/lib.rs | 2 + crates/bevy_app/src/preferences.rs | 165 +++++++++++++++++++++++++++++ 2 files changed, 167 insertions(+) create mode 100644 crates/bevy_app/src/preferences.rs diff --git a/crates/bevy_app/src/lib.rs b/crates/bevy_app/src/lib.rs index e338a45664609..6c73883c47265 100644 --- a/crates/bevy_app/src/lib.rs +++ b/crates/bevy_app/src/lib.rs @@ -12,6 +12,7 @@ mod main_schedule; mod panic_handler; mod plugin; mod plugin_group; +mod preferences; mod schedule_runner; mod sub_app; @@ -21,6 +22,7 @@ pub use main_schedule::*; pub use panic_handler::*; pub use plugin::*; pub use plugin_group::*; +pub use preferences::*; pub use schedule_runner::*; pub use sub_app::*; diff --git a/crates/bevy_app/src/preferences.rs b/crates/bevy_app/src/preferences.rs new file mode 100644 index 0000000000000..4434d259c2905 --- /dev/null +++ b/crates/bevy_app/src/preferences.rs @@ -0,0 +1,165 @@ +use bevy_ecs::system::Resource; +use bevy_reflect::{Map, Reflect, TypePath}; + +use crate::Plugin; + +/// Adds application [`Preferences`] functionality. +pub struct PreferencesPlugin; + +impl Plugin for PreferencesPlugin { + fn build(&self, app: &mut crate::App) { + app.init_resource::(); + } +} + +/// A map that stores all application preferences. +/// +/// Preferences are strongly typed, and defined independently by any `Plugin` that needs persistent +/// settings. Choice of serialization format and behavior is up to the application developer. The +/// preferences resource simply provides a common API surface to consolidate preferences for all +/// plugins in one location. +/// +/// ### Usage +/// +/// Preferences only require that the type implements [`Reflect`]. +/// +/// ``` +/// # use bevy_reflect::Reflect; +/// #[derive(Reflect)] +/// struct MyPluginPreferences { +/// do_things: bool, +/// fizz_buzz_count: usize +/// } +/// ``` +/// You can [`Self::get`] or [`Self::set`] preferences by accessing this type as a [`Resource`] +/// ``` +/// # use bevy_ecs::prelude::*; +/// # use bevy_app::Preferences; +/// # use bevy_reflect::Reflect; +/// # +/// # #[derive(Reflect)] +/// # struct MyPluginPreferences { +/// # do_things: bool, +/// # fizz_buzz_count: usize +/// # } +/// # +/// fn update(mut prefs: ResMut) { +/// let settings = MyPluginPreferences { +/// do_things: false, +/// fizz_buzz_count: 9000, +/// }; +/// prefs.set(settings); +/// +/// // Accessing preferences only requires the type: +/// let mut new_settings = prefs.get::().unwrap(); +/// +/// // If you are updating an existing struct, all type information can be inferred: +/// new_settings = prefs.get().unwrap(); +/// } +/// ``` +#[derive(Resource, Default, Debug)] +pub struct Preferences { + map: bevy_reflect::DynamicMap, +} + +impl Preferences { + /// Set preferences of type `P`. + pub fn set(&mut self, value: P) { + self.map.insert(P::short_type_path(), value); + } + + /// Get preferences of type `P`. + pub fn get(&self) -> Option<&P> { + let key = P::short_type_path(); + self.map + .get(key.as_reflect()) + .and_then(|val| val.downcast_ref()) + } +} + +#[cfg(test)] +mod tests { + use bevy_ecs::system::ResMut; + use bevy_reflect::{Map, Reflect}; + + use crate::{App, PreferencesPlugin, Startup}; + + use super::Preferences; + + #[test] + fn typed_get() { + #[derive(Reflect, Clone, PartialEq, Debug)] + struct FooPrefsV1 { + name: String, + } + + #[derive(Reflect, Clone, PartialEq, Debug)] + struct FooPrefsV2 { + name: String, + age: usize, + } + + let mut preferences = Preferences::default(); + + let v1 = FooPrefsV1 { + name: "Bevy".into(), + }; + + let v2 = FooPrefsV2 { + name: "Boovy".into(), + age: 42, + }; + + preferences.set(v1.clone()); + preferences.set(v2.clone()); + assert_eq!(preferences.get::(), Some(&v1)); + assert_eq!(preferences.get::(), Some(&v2)); + } + + #[test] + fn overwrite() { + #[derive(Reflect, Clone, PartialEq, Debug)] + struct FooPrefs(String); + + let mut preferences = Preferences::default(); + + let bevy = FooPrefs("Bevy".into()); + let boovy = FooPrefs("Boovy".into()); + + preferences.set(bevy.clone()); + preferences.set(boovy.clone()); + assert_eq!(preferences.get::(), Some(&boovy)); + } + + #[test] + fn init_exists() { + #[derive(Reflect, Clone, PartialEq, Debug)] + struct FooPrefs(String); + + let mut app = App::new(); + app.add_plugins(PreferencesPlugin); + app.update(); + assert!(app.world().resource::().map.is_empty()); + } + + #[test] + fn startup_sets_value() { + #[derive(Reflect, Clone, PartialEq, Debug)] + struct FooPrefs(String); + + let mut app = App::new(); + app.add_plugins(PreferencesPlugin); + app.add_systems(Startup, |mut prefs: ResMut| { + prefs.set(FooPrefs("Initial".into())); + }); + app.update(); + assert_eq!( + app.world() + .resource::() + .get::() + .unwrap() + .0, + "Initial" + ); + } +} From 19f8c0f4e7fff94c1a5b18f94b79756cfc7c0745 Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Thu, 9 May 2024 21:56:25 -0700 Subject: [PATCH 2/6] Add a `get_mut` method to `Preferences`. --- crates/bevy_app/src/preferences.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/bevy_app/src/preferences.rs b/crates/bevy_app/src/preferences.rs index 4434d259c2905..df38a1dc1e13e 100644 --- a/crates/bevy_app/src/preferences.rs +++ b/crates/bevy_app/src/preferences.rs @@ -75,6 +75,14 @@ impl Preferences { .get(key.as_reflect()) .and_then(|val| val.downcast_ref()) } + + /// Get a mutable reference to preferences of type `P`. + pub fn get_mut(&mut self) -> Option<&mut P> { + let key = P::short_type_path(); + self.map + .get_mut(key.as_reflect()) + .and_then(|val| val.downcast_mut()) + } } #[cfg(test)] From 7cbba20625b913988965ee9119efa2a0bb3dc0bb Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Fri, 10 May 2024 00:52:01 -0700 Subject: [PATCH 3/6] Add `serialization_round_trip` test --- crates/bevy_app/Cargo.toml | 4 ++ crates/bevy_app/src/preferences.rs | 99 +++++++++++++++++++++++++++++- 2 files changed, 100 insertions(+), 3 deletions(-) diff --git a/crates/bevy_app/Cargo.toml b/crates/bevy_app/Cargo.toml index 64333bfc9846c..7cb609f575332 100644 --- a/crates/bevy_app/Cargo.toml +++ b/crates/bevy_app/Cargo.toml @@ -28,6 +28,10 @@ serde = { version = "1.0", features = ["derive"], optional = true } downcast-rs = "1.2.0" thiserror = "1.0" +[dev-dependencies] +serde_json = "1.0" +serde = "1.0" + [target.'cfg(target_arch = "wasm32")'.dependencies] wasm-bindgen = { version = "0.2" } web-sys = { version = "0.3", features = ["Window"] } diff --git a/crates/bevy_app/src/preferences.rs b/crates/bevy_app/src/preferences.rs index df38a1dc1e13e..f772a6928fb15 100644 --- a/crates/bevy_app/src/preferences.rs +++ b/crates/bevy_app/src/preferences.rs @@ -64,13 +64,20 @@ pub struct Preferences { impl Preferences { /// Set preferences of type `P`. - pub fn set(&mut self, value: P) { - self.map.insert(P::short_type_path(), value); + pub fn set(&mut self, value: P) { + let path = value.reflect_short_type_path().to_string(); + self.map.insert(path, value); + } + + /// Set preferences from a boxed trait object of unknown type. + pub fn set_dyn(&mut self, value: Box) { + let path = value.reflect_short_type_path().to_string(); + self.map.insert_boxed(Box::new(path), value); } /// Get preferences of type `P`. pub fn get(&self) -> Option<&P> { - let key = P::short_type_path(); + let key = P::short_type_path().to_string(); self.map .get(key.as_reflect()) .and_then(|val| val.downcast_ref()) @@ -83,12 +90,18 @@ impl Preferences { .get_mut(key.as_reflect()) .and_then(|val| val.downcast_mut()) } + + /// Iterator over all preference entries as [`Reflect`] trait objects. + pub fn iter_reflect(&self) -> impl Iterator { + self.map.iter().map(|(_k, v)| v) + } } #[cfg(test)] mod tests { use bevy_ecs::system::ResMut; use bevy_reflect::{Map, Reflect}; + use serde_json::Value; use crate::{App, PreferencesPlugin, Startup}; @@ -170,4 +183,84 @@ mod tests { "Initial" ); } + + #[derive(Reflect, PartialEq, Debug)] + struct Foo(usize); + + #[derive(Reflect, PartialEq, Debug)] + struct Bar(String); + + fn get_registry() -> bevy_reflect::TypeRegistry { + let mut registry = bevy_reflect::TypeRegistry::default(); + registry.register::(); + registry.register::(); + registry + } + + #[test] + fn serialization_round_trip() { + use bevy_reflect::serde::ReflectDeserializer; + use serde::{de::DeserializeSeed, Serialize}; + + let mut preferences = Preferences::default(); + preferences.set(Foo(42)); + preferences.set(Bar("Bevy".into())); + + let mut output = String::new(); + output.push('['); + let registry = get_registry(); + + for value in preferences.iter_reflect() { + let serializer = bevy_reflect::serde::ReflectSerializer::new(value, ®istry); + let mut buf = Vec::new(); + let format = serde_json::ser::PrettyFormatter::with_indent(b" "); + let mut ser = serde_json::Serializer::with_formatter(&mut buf, format); + serializer.serialize(&mut ser).unwrap(); + + let value_output = std::str::from_utf8(&buf).unwrap(); + output.push_str(value_output); + output.push(','); + } + output.pop(); + output.push(']'); + + let expected = r#"[{ + "bevy_app::preferences::tests::Foo": [ + 42 + ] +},{ + "bevy_app::preferences::tests::Bar": [ + "Bevy" + ] +}]"#; + assert_eq!(expected, output); + + // Reset preferences and attempt to round-trip the data. + + let mut preferences = Preferences::default(); + assert!(preferences.map.is_empty()); + + let json: Value = serde_json::from_str(&output).unwrap(); + let entries = json.as_array().unwrap(); + + for entry in entries { + // Convert back to a string and re-deserialize. Is there an easier way? + let entry = entry.to_string(); + let mut deserializer = serde_json::Deserializer::from_str(&entry); + + let reflect_deserializer = ReflectDeserializer::new(®istry); + let output: Box = + reflect_deserializer.deserialize(&mut deserializer).unwrap(); + let type_id = output.get_represented_type_info().unwrap().type_id(); + let reflect_from_reflect = registry + .get_type_data::(type_id) + .unwrap(); + let value: Box = reflect_from_reflect.from_reflect(&*output).unwrap(); + dbg!(&value); + preferences.set_dyn(value); + } + + assert_eq!(preferences.get(), Some(&Foo(42))); + assert_eq!(preferences.get(), Some(&Bar("Bevy".into()))); + } } From 1f7131ee3c05fed2aada8a44dcde79edeae1c272 Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Fri, 10 May 2024 02:00:33 -0700 Subject: [PATCH 4/6] Add more tests and docs --- crates/bevy_app/src/preferences.rs | 107 +++++++++++++++-------------- 1 file changed, 57 insertions(+), 50 deletions(-) diff --git a/crates/bevy_app/src/preferences.rs b/crates/bevy_app/src/preferences.rs index f772a6928fb15..ca717750d9182 100644 --- a/crates/bevy_app/src/preferences.rs +++ b/crates/bevy_app/src/preferences.rs @@ -12,7 +12,7 @@ impl Plugin for PreferencesPlugin { } } -/// A map that stores all application preferences. +/// A map storing all application preferences. /// /// Preferences are strongly typed, and defined independently by any `Plugin` that needs persistent /// settings. Choice of serialization format and behavior is up to the application developer. The @@ -21,7 +21,7 @@ impl Plugin for PreferencesPlugin { /// /// ### Usage /// -/// Preferences only require that the type implements [`Reflect`]. +/// Preferences only require that a type being added implements [`Reflect`]. /// /// ``` /// # use bevy_reflect::Reflect; @@ -57,8 +57,23 @@ impl Plugin for PreferencesPlugin { /// new_settings = prefs.get().unwrap(); /// } /// ``` +/// +/// ### Serialization +/// +/// The preferences map is build on `bevy_reflect`. This makes it possible to serialize preferences +/// into a dynamic structure, and deserialize it back into this map, while retaining a +/// strongly-typed API. Because it uses `serde`, `Preferences` can be read ad written to any format. +/// +/// To build a storage backend, use [`Self::iter_reflect`] to get an iterator of `reflect`able trait +/// objects that can be serialized. To load serialized data into the preferences, use +/// `ReflectDeserializer` on each object to convert them into `Box` trait objects, +/// which you can then load into this resource using [`Preferences::set_dyn`]. #[derive(Resource, Default, Debug)] pub struct Preferences { + // Note the key is only used while the struct is in memory so we can quickly look up a value. + // The key itself does not need to be dynamic. This `DynamicMap` could be replaced with a custom + // built data structure to (potentially) improve lookup performance, however it functions + // perfectly fine for now. map: bevy_reflect::DynamicMap, } @@ -85,7 +100,7 @@ impl Preferences { /// Get a mutable reference to preferences of type `P`. pub fn get_mut(&mut self) -> Option<&mut P> { - let key = P::short_type_path(); + let key = P::short_type_path().to_string(); self.map .get_mut(key.as_reflect()) .and_then(|val| val.downcast_mut()) @@ -95,6 +110,14 @@ impl Preferences { pub fn iter_reflect(&self) -> impl Iterator { self.map.iter().map(|(_k, v)| v) } + + /// Remove and return an entry from preferences, if it exists. + pub fn remove(&mut self) -> Option> { + let key = P::short_type_path().to_string(); + self.map + .remove(key.as_reflect()) + .and_then(|val| val.downcast().ok()) + } } #[cfg(test)] @@ -107,49 +130,46 @@ mod tests { use super::Preferences; - #[test] - fn typed_get() { - #[derive(Reflect, Clone, PartialEq, Debug)] - struct FooPrefsV1 { - name: String, - } + #[derive(Reflect, PartialEq, Debug)] + struct Foo(usize); - #[derive(Reflect, Clone, PartialEq, Debug)] - struct FooPrefsV2 { - name: String, - age: usize, - } + #[derive(Reflect, PartialEq, Debug)] + struct Bar(String); - let mut preferences = Preferences::default(); + fn get_registry() -> bevy_reflect::TypeRegistry { + let mut registry = bevy_reflect::TypeRegistry::default(); + registry.register::(); + registry.register::(); + registry + } - let v1 = FooPrefsV1 { - name: "Bevy".into(), - }; + #[test] + fn setters_and_getters() { + let mut preferences = Preferences::default(); - let v2 = FooPrefsV2 { - name: "Boovy".into(), - age: 42, - }; + // Set initial value + preferences.set(Foo(36)); + assert_eq!(preferences.get::().unwrap().0, 36); - preferences.set(v1.clone()); - preferences.set(v2.clone()); - assert_eq!(preferences.get::(), Some(&v1)); - assert_eq!(preferences.get::(), Some(&v2)); - } + // Overwrite with set + preferences.set(Foo(500)); + assert_eq!(preferences.get::().unwrap().0, 500); - #[test] - fn overwrite() { - #[derive(Reflect, Clone, PartialEq, Debug)] - struct FooPrefs(String); + // Overwrite with get_mut + *preferences.get_mut().unwrap() = Foo(12); + assert_eq!(preferences.get::().unwrap().0, 12); - let mut preferences = Preferences::default(); + // Add new type of preference + assert!(preferences.get::().is_none()); + preferences.set(Bar("Bevy".into())); + assert_eq!(preferences.get::().unwrap().0, "Bevy"); - let bevy = FooPrefs("Bevy".into()); - let boovy = FooPrefs("Boovy".into()); + // Add trait object + preferences.set_dyn(Box::new(Bar("Boovy".into()))); + assert_eq!(preferences.get::().unwrap().0, "Boovy"); - preferences.set(bevy.clone()); - preferences.set(boovy.clone()); - assert_eq!(preferences.get::(), Some(&boovy)); + // Remove a preference + assert_eq!(*preferences.remove::().unwrap(), Foo(12)); } #[test] @@ -184,19 +204,6 @@ mod tests { ); } - #[derive(Reflect, PartialEq, Debug)] - struct Foo(usize); - - #[derive(Reflect, PartialEq, Debug)] - struct Bar(String); - - fn get_registry() -> bevy_reflect::TypeRegistry { - let mut registry = bevy_reflect::TypeRegistry::default(); - registry.register::(); - registry.register::(); - registry - } - #[test] fn serialization_round_trip() { use bevy_reflect::serde::ReflectDeserializer; From 79c3a07bdeb0fad7988befc29970cbb0de0e310a Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Fri, 10 May 2024 02:24:17 -0700 Subject: [PATCH 5/6] Make doc wording more consistent --- crates/bevy_app/src/preferences.rs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/bevy_app/src/preferences.rs b/crates/bevy_app/src/preferences.rs index ca717750d9182..b819c72ec6506 100644 --- a/crates/bevy_app/src/preferences.rs +++ b/crates/bevy_app/src/preferences.rs @@ -78,19 +78,19 @@ pub struct Preferences { } impl Preferences { - /// Set preferences of type `P`. + /// Set preferences entry of type `P`, potentially overwriting an existing entry. pub fn set(&mut self, value: P) { let path = value.reflect_short_type_path().to_string(); self.map.insert(path, value); } - /// Set preferences from a boxed trait object of unknown type. + /// Set preferences entry from a boxed trait object of unknown type. pub fn set_dyn(&mut self, value: Box) { let path = value.reflect_short_type_path().to_string(); self.map.insert_boxed(Box::new(path), value); } - /// Get preferences of type `P`. + /// Get preferences entry of type `P`. pub fn get(&self) -> Option<&P> { let key = P::short_type_path().to_string(); self.map @@ -98,7 +98,7 @@ impl Preferences { .and_then(|val| val.downcast_ref()) } - /// Get a mutable reference to preferences of type `P`. + /// Get a mutable reference to a preferences entry of type `P`. pub fn get_mut(&mut self) -> Option<&mut P> { let key = P::short_type_path().to_string(); self.map From 72a1dd3bff055ec6dd1b8bd72a409e0d4722fd0c Mon Sep 17 00:00:00 2001 From: Aevyrie Date: Fri, 10 May 2024 09:52:11 -0700 Subject: [PATCH 6/6] Tidy up serialization round tripping unit test. --- crates/bevy_app/src/preferences.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/bevy_app/src/preferences.rs b/crates/bevy_app/src/preferences.rs index b819c72ec6506..de5eb13b6a5aa 100644 --- a/crates/bevy_app/src/preferences.rs +++ b/crates/bevy_app/src/preferences.rs @@ -209,13 +209,16 @@ mod tests { use bevy_reflect::serde::ReflectDeserializer; use serde::{de::DeserializeSeed, Serialize}; + let registry = get_registry(); let mut preferences = Preferences::default(); preferences.set(Foo(42)); preferences.set(Bar("Bevy".into())); + // Manually turn this into a valid JSON map. There is almost certainly a better way to + // express this if we want to make this part of the `Preferences` API as a blessed way to + // assemble a preferences file, but this is enough to get the file round tripping. let mut output = String::new(); output.push('['); - let registry = get_registry(); for value in preferences.iter_reflect() { let serializer = bevy_reflect::serde::ReflectSerializer::new(value, ®istry); @@ -226,10 +229,10 @@ mod tests { let value_output = std::str::from_utf8(&buf).unwrap(); output.push_str(value_output); - output.push(','); + output.push(','); // Again, manual JSON map } - output.pop(); - output.push(']'); + output.pop(); // Remove trailing comma + output.push(']'); // Close manual JSON map let expected = r#"[{ "bevy_app::preferences::tests::Foo": [ @@ -263,7 +266,6 @@ mod tests { .get_type_data::(type_id) .unwrap(); let value: Box = reflect_from_reflect.from_reflect(&*output).unwrap(); - dbg!(&value); preferences.set_dyn(value); }