Import Oxide engine (Stages 0–10) under MIT license
Full project snapshot migrated to new Gitea remote without history: engine, editor, physics, script, examples, tests, docs, and assets. Relicensed from GPLv3 to MIT and updated repo URLs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
//! The settings / preferences framework.
|
||||
//!
|
||||
//! A unified, serialized configuration store shared by the engine, the editor,
|
||||
//! and modules. Each contributor registers a typed **section** (a plain
|
||||
//! `serde`-serializable struct) under a name; the framework persists every
|
||||
//! section to RON and restores it, without any central code knowing the
|
||||
//! sections' shapes. This is what lets:
|
||||
//!
|
||||
//! - **engine** preferences (render/quality defaults),
|
||||
//! - **editor** preferences (theme, layout, shortcuts), and
|
||||
//! - **per-module** settings (each module's own options)
|
||||
//!
|
||||
//! all live in one place, while a [`Project`](crate::project::Project) persists
|
||||
//! the per-project subset (it stores section → RON blobs that line up exactly
|
||||
//! with [`Settings::export`]/[`Settings::import`]).
|
||||
//!
|
||||
//! ```
|
||||
//! use oxide_engine::settings::Settings;
|
||||
//! use serde::{Serialize, Deserialize};
|
||||
//!
|
||||
//! #[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
|
||||
//! struct EditorPrefs { theme: String, grid: bool }
|
||||
//!
|
||||
//! let mut settings = Settings::new();
|
||||
//! settings.register::<EditorPrefs>("editor");
|
||||
//! settings.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
//!
|
||||
//! // Persist every section to RON, and restore it later.
|
||||
//! let saved = settings.export();
|
||||
//! let mut restored = Settings::new();
|
||||
//! restored.register::<EditorPrefs>("editor");
|
||||
//! restored.import(&saved);
|
||||
//! assert_eq!(restored.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
//! ```
|
||||
|
||||
use std::any::Any;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use serde::de::DeserializeOwned;
|
||||
use serde::Serialize;
|
||||
|
||||
/// The monomorphized operations for one registered section, as plain function
|
||||
/// pointers (the closures capture nothing).
|
||||
struct SectionOps {
|
||||
value: Box<dyn Any>,
|
||||
to_ron: fn(&dyn Any) -> Option<String>,
|
||||
from_ron: fn(&str) -> Option<Box<dyn Any>>,
|
||||
default: fn() -> Box<dyn Any>,
|
||||
}
|
||||
|
||||
/// A registry of typed, serializable settings sections keyed by name.
|
||||
#[derive(Default)]
|
||||
pub struct Settings {
|
||||
sections: BTreeMap<&'static str, SectionOps>,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
/// An empty settings store.
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
/// Registers section type `T` under `name`, initialized to `T::default()`.
|
||||
/// Re-registering the same name resets it to default.
|
||||
pub fn register<T>(&mut self, name: &'static str)
|
||||
where
|
||||
T: Serialize + DeserializeOwned + Default + 'static,
|
||||
{
|
||||
self.sections.insert(
|
||||
name,
|
||||
SectionOps {
|
||||
value: Box::new(T::default()),
|
||||
to_ron: |any| any.downcast_ref::<T>().and_then(|v| ron::to_string(v).ok()),
|
||||
from_ron: |text| {
|
||||
ron::from_str::<T>(text)
|
||||
.ok()
|
||||
.map(|v| Box::new(v) as Box<dyn Any>)
|
||||
},
|
||||
default: || Box::new(T::default()) as Box<dyn Any>,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Whether a section is registered under `name`.
|
||||
pub fn is_registered(&self, name: &str) -> bool {
|
||||
self.sections.contains_key(name)
|
||||
}
|
||||
|
||||
/// The registered section names, sorted.
|
||||
pub fn names(&self) -> impl Iterator<Item = &'static str> + '_ {
|
||||
self.sections.keys().copied()
|
||||
}
|
||||
|
||||
/// Borrows section `name` as `T`, or `None` if absent or the type mismatches.
|
||||
pub fn get<T: 'static>(&self, name: &str) -> Option<&T> {
|
||||
self.sections.get(name)?.value.downcast_ref::<T>()
|
||||
}
|
||||
|
||||
/// Mutably borrows section `name` as `T`.
|
||||
pub fn get_mut<T: 'static>(&mut self, name: &str) -> Option<&mut T> {
|
||||
self.sections.get_mut(name)?.value.downcast_mut::<T>()
|
||||
}
|
||||
|
||||
/// Replaces the value of section `name`. Returns whether it was registered
|
||||
/// (with a matching type).
|
||||
pub fn set<T: 'static>(&mut self, name: &str, value: T) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
// Only overwrite if the registered type matches.
|
||||
Some(section) if section.value.is::<T>() => {
|
||||
section.value = Box::new(value);
|
||||
true
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resets section `name` to its default. Returns whether it was registered.
|
||||
pub fn reset(&mut self, name: &str) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
Some(section) => {
|
||||
section.value = (section.default)();
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes section `name` to RON, or `None` if it is not registered.
|
||||
pub fn section_ron(&self, name: &str) -> Option<String> {
|
||||
let section = self.sections.get(name)?;
|
||||
(section.to_ron)(section.value.as_ref())
|
||||
}
|
||||
|
||||
/// Loads section `name` from a RON blob, replacing its value. Returns `false`
|
||||
/// if the section is not registered or the text fails to parse.
|
||||
pub fn load_section(&mut self, name: &str, ron: &str) -> bool {
|
||||
match self.sections.get_mut(name) {
|
||||
Some(section) => match (section.from_ron)(ron) {
|
||||
Some(value) => {
|
||||
section.value = value;
|
||||
true
|
||||
}
|
||||
None => false,
|
||||
},
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Serializes every section to a `name → RON` map (the format a
|
||||
/// [`Project`](crate::project::Project) stores).
|
||||
pub fn export(&self) -> BTreeMap<String, String> {
|
||||
self.sections
|
||||
.iter()
|
||||
.filter_map(|(name, section)| {
|
||||
(section.to_ron)(section.value.as_ref()).map(|ron| (name.to_string(), ron))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Loads every matching, registered section from a `name → RON` map.
|
||||
/// Unknown sections are ignored (a module may be disabled); malformed
|
||||
/// sections are skipped, leaving their current value.
|
||||
pub fn import(&mut self, map: &BTreeMap<String, String>) {
|
||||
for (name, ron) in map {
|
||||
self.load_section(name, ron);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Serialize, Deserialize, Default, PartialEq, Debug)]
|
||||
struct EditorPrefs {
|
||||
theme: String,
|
||||
grid: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Debug)]
|
||||
struct Render {
|
||||
shadows: bool,
|
||||
msaa: u32,
|
||||
}
|
||||
impl Default for Render {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
shadows: true,
|
||||
msaa: 4,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn settings() -> Settings {
|
||||
let mut s = Settings::new();
|
||||
s.register::<EditorPrefs>("editor");
|
||||
s.register::<Render>("render");
|
||||
s
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn defaults_and_typed_access() {
|
||||
let mut s = settings();
|
||||
assert_eq!(s.names().collect::<Vec<_>>(), vec!["editor", "render"]);
|
||||
assert!(s.get::<Render>("render").unwrap().shadows);
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "dark".into();
|
||||
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "dark");
|
||||
// Wrong type → None.
|
||||
assert!(s.get::<Render>("editor").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn set_and_reset() {
|
||||
let mut s = settings();
|
||||
assert!(s.set(
|
||||
"render",
|
||||
Render {
|
||||
shadows: false,
|
||||
msaa: 8
|
||||
}
|
||||
));
|
||||
assert_eq!(s.get::<Render>("render").unwrap().msaa, 8);
|
||||
// Setting an unregistered section fails.
|
||||
assert!(!s.set("missing", 5u32));
|
||||
// Reset returns to default.
|
||||
assert!(s.reset("render"));
|
||||
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn export_import_round_trips() {
|
||||
let mut s = settings();
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().theme = "light".into();
|
||||
s.get_mut::<EditorPrefs>("editor").unwrap().grid = true;
|
||||
s.set(
|
||||
"render",
|
||||
Render {
|
||||
shadows: false,
|
||||
msaa: 2,
|
||||
},
|
||||
);
|
||||
let saved = s.export();
|
||||
|
||||
// A fresh store with the same sections restores the saved values.
|
||||
let mut restored = settings();
|
||||
restored.import(&saved);
|
||||
assert_eq!(
|
||||
restored.get::<EditorPrefs>("editor").unwrap(),
|
||||
&EditorPrefs {
|
||||
theme: "light".into(),
|
||||
grid: true
|
||||
}
|
||||
);
|
||||
assert_eq!(restored.get::<Render>("render").unwrap().msaa, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn import_ignores_unknown_and_malformed() {
|
||||
let mut s = settings();
|
||||
let mut map = BTreeMap::new();
|
||||
map.insert("editor".to_string(), "(theme:\"x\",grid:true)".to_string());
|
||||
map.insert("disabled_module".to_string(), "(whatever:1)".to_string());
|
||||
map.insert("render".to_string(), "not valid ron".to_string());
|
||||
s.import(&map);
|
||||
// Known + valid applied.
|
||||
assert_eq!(s.get::<EditorPrefs>("editor").unwrap().theme, "x");
|
||||
// Malformed left the section at its default (unchanged).
|
||||
assert_eq!(s.get::<Render>("render").unwrap(), &Render::default());
|
||||
// Unknown silently ignored.
|
||||
assert!(!s.is_registered("disabled_module"));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user