d6cb9947b2
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>
249 lines
9.7 KiB
Rust
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");
|
|
}
|
|
}
|