isokit: engine core, theme and Rhai scene runtime with prefab library

This commit is contained in:
grabowski committed 2026-10-22 15:09:46 +07:00
commit 95c80ec6b1
32 files changed
+5896

No files matched your search

+204
View File
@@ -0,0 +1,204 @@
//! isokit themes.
//!
//! A theme is TOML with four parts, all optional:
//!
//! ```toml
//! [palette.dark] # CSS variables --iso-<name>; dark is the default
//! wall = "#7a8496"
//! [palette.light] # applies under html[data-theme=light]
//! wall = "#e3e7ec"
//! [materials] # class iso-<name> sets the face colour --b
//! wall = "wall" # a palette name -> var(--iso-wall)
//! sofa = "#6b5a8e" # or any CSS colour
//! [rules] # any other CSS: selector = declarations
//! ".iso-svg .iso-win" = "fill: var(--iso-win); stroke: none"
//! [extra]
//! css = "..." # appended verbatim
//! ```
//!
//! Themes stack: the built-in default first, then each project theme file;
//! later entries override earlier ones by key, keeping the first position.
use std::fmt::Write;
use toml::Table;
/// The built-in theme (palette, materials and rules of the CNX / Mooban views).
pub const DEFAULT: &str = include_str!("../themes/default.toml");
#[derive(Clone, Debug, Default)]
pub struct Theme {
pub dark: Vec<(String, String)>,
pub light: Vec<(String, String)>,
pub materials: Vec<(String, String)>,
pub rules: Vec<(String, String)>,
pub extra: String,
}
fn upsert(list: &mut Vec<(String, String)>, k: &str, v: String) {
match list.iter_mut().find(|(key, _)| key == k) {
Some(e) => e.1 = v,
None => list.push((k.to_string(), v)),
}
}
fn strings(t: Option<&toml::Value>, what: &str) -> Result<Vec<(String, String)>, String> {
let Some(v) = t else { return Ok(vec![]) };
let tab = v.as_table().ok_or_else(|| format!("[{what}] must be a table"))?;
tab.iter()
.map(|(k, v)| match v {
toml::Value::String(s) => Ok((k.clone(), s.clone())),
toml::Value::Integer(i) => Ok((k.clone(), i.to_string())),
toml::Value::Float(f) => Ok((k.clone(), f.to_string())),
_ => Err(format!("{what}.{k}: expected a string")),
})
.collect()
}
impl Theme {
/// The built-in default theme.
pub fn default_theme() -> Self {
let mut t = Theme::default();
t.merge_str(DEFAULT, "default").expect("built-in theme parses");
t
}
/// Merge one theme layer (TOML text). `name` is used in error messages.
pub fn merge_str(&mut self, src: &str, name: &str) -> Result<(), String> {
let tab: Table = src.parse().map_err(|e| format!("{name}: {e}"))?;
let pal = tab.get("palette").and_then(|p| p.as_table());
let err = |e: String| format!("{name}: {e}");
for (k, v) in strings(pal.and_then(|p| p.get("dark")), "palette.dark").map_err(err)? {
upsert(&mut self.dark, &k, v);
}
for (k, v) in strings(pal.and_then(|p| p.get("light")), "palette.light").map_err(err)? {
upsert(&mut self.light, &k, v);
}
for (k, v) in strings(tab.get("materials"), "materials").map_err(err)? {
upsert(&mut self.materials, &k, v);
}
for (k, v) in strings(tab.get("rules"), "rules").map_err(err)? {
upsert(&mut self.rules, &k, v);
}
if let Some(css) = tab.get("extra").and_then(|e| e.get("css")).and_then(|c| c.as_str()) {
self.extra.push_str(css);
self.extra.push('\n');
}
Ok(())
}
fn is_palette(&self, name: &str) -> bool {
self.dark.iter().any(|(k, _)| k == name) || self.light.iter().any(|(k, _)| k == name)
}
/// Value of a material: a palette name becomes `var(--iso-name)`.
pub fn material_value(&self, v: &str) -> String {
if self.is_palette(v) {
format!("var(--iso-{v})")
} else {
v.to_string()
}
}
/// The theme as CSS. `scope` is the selector the variables hang on
/// (`.iso-wrap` in pages, `svg` in standalone SVG files). `light`:
/// None = dark with a light block under `html[data-theme=light]`,
/// Some(x) = only that scheme.
pub fn css_scoped(&self, scope: &str, light: Option<bool>) -> String {
let mut o = String::with_capacity(32 * 1024);
let block = |o: &mut String, sel: &str, vars: &[(String, String)]| {
if vars.is_empty() {
return;
}
let _ = write!(o, "{sel} {{");
for (k, v) in vars {
let _ = write!(o, " --iso-{k}: {v};");
}
o.push_str(" }\n");
};
match light {
None => {
block(&mut o, scope, &self.dark);
block(&mut o, &format!("html[data-theme=light] {scope}"), &self.light);
}
Some(false) => block(&mut o, scope, &self.dark),
Some(true) => {
block(&mut o, scope, &self.dark);
block(&mut o, scope, &self.light);
}
}
for (k, v) in &self.materials {
let _ = writeln!(o, ".iso-{k} {{ --b: {}; }}", self.material_value(v));
}
for (sel, decl) in &self.rules {
let _ = writeln!(o, "{sel} {{ {decl}; }}");
}
o.push_str(&self.extra);
o
}
/// CSS for a page: dark by default, light under `html[data-theme=light]`.
pub fn css(&self) -> String {
self.css_scoped(".iso-wrap", None)
}
}
/// The default theme with project layers merged on top: (name, TOML text).
pub fn load(layers: &[(&str, &str)]) -> Result<Theme, String> {
let mut t = Theme::default_theme();
for (name, src) in layers {
t.merge_str(src, name)?;
}
Ok(t)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_theme_has_the_core_materials() {
let t = Theme::default_theme();
assert!(t.dark.len() > 100 && t.light.len() == t.dark.len());
let css = t.css();
assert!(css.contains(".iso-wall { --b: var(--iso-wall); }"));
assert!(css.contains("html[data-theme=light] .iso-wrap {"));
assert!(css.contains(".iso-svg .iso-win { fill: var(--iso-win); stroke: none; }"));
}
#[test]
fn project_layer_overrides_and_extends() {
let t = load(&[(
"p",
r##"
[palette.dark]
wall = "#ff0000"
brick = "#aa4433"
[palette.light]
brick = "#cc6655"
[materials]
brick = "brick"
glassy = "#88ccff"
[rules]
".iso-svg .iso-win" = "fill: red"
[extra]
css = ".x { y: z }"
"##,
)])
.unwrap();
let css = t.css();
assert!(css.contains("--iso-wall: #ff0000;"));
assert!(css.contains(".iso-brick { --b: var(--iso-brick); }"));
assert!(css.contains(".iso-glassy { --b: #88ccff; }"));
assert!(css.contains(".iso-svg .iso-win { fill: red; }"));
assert!(!css.contains("fill: var(--iso-win); stroke: none"));
assert!(css.ends_with(".x { y: z }\n"));
// the override keeps the default's position: wall still comes before brick
assert!(css.find("--iso-wall:").unwrap() < css.find("--iso-brick:").unwrap());
}
#[test]
fn bad_layer_reports_its_name() {
let e = load(&[("theme.toml", "[materials]\nx = [1]")]).unwrap_err();
assert!(e.starts_with("theme.toml:"), "{e}");
}
}