isokit: engine core, theme and Rhai scene runtime with prefab library
This commit is contained in:
commit
95c80ec6b1
32 files changed
+5896
No files matched your search
@@ -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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user