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:
Homer Simpson
2026-07-05 20:41:02 +02:00
parent 2afb56b329
commit 9eead719b0
157 changed files with 47270 additions and 2 deletions
+217
View File
@@ -0,0 +1,217 @@
//! Theme — reusable named [`VisualStyle`]s plus a default fallback.
//!
//! A [`Theme`] is what a project ships to give every UI document a consistent
//! look without hand-styling every widget. The resolution rule is a strict
//! left-to-right cascade:
//!
//! 1. Start with `theme.default` (a `VisualStyle` whose `Some` fields are the
//! project-wide defaults — body text color, border weight, …).
//! 2. If the widget specifies `theme_style: Some("button")` and the theme
//! contains a `"button"` entry, merge that on top.
//! 3. Merge the widget's per-instance `visual` on top.
//!
//! Each merge is field-by-field: a `Some` on the right replaces the field;
//! a `None` keeps what was there. The result is a single [`VisualStyle`]
//! where any field that's still `None` means "the renderer's own hard-coded
//! fallback applies" — that fallback lives in piece 4 (the 2D overlay pass).
//!
//! Why named styles instead of CSS-like selectors: it makes per-widget
//! attribution explicit in the UI document (`theme_style: "button-primary"`)
//! and keeps theme resolution constant-time per widget. CSS selectors and
//! cascading rules are a richer model but their authoring cost dwarfs what
//! Stage-8 game UIs actually need.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use super::visual::VisualStyle;
/// A named-style theme. Holds a `default` style applied to every widget plus
/// a map of named styles widgets can opt into by their `theme_style` field.
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Theme {
/// Project-wide defaults — applied first to every widget before its
/// `theme_style` and per-instance overrides.
#[serde(default, skip_serializing_if = "VisualStyle::is_empty")]
pub default: VisualStyle,
/// Named style buckets — `theme_style: "button"` on a widget pulls the
/// `"button"` entry here on top of `default`.
///
/// Stored as a `BTreeMap` (not `HashMap`) so RON output is in a
/// deterministic order — important for diff-friendly UI documents and
/// reproducible RON snapshots in tests.
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub styles: BTreeMap<String, VisualStyle>,
}
impl Theme {
/// Empty theme — no default fields, no named styles. Every widget under
/// this theme inherits only the renderer's hard-coded fallback.
pub const fn new() -> Self {
Self {
default: VisualStyle::EMPTY,
styles: BTreeMap::new(),
}
}
/// Insert (or replace) a named style. Chainable for builder-style theme
/// construction in tests and examples.
pub fn with_style(mut self, name: impl Into<String>, style: VisualStyle) -> Self {
self.styles.insert(name.into(), style);
self
}
/// Replace the project-wide default style.
pub fn with_default(mut self, default: VisualStyle) -> Self {
self.default = default;
self
}
/// Resolve the effective visual style for a widget that opts into
/// `style_ref` (if any) and provides its own `override_with` per-instance
/// fields.
///
/// Cascade: `self.default` → (`self.styles[style_ref]` if present) →
/// `override_with`. A missing named style is treated as empty (no
/// contribution) rather than an error — UI documents stay valid when a
/// theme is swapped for a smaller one mid-development.
pub fn resolve(&self, style_ref: Option<&str>, override_with: &VisualStyle) -> VisualStyle {
let mut resolved = self.default.clone();
if let Some(name) = style_ref {
if let Some(named) = self.styles.get(name) {
resolved = resolved.merged(named);
}
}
resolved.merged(override_with)
}
/// Serialize this theme to a pretty-printed RON string.
pub fn to_ron(&self) -> Result<String, ron::Error> {
ron::ser::to_string_pretty(self, ron::ser::PrettyConfig::default())
}
/// Parse a theme from a RON string.
pub fn from_ron(text: &str) -> Result<Self, ron::de::SpannedError> {
ron::de::from_str(text)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::math::Color;
use crate::ui::visual::{Border, FontRef};
fn theme_with_three_styles() -> Theme {
Theme::new()
.with_default(VisualStyle {
foreground: Some(Color::BLACK),
background: Some(Color::WHITE),
font: Some(FontRef::regular("Inter")),
font_size: Some(14.0),
..VisualStyle::EMPTY
})
.with_style(
"button",
VisualStyle {
background: Some(Color::rgb(0.85, 0.85, 0.9)),
border: Some(Border::new(Color::rgb(0.6, 0.6, 0.7), 1.0)),
corner_radius: Some(4.0),
..VisualStyle::EMPTY
},
)
.with_style(
"button-primary",
VisualStyle {
background: Some(Color::rgb(0.2, 0.4, 0.8)),
foreground: Some(Color::WHITE),
..VisualStyle::EMPTY
},
)
.with_style(
"label",
VisualStyle {
foreground: Some(Color::rgb(0.2, 0.2, 0.2)),
..VisualStyle::EMPTY
},
)
}
#[test]
fn resolve_returns_default_for_no_style_or_overrides() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(None, &VisualStyle::EMPTY);
assert_eq!(resolved.foreground, Some(Color::BLACK));
assert_eq!(resolved.background, Some(Color::WHITE));
assert_eq!(resolved.font_size, Some(14.0));
}
#[test]
fn named_style_overrides_default() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(Some("button"), &VisualStyle::EMPTY);
assert_eq!(resolved.background, Some(Color::rgb(0.85, 0.85, 0.9)));
// Foreground not set on "button" → kept from default.
assert_eq!(resolved.foreground, Some(Color::BLACK));
assert_eq!(resolved.corner_radius, Some(4.0));
}
#[test]
fn per_instance_override_takes_final_precedence() {
let theme = theme_with_three_styles();
let overlay = VisualStyle {
background: Some(Color::RED),
..VisualStyle::EMPTY
};
let resolved = theme.resolve(Some("button-primary"), &overlay);
// Per-instance background wins over the named style.
assert_eq!(resolved.background, Some(Color::RED));
// The named style's foreground (WHITE) still beats the default (BLACK).
assert_eq!(resolved.foreground, Some(Color::WHITE));
}
#[test]
fn unknown_named_style_falls_back_to_default() {
let theme = theme_with_three_styles();
let resolved = theme.resolve(Some("does-not-exist"), &VisualStyle::EMPTY);
// Same as resolve(None, &EMPTY).
assert_eq!(resolved, theme.resolve(None, &VisualStyle::EMPTY));
}
#[test]
fn theme_round_trips_through_ron() {
let theme = theme_with_three_styles();
let text = theme.to_ron().unwrap();
let decoded = Theme::from_ron(&text).unwrap();
assert_eq!(theme, decoded);
// Named styles are alphabetised by BTreeMap, so "button" precedes
// "button-primary" precedes "label" in the serialized form.
let button_pos = text.find("\"button\"").unwrap();
let primary_pos = text.find("\"button-primary\"").unwrap();
let label_pos = text.find("\"label\"").unwrap();
assert!(button_pos < primary_pos);
assert!(primary_pos < label_pos);
}
#[test]
fn empty_theme_round_trips_to_empty_ron() {
let empty = Theme::new();
let text = empty.to_ron().unwrap();
let decoded = Theme::from_ron(&text).unwrap();
assert_eq!(empty, decoded);
// The empty theme should not mention either field.
assert!(!text.contains("default:"));
assert!(!text.contains("styles:"));
}
#[test]
fn builder_chaining_inserts_styles_in_order() {
let t = Theme::new()
.with_style("a", VisualStyle::EMPTY)
.with_style("b", VisualStyle::EMPTY);
assert_eq!(t.styles.len(), 2);
assert!(t.styles.contains_key("a"));
assert!(t.styles.contains_key("b"));
}
}