//! Bundled editor assets — locating the shared `assets/` tree, seeding a new //! project's default content (currently the default UI font), and creating new //! script files from the built-in template (the inspector's "New Script" //! button). //! //! The editor ships a small set of shared assets (icons, the default UI font, …) //! installed by `install.sh` to `$PREFIX/share/oxide/assets`. At runtime we have //! to find that tree whether the editor is *installed* or run from a *dev* //! checkout, so [`bundled_assets_dir`] resolves it in priority order: //! //! 1. the `OXIDE_ASSETS_DIR` environment variable, if set (explicit override); //! 2. `/../share/oxide/assets` — the install layout (`bin/` next to //! `share/`); //! 3. `/../assets` — the repo's top-level `assets/` for `cargo run`. use std::path::{Path, PathBuf}; /// The default UI font's path, relative to the bundled `assets/` directory and /// to a project's `assets/` directory (they share the typed-folder layout). /// /// Inter (SIL Open Font License) — the variable font's default instance is the /// Regular weight. The license travels next to it as `fonts/OFL.txt`. pub const DEFAULT_UI_FONT_REL: &str = "fonts/InterVariable.ttf"; /// The default UI font's license file, copied alongside the font so a project /// (and any game exported from it) carries the attribution the OFL requires. pub const DEFAULT_UI_FONT_LICENSE_REL: &str = "fonts/OFL.txt"; /// Locates the editor's bundled `assets/` directory, or `None` if no candidate /// exists (e.g. a stripped install missing its share tree). pub fn bundled_assets_dir() -> Option { // 1. Explicit override. if let Some(dir) = std::env::var_os("OXIDE_ASSETS_DIR") { let dir = PathBuf::from(dir); if dir.is_dir() { return Some(dir); } } // 2. Installed layout: /bin/oxide-editor + /share/oxide/assets. if let Ok(exe) = std::env::current_exe() { if let Some(bin_dir) = exe.parent() { if let Some(prefix) = bin_dir.parent() { let installed = prefix.join("share/oxide/assets"); if installed.is_dir() { return Some(installed); } } } } // 3. Dev checkout: the repo's top-level `assets/` sits one level above this // crate (`editor/`). let dev = Path::new(env!("CARGO_MANIFEST_DIR")).join("../assets"); dev.is_dir().then_some(dev) } /// The absolute path of the bundled default UI font, if the assets tree was /// found and the font is present. pub fn default_ui_font_source() -> Option { let path = bundled_assets_dir()?.join(DEFAULT_UI_FONT_REL); path.is_file().then_some(path) } /// Copies the bundled default UI font (and its license) into `project_assets_dir` /// under the same relative path, unless a file is already there. Returns whether /// the font was newly copied. A missing bundle is a no-op (returns `false`). /// /// Called when a project is created so the asset browser has a usable font to /// pick from immediately, referenced by the project-relative path the /// [`AssetDatabase`](oxide_engine::asset::AssetDatabase) records. pub fn seed_default_font(project_assets_dir: &Path) -> std::io::Result { let Some(src) = default_ui_font_source() else { return Ok(false); }; let dst = project_assets_dir.join(DEFAULT_UI_FONT_REL); if dst.exists() { return Ok(false); } if let Some(parent) = dst.parent() { std::fs::create_dir_all(parent)?; } std::fs::copy(&src, &dst)?; // Best-effort: carry the license next to the font (don't fail the seed if // only the license is missing from the bundle). if let Some(bundle) = bundled_assets_dir() { let lic_src = bundle.join(DEFAULT_UI_FONT_LICENSE_REL); if lic_src.is_file() { let _ = std::fs::copy( lic_src, project_assets_dir.join(DEFAULT_UI_FONT_LICENSE_REL), ); } } Ok(true) } /// The `.rhai` source the "New Script" button writes, personalised with the /// script's file stem so the Console output identifies which script speaks. /// /// Kept to the two lifecycle hooks `docs/scripting.md` teaches first; the /// `update` body ships commented out so a freshly created script visibly runs /// (the `init` print) without moving anything until the author opts in. pub fn script_template(stem: &str) -> String { format!( r#"// {stem}.rhai — attached via a Script component. // // Top-level statements run once when the script (re)starts. `init()` runs // once after them; `update(dt)` runs every frame (dt = seconds). fn init() {{ print("{stem}: init"); }} fn update(dt) {{ // e.g. rotate_y(dt * 1.5); }} "# ) } /// Reduces a typed script name to a safe file stem: keeps ASCII alphanumerics, /// `-` and `_`, folds anything else (spaces, punctuation, Unicode) to `_`, /// collapses runs, trims the ends, and drops a trailing `.rhai` the user may /// have typed. An unusable input yields `"new_script"`. pub fn sanitize_script_stem(name: &str) -> String { let trimmed = name.trim(); let trimmed = trimmed.strip_suffix(".rhai").unwrap_or(trimmed); let mut stem = String::with_capacity(trimmed.len()); for c in trimmed.chars() { if c.is_ascii_alphanumeric() || c == '-' || c == '_' { stem.push(c); } else if !stem.ends_with('_') { stem.push('_'); } } let stem = stem.trim_matches('_'); if stem.is_empty() { "new_script".to_owned() } else { stem.to_owned() } } /// Creates a new script file under `/scripts/` from the template, /// returning its assets-relative path (e.g. `"scripts/my_script.rhai"`) for /// registration in the [`AssetDatabase`](oxide_engine::asset::AssetDatabase). /// /// The desired name is [sanitized](sanitize_script_stem); a taken name gets a /// numeric suffix (`stem_2`, `stem_3`, …) instead of failing or overwriting, /// so the button always succeeds on a writable project. pub fn create_script_file(assets_dir: &Path, desired_name: &str) -> std::io::Result { use oxide_engine::asset::AssetKind; let stem = sanitize_script_stem(desired_name); let dir = assets_dir.join(AssetKind::Script.folder()); std::fs::create_dir_all(&dir)?; let mut candidate = stem.clone(); let mut n = 1; while dir.join(format!("{candidate}.rhai")).exists() { n += 1; candidate = format!("{stem}_{n}"); } std::fs::write( dir.join(format!("{candidate}.rhai")), script_template(&candidate), )?; Ok(format!("{}/{candidate}.rhai", AssetKind::Script.folder())) } #[cfg(test)] mod tests { use super::*; #[test] fn bundle_resolves_in_dev_checkout() { // Running tests from the workspace, the dev-checkout fallback (3) finds // the repo's top-level assets/ with the bundled font. let dir = bundled_assets_dir().expect("bundled assets dir should resolve in dev"); assert!( dir.join(DEFAULT_UI_FONT_REL).is_file(), "default font present" ); assert!(default_ui_font_source().is_some()); } #[test] fn seed_copies_font_once() { let mut tmp = std::env::temp_dir(); tmp.push(format!("oxide_seedfont_{}", std::process::id())); let assets = tmp.join("assets"); std::fs::create_dir_all(&assets).unwrap(); assert!(seed_default_font(&assets).unwrap(), "first seed copies"); assert!(assets.join(DEFAULT_UI_FONT_REL).is_file()); // Idempotent: a second seed finds the file already present. assert!( !seed_default_font(&assets).unwrap(), "second seed is a no-op" ); std::fs::remove_dir_all(tmp).ok(); } #[test] fn sanitize_covers_typed_names() { assert_eq!(sanitize_script_stem("spin"), "spin"); assert_eq!(sanitize_script_stem(" My Cool Script! "), "My_Cool_Script"); assert_eq!(sanitize_script_stem("door.rhai"), "door"); assert_eq!(sanitize_script_stem("a//b\\c"), "a_b_c"); assert_eq!(sanitize_script_stem("čárka"), "rka"); // Unusable inputs fall back rather than producing "" or "_". assert_eq!(sanitize_script_stem(""), "new_script"); assert_eq!(sanitize_script_stem("!!!"), "new_script"); assert_eq!(sanitize_script_stem(".rhai"), "new_script"); } #[test] fn create_script_writes_template_and_dodges_collisions() { let mut tmp = std::env::temp_dir(); tmp.push(format!("oxide_newscript_{}", std::process::id())); let assets = tmp.join("assets"); std::fs::create_dir_all(&assets).unwrap(); let rel = create_script_file(&assets, "door opener").unwrap(); assert_eq!(rel, "scripts/door_opener.rhai"); let text = std::fs::read_to_string(assets.join(&rel)).unwrap(); assert!(text.contains("fn update(dt)")); // Same name again: suffixed, nothing overwritten. let rel2 = create_script_file(&assets, "door opener").unwrap(); assert_eq!(rel2, "scripts/door_opener_2.rhai"); let rel3 = create_script_file(&assets, "door opener").unwrap(); assert_eq!(rel3, "scripts/door_opener_3.rhai"); std::fs::remove_dir_all(tmp).ok(); } #[test] fn template_compiles_in_the_script_engine() { // The template must never ship a syntax error: compile it exactly as // the runtime would. let asset = oxide_script::ScriptAsset::from_source( "new_script.rhai", script_template("new_script"), ); let engine = oxide_script::ScriptEngine::new(); engine.compile(&asset).expect("template compiles"); } }