Files
Oxide/editor/src/assets.rs
T
Homer d6cb9947b2 Asset database file ops + script-file creation helpers
Groundwork for the Stage-10 editor-UX batch (file explorer, New Script
button), all pure logic proven by unit tests:

- `AssetDatabase::move_asset` / `move_folder` / `delete_asset`: perform
  the disk operation and the uid-map update together, so renaming or
  moving an asset (or a whole folder) keeps its uid and every saved
  `AssetRef` resolving. Kinds re-classify from the new path; `..` and
  existing destinations are refused via the new `AssetDbError` enum;
  deleted uids are never reused.
- Editor `assets.rs`: `script_template` (compiles under the sandboxed
  ScriptEngine — covered by a test), `sanitize_script_stem`, and
  `create_script_file` which writes a collision-free
  `assets/scripts/<stem>.rhai` and returns the relative path for
  database registration.
- docs/assets.md: new "File operations" section + the missing Script
  row in the typed-folder table.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-10 20:29:08 +02:00

249 lines
9.7 KiB
Rust

//! 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. `<exe>/../share/oxide/assets` — the install layout (`bin/` next to
//! `share/`);
//! 3. `<crate>/../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<PathBuf> {
// 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: <prefix>/bin/oxide-editor + <prefix>/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<PathBuf> {
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<bool> {
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 `<assets_dir>/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<String> {
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");
}
}